mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 15:00:43 +00:00
indentity store instead
This commit is contained in:
@@ -662,7 +662,8 @@
|
||||
"tag_too_short": "Clan tag must be 2-5 alphanumeric characters.",
|
||||
"tag_too_long": "Clan tag cannot exceed 5 characters.",
|
||||
"tag_invalid_chars": "Clan tag can only contain letters and numbers.",
|
||||
"tag_not_member": "Join the {tag} clan before using its tag."
|
||||
"tag_not_member": "Join the {tag} clan before using its tag.",
|
||||
"tag_checking": "Checking clan tag…"
|
||||
},
|
||||
"host_modal": {
|
||||
"title": "Create Private Lobby",
|
||||
|
||||
+51
-157
@@ -1,17 +1,18 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import { translateText } from "../client/Utils";
|
||||
import { sanitizeClanTag } from "../core/Util";
|
||||
import {
|
||||
MAX_CLAN_TAG_LENGTH,
|
||||
MIN_CLAN_TAG_LENGTH,
|
||||
validateClanTag,
|
||||
} from "../core/validations/username";
|
||||
import { getUserMe } from "./Api";
|
||||
import { fetchClanExists } from "./ClanApi";
|
||||
|
||||
const CLAN_OWNERSHIP_DEBOUNCE_MS = 400;
|
||||
const clanTagKey = "clanTag";
|
||||
import { IdentityReadyController } from "./identity/IdentityReadyController";
|
||||
import {
|
||||
awaitIdentityReady,
|
||||
getClanTagForSubmit,
|
||||
initIdentityFromStorage,
|
||||
revalidateIdentityTranslations,
|
||||
setClanTag,
|
||||
} from "./identity/IdentityStore";
|
||||
|
||||
interface LangSelectorLike {
|
||||
currentLang?: string;
|
||||
@@ -21,16 +22,7 @@ interface LangSelectorLike {
|
||||
|
||||
@customElement("clan-tag-input")
|
||||
export class ClanTagInput extends LitElement {
|
||||
@state() private clanTag: string = "";
|
||||
|
||||
@property({ type: String }) validationError: string = "";
|
||||
|
||||
private formatError: string = "";
|
||||
private ownershipError: string = "";
|
||||
private checkCounter: number = 0;
|
||||
private checkTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private currentCheck: Promise<void> = Promise.resolve();
|
||||
private resolveDebounce: (() => void) | null = null;
|
||||
private identity = new IdentityReadyController(this);
|
||||
private lastTranslatedLang: string | null = null;
|
||||
|
||||
createRenderRoot() {
|
||||
@@ -38,42 +30,22 @@ export class ClanTagInput extends LitElement {
|
||||
}
|
||||
|
||||
public isValid(): boolean {
|
||||
return this.formatError === "" && this.ownershipError === "";
|
||||
return this.identity.state.clanTag.valid;
|
||||
}
|
||||
|
||||
public getValue(): string | null {
|
||||
return this.isValid() &&
|
||||
this.clanTag.length >= MIN_CLAN_TAG_LENGTH &&
|
||||
this.clanTag.length <= MAX_CLAN_TAG_LENGTH &&
|
||||
validateClanTag(this.clanTag).isValid
|
||||
? this.clanTag
|
||||
: null;
|
||||
return getClanTagForSubmit();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.clanTag = localStorage.getItem(clanTagKey) ?? "";
|
||||
// No user input to coalesce on initial mount — fire the ownership check
|
||||
// immediately instead of paying the debounce delay.
|
||||
this.validate({ immediate: true });
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this.checkTimer !== null) {
|
||||
clearTimeout(this.checkTimer);
|
||||
this.checkTimer = null;
|
||||
}
|
||||
this.checkCounter++; // cancel any in-flight async check
|
||||
if (this.resolveDebounce) this.resolveDebounce();
|
||||
this.resolveDebounce = null;
|
||||
this.currentCheck = Promise.resolve();
|
||||
initIdentityFromStorage();
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
// Re-validate when translations finish loading so the initial error
|
||||
// (which may have been built from raw keys) gets re-translated.
|
||||
if (!this.validationError) return;
|
||||
// Re-translate any error string when the active language changes — the
|
||||
// store caches the i18n key for ownership errors, but format errors are
|
||||
// raw translated strings that need to be regenerated.
|
||||
const ls = document.querySelector<LangSelectorLike & Element>(
|
||||
"lang-selector",
|
||||
);
|
||||
@@ -81,38 +53,62 @@ export class ClanTagInput extends LitElement {
|
||||
const hasTranslations = ls?.translations ?? ls?.defaultTranslations;
|
||||
if (hasTranslations && lang && lang !== this.lastTranslatedLang) {
|
||||
this.lastTranslatedLang = lang;
|
||||
this.validate();
|
||||
revalidateIdentityTranslations();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { value, error } = this.identity.state.clanTag;
|
||||
const checking = this.identity.validating;
|
||||
const displayError = this.translatedError(error);
|
||||
return html`
|
||||
<div class="relative flex items-center h-full">
|
||||
<input
|
||||
type="text"
|
||||
.value=${this.clanTag}
|
||||
.value=${value}
|
||||
@input=${this.handleInput}
|
||||
placeholder="${translateText("username.tag")}"
|
||||
minlength="${MIN_CLAN_TAG_LENGTH}"
|
||||
maxlength="${MAX_CLAN_TAG_LENGTH}"
|
||||
aria-busy=${checking ? "true" : "false"}
|
||||
aria-invalid=${displayError ? "true" : "false"}
|
||||
class="w-[6rem] text-xl font-medium tracking-wider text-center uppercase shrink-0 bg-transparent text-white placeholder-white/70 focus:placeholder-transparent border-0 border-b border-white/40 focus:outline-none focus:border-white/60"
|
||||
/>
|
||||
${this.validationError
|
||||
${checking
|
||||
? html`<span
|
||||
class="absolute right-1 top-1/2 -translate-y-1/2 w-3 h-3 border-2 border-white/30 border-t-white/80 rounded-full animate-spin pointer-events-none"
|
||||
aria-hidden="true"
|
||||
></span>`
|
||||
: null}
|
||||
${displayError
|
||||
? html`<div
|
||||
id="clan-tag-validation-error"
|
||||
class="absolute top-full left-0 z-50 mt-1 px-3 py-2 text-sm font-medium border border-red-500/50 rounded-lg bg-red-900/90 text-red-200 backdrop-blur-md shadow-lg whitespace-nowrap"
|
||||
>
|
||||
${this.validationError}
|
||||
${displayError}
|
||||
</div>`
|
||||
: null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private translatedError(raw: string): string {
|
||||
if (!raw) return "";
|
||||
// Ownership errors are stored as i18n keys (with optional tag param);
|
||||
// format errors are already-translated strings from validateClanTag.
|
||||
if (raw === "username.tag_not_member") {
|
||||
return translateText(raw, { tag: this.identity.state.clanTag.value });
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
private handleInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const sanitized = sanitizeClanTag(input.value);
|
||||
if (input.value.toUpperCase() !== sanitized) {
|
||||
const raw = input.value;
|
||||
const upper = raw.toUpperCase();
|
||||
setClanTag(raw);
|
||||
const sanitized = this.identity.state.clanTag.value;
|
||||
if (upper !== sanitized) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: {
|
||||
@@ -124,120 +120,18 @@ export class ClanTagInput extends LitElement {
|
||||
);
|
||||
}
|
||||
input.value = sanitized;
|
||||
this.clanTag = sanitized;
|
||||
this.validate();
|
||||
}
|
||||
|
||||
private validate(options: { immediate?: boolean } = {}) {
|
||||
const tag = this.clanTag;
|
||||
const result = validateClanTag(tag);
|
||||
this.formatError = result.isValid ? "" : (result.error ?? "");
|
||||
|
||||
// Cancel any pending/in-flight ownership check. checkCounter++ marks
|
||||
// any in-flight async work obsolete (stillCurrent() in checkOwnership
|
||||
// returns false). Resolve the prior debounce so awaitValidation()
|
||||
// callers don't hang on the cancelled chain.
|
||||
if (this.checkTimer !== null) clearTimeout(this.checkTimer);
|
||||
this.checkTimer = null;
|
||||
this.checkCounter++;
|
||||
if (this.resolveDebounce) this.resolveDebounce();
|
||||
this.resolveDebounce = null;
|
||||
|
||||
if (!result.isValid || tag.length === 0) {
|
||||
// Nothing to ask the server about — clear any old ownership error
|
||||
// and wipe the stored tag so a reload doesn't restore a stale value
|
||||
// that no longer matches the current (invalid/empty) input.
|
||||
this.ownershipError = "";
|
||||
localStorage.setItem(clanTagKey, "");
|
||||
this.currentCheck = Promise.resolve();
|
||||
} else {
|
||||
// Snapshot the generation so cancelled debounce chains skip the API
|
||||
// round-trip entirely — checkOwnership's internal stillCurrent() only
|
||||
// fires after getUserMe() has already returned.
|
||||
const generation = this.checkCounter;
|
||||
const run = (): Promise<void> => {
|
||||
if (generation !== this.checkCounter) return Promise.resolve();
|
||||
return this.checkOwnership(tag);
|
||||
};
|
||||
if (options.immediate) {
|
||||
// Initial mount / non-typing trigger — no input to coalesce, run now.
|
||||
this.currentCheck = run();
|
||||
} else {
|
||||
const debounce = new Promise<void>((resolve) => {
|
||||
this.resolveDebounce = resolve;
|
||||
});
|
||||
this.checkTimer = setTimeout(() => {
|
||||
this.checkTimer = null;
|
||||
const resolve = this.resolveDebounce;
|
||||
this.resolveDebounce = null;
|
||||
resolve?.();
|
||||
}, CLAN_OWNERSHIP_DEBOUNCE_MS);
|
||||
this.currentCheck = debounce.then(run);
|
||||
}
|
||||
}
|
||||
|
||||
this.refreshError();
|
||||
}
|
||||
|
||||
// Resolves once the latest validate() chain finishes — either the debounce
|
||||
// timer + ownership check, or immediately if the input is invalid/empty.
|
||||
// Resolves once any in-flight async ownership check settles. Returns
|
||||
// immediately when nothing is in flight.
|
||||
public async awaitValidation(): Promise<void> {
|
||||
let last: Promise<void> | undefined;
|
||||
while (this.currentCheck !== last) {
|
||||
last = this.currentCheck;
|
||||
await last;
|
||||
}
|
||||
}
|
||||
|
||||
// Are you a member? If not, only accept when the API confirms the clan
|
||||
// doesn't exist (fictional). Inconclusive results (null/timeout) reject so
|
||||
// the client matches the server's fail-closed enforcement — otherwise the
|
||||
// client would let the modal open with a tag the server later drops.
|
||||
private async checkOwnership(tag: string) {
|
||||
const checkId = this.checkCounter;
|
||||
const stillCurrent = () =>
|
||||
checkId === this.checkCounter && this.clanTag === tag;
|
||||
|
||||
const me = await getUserMe();
|
||||
if (!stillCurrent()) return;
|
||||
const myTags = me
|
||||
? (me.player.clans ?? []).map((c) => c.tag.toUpperCase())
|
||||
: [];
|
||||
|
||||
if (!myTags.includes(tag.toUpperCase())) {
|
||||
const exists = await fetchClanExists(tag);
|
||||
if (!stillCurrent()) return;
|
||||
if (exists !== false) {
|
||||
this.reject(tag);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.accept(tag);
|
||||
}
|
||||
|
||||
private accept(tag: string) {
|
||||
this.ownershipError = "";
|
||||
localStorage.setItem(clanTagKey, tag);
|
||||
this.refreshError();
|
||||
}
|
||||
|
||||
private reject(tag: string) {
|
||||
this.ownershipError = translateText("username.tag_not_member", { tag });
|
||||
localStorage.removeItem(clanTagKey);
|
||||
this.refreshError();
|
||||
}
|
||||
|
||||
private refreshError() {
|
||||
const next = this.formatError || this.ownershipError;
|
||||
if (this.validationError !== next) {
|
||||
this.validationError = next;
|
||||
this.requestUpdate();
|
||||
}
|
||||
await awaitIdentityReady();
|
||||
}
|
||||
|
||||
public showValidationFeedback() {
|
||||
const message =
|
||||
this.validationError || translateText("username.tag_invalid_chars");
|
||||
this.translatedError(this.identity.state.clanTag.error) ||
|
||||
translateText("username.tag_invalid_chars");
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: { message, color: "red", duration: 2500 },
|
||||
|
||||
@@ -10,16 +10,15 @@ import {
|
||||
Trios,
|
||||
} from "../core/game/Game";
|
||||
import { PublicGameInfo, PublicGames } from "../core/Schemas";
|
||||
import { ClanTagInput } from "./ClanTagInput";
|
||||
import "./components/IOSAddToHomeScreenBanner";
|
||||
import { crazyGamesSDK } from "./CrazyGamesSDK";
|
||||
import { HostLobbyModal } from "./HostLobbyModal";
|
||||
import { IdentityReadyController } from "./identity/IdentityReadyController";
|
||||
import { JoinLobbyModal } from "./JoinLobbyModal";
|
||||
import { PublicLobbySocket } from "./LobbySocket";
|
||||
import { JoinLobbyEvent } from "./Main";
|
||||
import { SinglePlayerModal } from "./SinglePlayerModal";
|
||||
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
|
||||
import { UsernameInput } from "./UsernameInput";
|
||||
import {
|
||||
calculateServerTimeOffset,
|
||||
getMapName,
|
||||
@@ -42,27 +41,20 @@ export class GameModeSelector extends LitElement {
|
||||
this.handleLobbiesUpdate(lobbies),
|
||||
);
|
||||
|
||||
private identity = new IdentityReadyController(this);
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates username and clan tag inputs and shows error messages if invalid.
|
||||
* Awaits any pending clan-tag ownership check so the gate doesn't pass while
|
||||
* an async validation is still in flight.
|
||||
*/
|
||||
private async validateIdentity(): Promise<boolean> {
|
||||
const clanTagInput = document.querySelector(
|
||||
"clan-tag-input",
|
||||
) as ClanTagInput | null;
|
||||
if (clanTagInput) {
|
||||
await clanTagInput.awaitValidation();
|
||||
if (!clanTagInput.validateOrShowError()) return false;
|
||||
private identityTooltip(): string {
|
||||
if (this.identity.validating) {
|
||||
return translateText("username.tag_checking");
|
||||
}
|
||||
const usernameInput = document.querySelector(
|
||||
"username-input",
|
||||
) as UsernameInput | null;
|
||||
return usernameInput ? usernameInput.validateOrShowError() : true;
|
||||
const { username, clanTag } = this.identity.state;
|
||||
if (!username.valid) return username.error;
|
||||
if (!clanTag.valid) return clanTag.error;
|
||||
return "";
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
@@ -238,25 +230,25 @@ export class GameModeSelector extends LitElement {
|
||||
return this.renderLobbyCard(lobby, this.getLobbyTitle(lobby));
|
||||
}
|
||||
|
||||
private openRankedMenu = async () => {
|
||||
if (!(await this.validateIdentity())) return;
|
||||
private openRankedMenu = () => {
|
||||
if (!this.identity.ready) return;
|
||||
window.showPage?.("page-ranked");
|
||||
};
|
||||
|
||||
private openSinglePlayerModal = async () => {
|
||||
if (!(await this.validateIdentity())) return;
|
||||
private openSinglePlayerModal = () => {
|
||||
if (!this.identity.ready) return;
|
||||
(
|
||||
document.querySelector("single-player-modal") as SinglePlayerModal
|
||||
)?.open();
|
||||
};
|
||||
|
||||
private openHostLobby = async () => {
|
||||
if (!(await this.validateIdentity())) return;
|
||||
private openHostLobby = () => {
|
||||
if (!this.identity.ready) return;
|
||||
(document.querySelector("host-lobby-modal") as HostLobbyModal)?.open();
|
||||
};
|
||||
|
||||
private openJoinLobby = async () => {
|
||||
if (!(await this.validateIdentity())) return;
|
||||
private openJoinLobby = () => {
|
||||
if (!this.identity.ready) return;
|
||||
(document.querySelector("join-lobby-modal") as JoinLobbyModal)?.open();
|
||||
};
|
||||
|
||||
@@ -265,10 +257,15 @@ export class GameModeSelector extends LitElement {
|
||||
onClick: () => void,
|
||||
bgClass: string = CARD_BG,
|
||||
) {
|
||||
const disabled = !this.identity.ready;
|
||||
const tip = this.identityTooltip();
|
||||
return html`
|
||||
<button
|
||||
@click=${onClick}
|
||||
class="flex items-center justify-center w-full h-full rounded-lg ${bgClass} transition-all duration-200 text-sm lg:text-base font-medium text-white uppercase tracking-wider text-center"
|
||||
?disabled=${disabled}
|
||||
title=${disabled && tip ? tip : ""}
|
||||
aria-busy=${this.identity.validating ? "true" : "false"}
|
||||
class="flex items-center justify-center w-full h-full rounded-lg ${bgClass} transition-all duration-200 text-sm lg:text-base font-medium text-white uppercase tracking-wider text-center disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 disabled:hover:brightness-100"
|
||||
>
|
||||
${title}
|
||||
</button>
|
||||
@@ -311,10 +308,15 @@ export class GameModeSelector extends LitElement {
|
||||
modifierLabels.sort((a, b) => a.length - b.length);
|
||||
}
|
||||
|
||||
const disabled = !this.identity.ready;
|
||||
const tip = this.identityTooltip();
|
||||
return html`
|
||||
<button
|
||||
@click=${() => this.validateAndJoin(lobby)}
|
||||
class="group relative w-full h-44 sm:h-full text-white uppercase rounded-2xl transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] bg-surface hover:shadow-[var(--shadow-lobby-card-hover)]"
|
||||
@click=${() => this.joinPublicLobby(lobby)}
|
||||
?disabled=${disabled}
|
||||
title=${disabled && tip ? tip : ""}
|
||||
aria-busy=${this.identity.validating ? "true" : "false"}
|
||||
class="group relative w-full h-44 sm:h-full text-white uppercase rounded-2xl transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] bg-surface hover:shadow-[var(--shadow-lobby-card-hover)] disabled:opacity-60 disabled:cursor-not-allowed disabled:hover:scale-100"
|
||||
>
|
||||
<!-- Image clipped separately so overflow-hidden doesn't block absolute children -->
|
||||
<div
|
||||
@@ -390,8 +392,8 @@ export class GameModeSelector extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private async validateAndJoin(lobby: PublicGameInfo) {
|
||||
if (!(await this.validateIdentity())) return;
|
||||
private joinPublicLobby(lobby: PublicGameInfo) {
|
||||
if (!this.identity.ready) return;
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("join-lobby", {
|
||||
|
||||
@@ -29,6 +29,7 @@ import "./components/LobbyPlayerView";
|
||||
import "./components/ToggleInputCard";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
import { crazyGamesSDK } from "./CrazyGamesSDK";
|
||||
import { IdentityReadyController } from "./identity/IdentityReadyController";
|
||||
import { JoinLobbyEvent } from "./Main";
|
||||
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
|
||||
import {
|
||||
@@ -94,6 +95,7 @@ export class HostLobbyModal extends BaseModal {
|
||||
private nationsUpdateTimer: number | null = null;
|
||||
private mapLoader = terrainMapFileLoader;
|
||||
private userSettings = new UserSettings();
|
||||
private identity = new IdentityReadyController(this);
|
||||
|
||||
private leaveLobbyOnClose = true;
|
||||
|
||||
@@ -417,10 +419,12 @@ export class HostLobbyModal extends BaseModal {
|
||||
variant="primary"
|
||||
width="block"
|
||||
size="lg"
|
||||
.title=${this.clients.length === 1
|
||||
? translateText("host_modal.waiting")
|
||||
: translateText("host_modal.start")}
|
||||
?disable=${this.clients.length < 2}
|
||||
.title=${this.identity.validating
|
||||
? translateText("username.tag_checking")
|
||||
: this.clients.length === 1
|
||||
? translateText("host_modal.waiting")
|
||||
: translateText("host_modal.start")}
|
||||
?disable=${this.clients.length < 2 || !this.identity.ready}
|
||||
@click=${this.startGame}
|
||||
></o-button>
|
||||
</div>
|
||||
|
||||
+15
-32
@@ -22,7 +22,6 @@ import { getUserMe, invalidateUserMe } from "./Api";
|
||||
import { userAuth } from "./Auth";
|
||||
import "./ClanModal";
|
||||
import "./ClanTagInput";
|
||||
import { ClanTagInput } from "./ClanTagInput";
|
||||
import { joinLobby, type JoinLobbyResult } from "./ClientGameRunner";
|
||||
import { getPlayerCosmeticsRefs } from "./Cosmetics";
|
||||
import { crazyGamesSDK } from "./CrazyGamesSDK";
|
||||
@@ -61,13 +60,18 @@ import {
|
||||
} from "./Transport";
|
||||
import { UserSettingModal } from "./UserSettingModal";
|
||||
import "./UsernameInput";
|
||||
import { genAnonUsername, UsernameInput } from "./UsernameInput";
|
||||
import { genAnonUsername } from "./UsernameInput";
|
||||
import {
|
||||
getDiscordAvatarUrl,
|
||||
incrementGamesPlayed,
|
||||
isInIframe,
|
||||
translateText,
|
||||
} from "./Utils";
|
||||
import {
|
||||
awaitIdentityReady,
|
||||
getClanTagForSubmit,
|
||||
getUsernameForSubmit,
|
||||
} from "./identity/IdentityStore";
|
||||
import { installSafariPinchZoomBlocker } from "./utilities/DisableSafariPinchZoom";
|
||||
|
||||
import "./components/DesktopNavBar";
|
||||
@@ -252,8 +256,6 @@ class Client {
|
||||
|
||||
private currentUrl: string | null = null;
|
||||
|
||||
private usernameInput: UsernameInput | null = null;
|
||||
private clanTagInput: ClanTagInput | null = null;
|
||||
private flagInput: FlagInput | null = null;
|
||||
|
||||
private hostModal: HostPrivateLobbyModal;
|
||||
@@ -357,20 +359,6 @@ class Client {
|
||||
console.warn("Flag input element not found");
|
||||
}
|
||||
|
||||
this.usernameInput = document.querySelector(
|
||||
"username-input",
|
||||
) as UsernameInput;
|
||||
if (!this.usernameInput) {
|
||||
console.warn("Username input element not found");
|
||||
}
|
||||
|
||||
this.clanTagInput = document.querySelector(
|
||||
"clan-tag-input",
|
||||
) as ClanTagInput;
|
||||
if (!this.clanTagInput) {
|
||||
console.warn("Clan tag input element not found");
|
||||
}
|
||||
|
||||
this.gameModeSelector = document.querySelector(
|
||||
"game-mode-selector",
|
||||
) as GameModeSelector;
|
||||
@@ -824,11 +812,13 @@ class Client {
|
||||
private async handleJoinLobby(event: CustomEvent<JoinLobbyEvent>) {
|
||||
const lobby = event.detail;
|
||||
this.mostRecentJoinEvent = event.timeStamp;
|
||||
if (this.clanTagInput) {
|
||||
await this.clanTagInput.awaitValidation();
|
||||
if (!this.clanTagInput.validateOrShowError()) return;
|
||||
}
|
||||
if (this.usernameInput && !this.usernameInput.validateOrShowError()) {
|
||||
// Final identity gate. Callers (play buttons, modals) already disable
|
||||
// themselves until identity is ready, so this is a defense-in-depth check
|
||||
// for stray dispatches — bail silently rather than show a toast since
|
||||
// the inputs already render their own inline errors.
|
||||
const ready = await awaitIdentityReady();
|
||||
if (!ready) {
|
||||
console.warn("join-lobby blocked: identity not ready");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -854,8 +844,8 @@ class Client {
|
||||
gameID: lobby.gameID,
|
||||
cosmetics: await getPlayerCosmeticsRefs(),
|
||||
turnstileToken: await this.getTurnstileToken(lobby),
|
||||
playerName: this.usernameInput?.getUsername() ?? genAnonUsername(),
|
||||
playerClanTag: this.clanTagInput?.getValue() ?? null,
|
||||
playerName: getUsernameForSubmit() || genAnonUsername(),
|
||||
playerClanTag: getClanTagForSubmit(),
|
||||
playerRole,
|
||||
gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info,
|
||||
gameRecord: lobby.gameRecord,
|
||||
@@ -872,13 +862,6 @@ class Client {
|
||||
this.lobbyHandle.prestart.then(() => {
|
||||
console.log("Closing modals");
|
||||
document.getElementById("settings-button")?.classList.add("hidden");
|
||||
if (this.usernameInput) {
|
||||
// fix edge case where username-validation-error is re-rendered and hidden tag removed
|
||||
this.usernameInput.validationError = "";
|
||||
}
|
||||
if (this.clanTagInput) {
|
||||
this.clanTagInput.validationError = "";
|
||||
}
|
||||
document
|
||||
.getElementById("clan-tag-validation-error")
|
||||
?.classList.add("hidden");
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
import { TeamCountConfig } from "../core/Schemas";
|
||||
import { generateID } from "../core/Util";
|
||||
import { hasLinkedAccount } from "./Api";
|
||||
import { ClanTagInput } from "./ClanTagInput";
|
||||
import "./components/baseComponents/Button";
|
||||
import "./components/baseComponents/Modal";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
@@ -23,8 +22,12 @@ import "./components/ToggleInputCard";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
import { getPlayerCosmetics } from "./Cosmetics";
|
||||
import { crazyGamesSDK } from "./CrazyGamesSDK";
|
||||
import { IdentityReadyController } from "./identity/IdentityReadyController";
|
||||
import {
|
||||
getClanTagForSubmit,
|
||||
getUsernameForSubmit,
|
||||
} from "./identity/IdentityStore";
|
||||
import { JoinLobbyEvent } from "./Main";
|
||||
import { UsernameInput } from "./UsernameInput";
|
||||
import {
|
||||
getBotsForCompactMap,
|
||||
getNationsForCompactMap,
|
||||
@@ -99,6 +102,7 @@ export class SinglePlayerModal extends BaseModal {
|
||||
@state() private disableAlliances: boolean = DEFAULT_OPTIONS.disableAlliances;
|
||||
@state() private waterNukes: boolean = DEFAULT_OPTIONS.waterNukes;
|
||||
|
||||
private identity = new IdentityReadyController(this);
|
||||
private mapLoader = terrainMapFileLoader;
|
||||
|
||||
connectedCallback() {
|
||||
@@ -355,7 +359,12 @@ export class SinglePlayerModal extends BaseModal {
|
||||
variant="primary"
|
||||
width="block"
|
||||
size="lg"
|
||||
translationKey="single_modal.start"
|
||||
translationKey=${
|
||||
this.identity.validating
|
||||
? "username.tag_checking"
|
||||
: "single_modal.start"
|
||||
}
|
||||
?disable=${!this.identity.ready}
|
||||
@click=${this.startGame}
|
||||
></o-button>
|
||||
</div>
|
||||
@@ -644,24 +653,6 @@ export class SinglePlayerModal extends BaseModal {
|
||||
const clientID = generateID();
|
||||
const gameID = generateID();
|
||||
|
||||
const usernameInput = document.querySelector(
|
||||
"username-input",
|
||||
) as UsernameInput;
|
||||
const clanTagInput = document.querySelector(
|
||||
"clan-tag-input",
|
||||
) as ClanTagInput | null;
|
||||
|
||||
// Validate identity before dispatching/closing so a failed clan-tag
|
||||
// ownership check keeps the modal open with the user's configured game
|
||||
// settings intact rather than silently dropping them.
|
||||
if (clanTagInput) {
|
||||
await clanTagInput.awaitValidation();
|
||||
if (!clanTagInput.validateOrShowError()) return;
|
||||
}
|
||||
if (usernameInput && !usernameInput.validateOrShowError()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await crazyGamesSDK.requestMidgameAd();
|
||||
|
||||
this.dispatchEvent(
|
||||
@@ -673,8 +664,8 @@ export class SinglePlayerModal extends BaseModal {
|
||||
players: [
|
||||
{
|
||||
clientID,
|
||||
username: usernameInput.getUsername(),
|
||||
clanTag: clanTagInput?.getValue() ?? null,
|
||||
username: getUsernameForSubmit(),
|
||||
clanTag: getClanTagForSubmit(),
|
||||
cosmetics: await getPlayerCosmetics(),
|
||||
},
|
||||
],
|
||||
|
||||
+46
-74
@@ -1,12 +1,18 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import { generateCryptoRandomUUID, translateText } from "../client/Utils";
|
||||
import {
|
||||
MAX_USERNAME_LENGTH,
|
||||
MIN_USERNAME_LENGTH,
|
||||
validateUsername,
|
||||
} from "../core/validations/username";
|
||||
import { crazyGamesSDK } from "./CrazyGamesSDK";
|
||||
import { IdentityReadyController } from "./identity/IdentityReadyController";
|
||||
import {
|
||||
getUsernameForSubmit,
|
||||
initIdentityFromStorage,
|
||||
revalidateIdentityTranslations,
|
||||
setUsername,
|
||||
} from "./identity/IdentityStore";
|
||||
|
||||
interface LangSelectorLike {
|
||||
currentLang?: string;
|
||||
@@ -14,100 +20,84 @@ interface LangSelectorLike {
|
||||
defaultTranslations?: Record<string, string>;
|
||||
}
|
||||
|
||||
const usernameKey: string = "username";
|
||||
|
||||
@customElement("username-input")
|
||||
export class UsernameInput extends LitElement {
|
||||
@state() private baseUsername: string = "";
|
||||
|
||||
@property({ type: String }) validationError: string = "";
|
||||
private _isValid: boolean = true;
|
||||
private _lastValidatedLang: string | null = null;
|
||||
private identity = new IdentityReadyController(this);
|
||||
private lastTranslatedLang: string | null = null;
|
||||
|
||||
createRenderRoot() {
|
||||
// Disable shadow DOM to allow Tailwind classes to work
|
||||
return this;
|
||||
}
|
||||
|
||||
public getUsername(): string {
|
||||
return this.baseUsername.trim();
|
||||
return getUsernameForSubmit();
|
||||
}
|
||||
|
||||
public isValid(): boolean {
|
||||
return this.identity.state.username.valid;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.loadStoredUsername();
|
||||
initIdentityFromStorage();
|
||||
// Fall back to an anonymous handle the first time a user shows up with
|
||||
// nothing in storage, so the field isn't empty (which would fail
|
||||
// validation immediately and block play).
|
||||
if (getUsernameForSubmit().length === 0) {
|
||||
setUsername(genAnonUsername());
|
||||
}
|
||||
crazyGamesSDK.getUsername().then((username) => {
|
||||
if (username) {
|
||||
this.baseUsername = username;
|
||||
this.validateAndStore();
|
||||
}
|
||||
if (username) setUsername(username);
|
||||
});
|
||||
crazyGamesSDK.addAuthListener((user) => {
|
||||
if (user) {
|
||||
this.baseUsername = user.username;
|
||||
this.validateAndStore();
|
||||
}
|
||||
if (user) setUsername(user.username);
|
||||
});
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
// Re-validate when translations become available or language changes,
|
||||
// since initial validation may run before translations are loaded.
|
||||
if (this.validationError) {
|
||||
const langSelector = document.querySelector<LangSelectorLike & Element>(
|
||||
"lang-selector",
|
||||
);
|
||||
const lang = langSelector?.currentLang;
|
||||
const hasTranslations =
|
||||
langSelector?.translations ?? langSelector?.defaultTranslations;
|
||||
if (hasTranslations && lang && lang !== this._lastValidatedLang) {
|
||||
this._lastValidatedLang = lang;
|
||||
this.validateAndStore();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private loadStoredUsername() {
|
||||
const storedUsername = localStorage.getItem(usernameKey);
|
||||
if (storedUsername) {
|
||||
this.baseUsername = storedUsername;
|
||||
this.validateAndStore();
|
||||
} else {
|
||||
this.baseUsername = genAnonUsername();
|
||||
this.validateAndStore();
|
||||
const ls = document.querySelector<LangSelectorLike & Element>(
|
||||
"lang-selector",
|
||||
);
|
||||
const lang = ls?.currentLang;
|
||||
const hasTranslations = ls?.translations ?? ls?.defaultTranslations;
|
||||
if (hasTranslations && lang && lang !== this.lastTranslatedLang) {
|
||||
this.lastTranslatedLang = lang;
|
||||
revalidateIdentityTranslations();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { value, error } = this.identity.state.username;
|
||||
return html`
|
||||
<div class="relative w-full h-full">
|
||||
<input
|
||||
type="text"
|
||||
.value=${this.baseUsername}
|
||||
@input=${this.handleUsernameChange}
|
||||
.value=${value}
|
||||
@input=${this.handleInput}
|
||||
placeholder="${translateText("username.enter_username")}"
|
||||
minlength="${MIN_USERNAME_LENGTH}"
|
||||
maxlength="${MAX_USERNAME_LENGTH}"
|
||||
aria-invalid=${error ? "true" : "false"}
|
||||
class="w-full h-full border-0 text-2xl font-medium tracking-wider text-left text-white placeholder-white/70 focus:outline-none focus:ring-0 overflow-x-auto whitespace-nowrap text-ellipsis pr-2 bg-transparent"
|
||||
/>
|
||||
${this.validationError
|
||||
${error
|
||||
? html`<div
|
||||
id="username-validation-error"
|
||||
class="absolute top-full left-0 z-50 w-full mt-1 px-3 py-2 text-sm font-medium border border-red-500/50 rounded-lg bg-red-900/90 text-red-200 backdrop-blur-md shadow-lg"
|
||||
>
|
||||
${this.validationError}
|
||||
${error}
|
||||
</div>`
|
||||
: null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private handleUsernameChange(e: Event) {
|
||||
private handleInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const originalValue = input.value;
|
||||
const val = originalValue.replace(/[[\]]/g, "");
|
||||
if (originalValue !== val) {
|
||||
input.value = val;
|
||||
const stripped = originalValue.replace(/[[\]]/g, "");
|
||||
if (originalValue !== stripped) {
|
||||
input.value = stripped;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: {
|
||||
@@ -118,29 +108,13 @@ export class UsernameInput extends LitElement {
|
||||
}),
|
||||
);
|
||||
}
|
||||
this.baseUsername = val;
|
||||
this.validateAndStore();
|
||||
}
|
||||
|
||||
private validateAndStore() {
|
||||
const trimmedBase = this.getUsername();
|
||||
const result = validateUsername(trimmedBase);
|
||||
this._isValid = result.isValid;
|
||||
if (result.isValid) {
|
||||
localStorage.setItem(usernameKey, trimmedBase);
|
||||
this.validationError = "";
|
||||
} else {
|
||||
this.validationError = result.error ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
public isValid(): boolean {
|
||||
return this._isValid;
|
||||
setUsername(stripped);
|
||||
}
|
||||
|
||||
public showValidationFeedback(): void {
|
||||
const message =
|
||||
this.validationError || translateText("username.invalid_chars");
|
||||
this.identity.state.username.error ||
|
||||
translateText("username.invalid_chars");
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: {
|
||||
@@ -153,9 +127,7 @@ export class UsernameInput extends LitElement {
|
||||
}
|
||||
|
||||
public validateOrShowError(): boolean {
|
||||
if (this.isValid()) {
|
||||
return true;
|
||||
}
|
||||
if (this.isValid()) return true;
|
||||
this.showValidationFeedback();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ReactiveController, ReactiveControllerHost } from "lit";
|
||||
import {
|
||||
awaitIdentityReady,
|
||||
getIdentityState,
|
||||
subscribeIdentity,
|
||||
type IdentityState,
|
||||
} from "./IdentityStore";
|
||||
|
||||
// Subscribes a Lit host to the identity store and triggers a re-render
|
||||
// whenever the ready/validating state changes. Lets play buttons bind
|
||||
// `?disabled=${!identity.ready}` without bespoke wiring per consumer.
|
||||
export class IdentityReadyController implements ReactiveController {
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
private snapshot: IdentityState;
|
||||
|
||||
constructor(private readonly host: ReactiveControllerHost) {
|
||||
this.host.addController(this);
|
||||
this.snapshot = getIdentityState();
|
||||
}
|
||||
|
||||
hostConnected(): void {
|
||||
this.unsubscribe = subscribeIdentity((state) => {
|
||||
this.snapshot = state;
|
||||
this.host.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
hostDisconnected(): void {
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = null;
|
||||
}
|
||||
|
||||
get ready(): boolean {
|
||||
return this.snapshot.ready;
|
||||
}
|
||||
|
||||
get validating(): boolean {
|
||||
return this.snapshot.clanTagChecking;
|
||||
}
|
||||
|
||||
get state(): IdentityState {
|
||||
return this.snapshot;
|
||||
}
|
||||
|
||||
async awaitReady(): Promise<boolean> {
|
||||
return awaitIdentityReady();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { sanitizeClanTag } from "../../core/Util";
|
||||
import {
|
||||
MAX_CLAN_TAG_LENGTH,
|
||||
MIN_CLAN_TAG_LENGTH,
|
||||
validateClanTag,
|
||||
validateUsername,
|
||||
} from "../../core/validations/username";
|
||||
import { getUserMe } from "../Api";
|
||||
import { fetchClanExists } from "../ClanApi";
|
||||
|
||||
const CLAN_OWNERSHIP_DEBOUNCE_MS = 400;
|
||||
const CLAN_TAG_KEY = "clanTag";
|
||||
const USERNAME_KEY = "username";
|
||||
|
||||
export type IdentityField<T> = {
|
||||
value: T;
|
||||
valid: boolean;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export type IdentityState = {
|
||||
username: IdentityField<string>;
|
||||
clanTag: IdentityField<string>;
|
||||
clanTagChecking: boolean;
|
||||
ready: boolean;
|
||||
};
|
||||
|
||||
type Listener = (state: IdentityState) => void;
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
const state: IdentityState = {
|
||||
username: { value: "", valid: false, error: "" },
|
||||
clanTag: { value: "", valid: true, error: "" },
|
||||
clanTagChecking: false,
|
||||
ready: false,
|
||||
};
|
||||
|
||||
let lastInput: { username: string; clanTag: string } = {
|
||||
username: "",
|
||||
clanTag: "",
|
||||
};
|
||||
|
||||
function recomputeReady() {
|
||||
state.ready =
|
||||
state.username.valid && state.clanTag.valid && !state.clanTagChecking;
|
||||
}
|
||||
|
||||
function emit() {
|
||||
recomputeReady();
|
||||
for (const listener of listeners) listener(state);
|
||||
}
|
||||
|
||||
export function subscribeIdentity(listener: Listener): () => void {
|
||||
listeners.add(listener);
|
||||
listener(state);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function getIdentityState(): IdentityState {
|
||||
return state;
|
||||
}
|
||||
|
||||
export function getUsernameForSubmit(): string {
|
||||
return state.username.value;
|
||||
}
|
||||
|
||||
// Mirrors the legacy ClanTagInput.getValue contract: only emit a non-null
|
||||
// value when the tag is valid AND meets length AND format. Empty / pending /
|
||||
// failed states submit as null so the server falls back to "no tag".
|
||||
export function getClanTagForSubmit(): string | null {
|
||||
// Don't submit a tag while the ownership check is in flight — callers
|
||||
// either gate on `state.ready` first or await `awaitIdentityReady()`.
|
||||
if (state.clanTagChecking) return null;
|
||||
const { value, valid } = state.clanTag;
|
||||
if (!valid) return null;
|
||||
if (value.length < MIN_CLAN_TAG_LENGTH) return null;
|
||||
if (value.length > MAX_CLAN_TAG_LENGTH) return null;
|
||||
if (!validateClanTag(value).isValid) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function setUsername(raw: string) {
|
||||
lastInput.username = raw;
|
||||
const trimmed = raw.trim();
|
||||
const result = validateUsername(trimmed);
|
||||
state.username = {
|
||||
value: trimmed,
|
||||
valid: result.isValid,
|
||||
error: result.isValid ? "" : (result.error ?? ""),
|
||||
};
|
||||
if (result.isValid) {
|
||||
localStorage.setItem(USERNAME_KEY, trimmed);
|
||||
}
|
||||
emit();
|
||||
}
|
||||
|
||||
let clanCheckCounter = 0;
|
||||
let clanCheckTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let resolveDebounce: (() => void) | null = null;
|
||||
let currentCheck: Promise<void> = Promise.resolve();
|
||||
|
||||
export function setClanTag(raw: string, options: { immediate?: boolean } = {}) {
|
||||
lastInput.clanTag = raw;
|
||||
const tag = sanitizeClanTag(raw);
|
||||
const result = validateClanTag(tag);
|
||||
|
||||
// Cancel any pending/in-flight ownership work. checkCounter++ marks stale
|
||||
// chains; resolving the prior debounce lets awaitReady() callers unblock.
|
||||
if (clanCheckTimer !== null) clearTimeout(clanCheckTimer);
|
||||
clanCheckTimer = null;
|
||||
clanCheckCounter++;
|
||||
if (resolveDebounce) resolveDebounce();
|
||||
resolveDebounce = null;
|
||||
|
||||
state.clanTag = {
|
||||
value: tag,
|
||||
valid: result.isValid,
|
||||
error: result.isValid ? "" : (result.error ?? ""),
|
||||
};
|
||||
|
||||
if (!result.isValid || tag.length === 0) {
|
||||
// Nothing to ask the server about. Wipe the stored tag so a reload
|
||||
// doesn't restore a stale value that no longer matches input.
|
||||
state.clanTagChecking = false;
|
||||
localStorage.setItem(CLAN_TAG_KEY, "");
|
||||
currentCheck = Promise.resolve();
|
||||
emit();
|
||||
return;
|
||||
}
|
||||
|
||||
state.clanTagChecking = true;
|
||||
emit();
|
||||
|
||||
const generation = clanCheckCounter;
|
||||
const run = (): Promise<void> => {
|
||||
if (generation !== clanCheckCounter) return Promise.resolve();
|
||||
return runOwnershipCheck(tag, generation);
|
||||
};
|
||||
|
||||
if (options.immediate) {
|
||||
currentCheck = run();
|
||||
} else {
|
||||
const debounce = new Promise<void>((resolve) => {
|
||||
resolveDebounce = resolve;
|
||||
});
|
||||
clanCheckTimer = setTimeout(() => {
|
||||
clanCheckTimer = null;
|
||||
const r = resolveDebounce;
|
||||
resolveDebounce = null;
|
||||
r?.();
|
||||
}, CLAN_OWNERSHIP_DEBOUNCE_MS);
|
||||
currentCheck = debounce.then(run);
|
||||
}
|
||||
}
|
||||
|
||||
async function runOwnershipCheck(tag: string, generation: number) {
|
||||
const stillCurrent = () =>
|
||||
generation === clanCheckCounter && state.clanTag.value === tag;
|
||||
|
||||
const me = await getUserMe();
|
||||
if (!stillCurrent()) return;
|
||||
const myTags = me
|
||||
? (me.player.clans ?? []).map((c) => c.tag.toUpperCase())
|
||||
: [];
|
||||
|
||||
if (!myTags.includes(tag.toUpperCase())) {
|
||||
const exists = await fetchClanExists(tag);
|
||||
if (!stillCurrent()) return;
|
||||
if (exists !== false) {
|
||||
rejectTag(tag);
|
||||
return;
|
||||
}
|
||||
}
|
||||
acceptTag(tag);
|
||||
}
|
||||
|
||||
function acceptTag(tag: string) {
|
||||
state.clanTag = { value: tag, valid: true, error: "" };
|
||||
state.clanTagChecking = false;
|
||||
localStorage.setItem(CLAN_TAG_KEY, tag);
|
||||
emit();
|
||||
}
|
||||
|
||||
function rejectTag(tag: string) {
|
||||
state.clanTag = {
|
||||
value: tag,
|
||||
valid: false,
|
||||
error: "username.tag_not_member",
|
||||
};
|
||||
state.clanTagChecking = false;
|
||||
localStorage.removeItem(CLAN_TAG_KEY);
|
||||
emit();
|
||||
}
|
||||
|
||||
// Resolves once any in-flight async clan check settles. Returns the final
|
||||
// ready state so callers can branch without a second read.
|
||||
export async function awaitIdentityReady(): Promise<boolean> {
|
||||
let last: Promise<void> | undefined;
|
||||
while (currentCheck !== last) {
|
||||
last = currentCheck;
|
||||
await last;
|
||||
}
|
||||
return state.ready;
|
||||
}
|
||||
|
||||
// Re-runs sync validation against the last raw input so error messages get
|
||||
// re-translated when the active language changes. Does NOT re-trigger the
|
||||
// async ownership check (the cached result is still correct).
|
||||
export function revalidateIdentityTranslations() {
|
||||
const trimmed = lastInput.username.trim();
|
||||
const usernameResult = validateUsername(trimmed);
|
||||
state.username = {
|
||||
value: trimmed,
|
||||
valid: usernameResult.isValid,
|
||||
error: usernameResult.isValid ? "" : (usernameResult.error ?? ""),
|
||||
};
|
||||
|
||||
const tag = sanitizeClanTag(lastInput.clanTag);
|
||||
const tagResult = validateClanTag(tag);
|
||||
// Preserve any existing ownership error (it's already an i18n key); only
|
||||
// refresh the format-level error so language changes pick up new strings.
|
||||
if (!tagResult.isValid) {
|
||||
state.clanTag = {
|
||||
value: tag,
|
||||
valid: false,
|
||||
error: tagResult.error ?? "",
|
||||
};
|
||||
}
|
||||
emit();
|
||||
}
|
||||
|
||||
let initialized = false;
|
||||
export function initIdentityFromStorage() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
const storedUsername = localStorage.getItem(USERNAME_KEY) ?? "";
|
||||
setUsername(storedUsername);
|
||||
const storedClanTag = localStorage.getItem(CLAN_TAG_KEY) ?? "";
|
||||
if (storedClanTag.length > 0) {
|
||||
setClanTag(storedClanTag, { immediate: true });
|
||||
} else {
|
||||
setClanTag("", { immediate: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Test-only reset; not part of the public surface but exported so unit tests
|
||||
// can wipe singleton state between cases.
|
||||
export function __resetIdentityStoreForTests() {
|
||||
initialized = false;
|
||||
listeners.clear();
|
||||
state.username = { value: "", valid: false, error: "" };
|
||||
state.clanTag = { value: "", valid: true, error: "" };
|
||||
state.clanTagChecking = false;
|
||||
state.ready = false;
|
||||
lastInput = { username: "", clanTag: "" };
|
||||
if (clanCheckTimer !== null) clearTimeout(clanCheckTimer);
|
||||
clanCheckTimer = null;
|
||||
clanCheckCounter = 0;
|
||||
resolveDebounce = null;
|
||||
currentCheck = Promise.resolve();
|
||||
}
|
||||
@@ -30,7 +30,13 @@ vi.mock("../../src/client/ClanApi", () => ({
|
||||
|
||||
import { getUserMe } from "../../src/client/Api";
|
||||
import { fetchClanExists } from "../../src/client/ClanApi";
|
||||
import { ClanTagInput } from "../../src/client/ClanTagInput";
|
||||
import {
|
||||
__resetIdentityStoreForTests,
|
||||
awaitIdentityReady,
|
||||
getClanTagForSubmit,
|
||||
getIdentityState,
|
||||
setClanTag,
|
||||
} from "../../src/client/identity/IdentityStore";
|
||||
|
||||
const flushPromises = async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
@@ -43,6 +49,7 @@ beforeEach(() => {
|
||||
vi.mocked(getUserMe).mockReset();
|
||||
vi.mocked(fetchClanExists).mockReset();
|
||||
vi.stubGlobal("localStorage", createMockLocalStorage());
|
||||
__resetIdentityStoreForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -51,7 +58,7 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("ClanTagInput async ownership check", () => {
|
||||
describe("IdentityStore clan-tag ownership check", () => {
|
||||
it("surfaces tag_not_member when user is not a member and clan exists", async () => {
|
||||
vi.mocked(getUserMe).mockResolvedValue({
|
||||
user: {},
|
||||
@@ -66,16 +73,14 @@ describe("ClanTagInput async ownership check", () => {
|
||||
} as any);
|
||||
vi.mocked(fetchClanExists).mockResolvedValue(true);
|
||||
|
||||
const input = new ClanTagInput();
|
||||
(input as any).clanTag = "ABC";
|
||||
(input as any).validate();
|
||||
|
||||
setClanTag("ABC");
|
||||
vi.advanceTimersByTime(401);
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
|
||||
expect(input.isValid()).toBe(false);
|
||||
expect((input as any).ownershipError).toBe("username.tag_not_member");
|
||||
const state = getIdentityState();
|
||||
expect(state.clanTag.valid).toBe(false);
|
||||
expect(state.clanTag.error).toBe("username.tag_not_member");
|
||||
});
|
||||
|
||||
it("clears any stored clanTag when async detects ownership conflict", async () => {
|
||||
@@ -93,10 +98,7 @@ describe("ClanTagInput async ownership check", () => {
|
||||
vi.mocked(fetchClanExists).mockResolvedValue(true);
|
||||
localStorage.setItem("clanTag", "ABC");
|
||||
|
||||
const input = new ClanTagInput();
|
||||
(input as any).clanTag = "ABC";
|
||||
(input as any).validate();
|
||||
|
||||
setClanTag("ABC");
|
||||
vi.advanceTimersByTime(401);
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
@@ -108,15 +110,12 @@ describe("ClanTagInput async ownership check", () => {
|
||||
vi.mocked(getUserMe).mockResolvedValue(false);
|
||||
vi.mocked(fetchClanExists).mockResolvedValue(false);
|
||||
|
||||
const input = new ClanTagInput();
|
||||
(input as any).clanTag = "FIC";
|
||||
(input as any).validate();
|
||||
|
||||
setClanTag("FIC");
|
||||
vi.advanceTimersByTime(401);
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
|
||||
expect(input.isValid()).toBe(true);
|
||||
expect(getIdentityState().clanTag.valid).toBe(true);
|
||||
expect(localStorage.getItem("clanTag")).toBe("FIC");
|
||||
});
|
||||
|
||||
@@ -124,15 +123,12 @@ describe("ClanTagInput async ownership check", () => {
|
||||
vi.mocked(getUserMe).mockResolvedValue(false);
|
||||
vi.mocked(fetchClanExists).mockResolvedValue(null);
|
||||
|
||||
const input = new ClanTagInput();
|
||||
(input as any).clanTag = "ABC";
|
||||
(input as any).validate();
|
||||
|
||||
setClanTag("ABC");
|
||||
vi.advanceTimersByTime(401);
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
|
||||
expect(input.isValid()).toBe(false);
|
||||
expect(getIdentityState().clanTag.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("discards stale async results when the tag has changed", async () => {
|
||||
@@ -146,16 +142,12 @@ describe("ClanTagInput async ownership check", () => {
|
||||
.mockReturnValueOnce(first)
|
||||
.mockReturnValueOnce(second);
|
||||
|
||||
const input = new ClanTagInput();
|
||||
|
||||
(input as any).clanTag = "AAA";
|
||||
(input as any).validate();
|
||||
setClanTag("AAA");
|
||||
vi.advanceTimersByTime(401);
|
||||
await flushPromises();
|
||||
|
||||
// Now the user switches to a different tag before the first response lands.
|
||||
(input as any).clanTag = "BBB";
|
||||
(input as any).validate();
|
||||
// User switches to a different tag before the first response lands.
|
||||
setClanTag("BBB");
|
||||
vi.advanceTimersByTime(401);
|
||||
await flushPromises();
|
||||
|
||||
@@ -163,41 +155,53 @@ describe("ClanTagInput async ownership check", () => {
|
||||
// tag is no longer AAA, so this must NOT clobber the result for BBB.
|
||||
resolveFirst(true);
|
||||
await flushPromises();
|
||||
expect((input as any).ownershipError).toBe("");
|
||||
expect(getIdentityState().clanTag.error).toBe("");
|
||||
|
||||
// Second response says BBB doesn't exist → fictional, accept.
|
||||
resolveSecond(false);
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
|
||||
expect(input.isValid()).toBe(true);
|
||||
expect(getIdentityState().clanTag.valid).toBe(true);
|
||||
expect(localStorage.getItem("clanTag")).toBe("BBB");
|
||||
});
|
||||
|
||||
it("clears the pending timer in disconnectedCallback", () => {
|
||||
it("flips ready false while a check is in flight, true on success", async () => {
|
||||
vi.mocked(getUserMe).mockResolvedValue(false);
|
||||
vi.mocked(fetchClanExists).mockResolvedValue(false);
|
||||
|
||||
const input = new ClanTagInput();
|
||||
(input as any).clanTag = "ABC";
|
||||
(input as any).validate();
|
||||
setClanTag("ABC");
|
||||
// Pre-debounce: checking flag already set so play buttons disable
|
||||
// immediately, not after the debounce.
|
||||
expect(getIdentityState().clanTagChecking).toBe(true);
|
||||
expect(getIdentityState().ready).toBe(false);
|
||||
|
||||
expect((input as any).checkTimer).not.toBeNull();
|
||||
|
||||
(input as any).disconnectedCallback();
|
||||
|
||||
expect((input as any).checkTimer).toBeNull();
|
||||
vi.advanceTimersByTime(401);
|
||||
const ready = await awaitIdentityReady();
|
||||
expect(ready).toBe(false); // username still invalid (empty)
|
||||
expect(getIdentityState().clanTagChecking).toBe(false);
|
||||
expect(getIdentityState().clanTag.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("getValue returns null for empty/short/invalid tags and the tag when valid", () => {
|
||||
const input = new ClanTagInput();
|
||||
(input as any).clanTag = "";
|
||||
expect(input.getValue()).toBeNull();
|
||||
(input as any).clanTag = "A";
|
||||
expect(input.getValue()).toBeNull();
|
||||
(input as any).clanTag = "TOOLONG";
|
||||
expect(input.getValue()).toBeNull();
|
||||
(input as any).clanTag = "ABC";
|
||||
expect(input.getValue()).toBe("ABC");
|
||||
it("getClanTagForSubmit returns null while empty/short/pending; tag once accepted", async () => {
|
||||
vi.mocked(getUserMe).mockResolvedValue(false);
|
||||
vi.mocked(fetchClanExists).mockResolvedValue(false);
|
||||
|
||||
setClanTag("");
|
||||
expect(getClanTagForSubmit()).toBeNull();
|
||||
|
||||
setClanTag("A");
|
||||
expect(getClanTagForSubmit()).toBeNull();
|
||||
|
||||
setClanTag("ABC");
|
||||
// Ownership check hasn't resolved yet → not submittable.
|
||||
expect(getIdentityState().clanTagChecking).toBe(true);
|
||||
expect(getClanTagForSubmit()).toBeNull();
|
||||
|
||||
vi.advanceTimersByTime(401);
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
|
||||
expect(getClanTagForSubmit()).toBe("ABC");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user