diff --git a/resources/lang/en.json b/resources/lang/en.json index 13e735459..50a89e5eb 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -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", diff --git a/src/client/ClanTagInput.ts b/src/client/ClanTagInput.ts index 2c2a92e91..999364693 100644 --- a/src/client/ClanTagInput.ts +++ b/src/client/ClanTagInput.ts @@ -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 | null = null; - private currentCheck: Promise = 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( "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`
- ${this.validationError + ${checking + ? html`` + : null} + ${displayError ? html`
- ${this.validationError} + ${displayError}
` : null}
`; } + 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 => { - 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((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 { - let last: Promise | 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 }, diff --git a/src/client/GameModeSelector.ts b/src/client/GameModeSelector.ts index 19acf2711..c1bdb3759 100644 --- a/src/client/GameModeSelector.ts +++ b/src/client/GameModeSelector.ts @@ -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 { - 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` @@ -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`