import { LitElement, html } from "lit"; import { customElement, property, state } 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"; interface LangSelectorLike { currentLang?: string; translations?: Record; defaultTranslations?: Record; } @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 lastTranslatedLang: string | null = null; createRenderRoot() { return this; } public isValid(): boolean { return this.formatError === "" && this.ownershipError === ""; } public getValue(): string | null { return this.clanTag.length >= MIN_CLAN_TAG_LENGTH && this.clanTag.length <= MAX_CLAN_TAG_LENGTH && validateClanTag(this.clanTag).isValid ? this.clanTag : null; } connectedCallback() { super.connectedCallback(); this.clanTag = localStorage.getItem(clanTagKey) ?? ""; this.validate(); } 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(); } 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; const ls = document.querySelector( "lang-selector", ); const lang = ls?.currentLang; const hasTranslations = ls?.translations ?? ls?.defaultTranslations; if (hasTranslations && lang && lang !== this.lastTranslatedLang) { this.lastTranslatedLang = lang; this.validate(); } } render() { return html`
${this.validationError ? html`
${this.validationError}
` : null}
`; } private handleInput(e: Event) { const input = e.target as HTMLInputElement; const sanitized = sanitizeClanTag(input.value); if (input.value.toUpperCase() !== sanitized) { window.dispatchEvent( new CustomEvent("show-message", { detail: { message: translateText("username.tag_invalid_chars"), color: "red", duration: 2000, }, }), ); } input.value = sanitized; this.clanTag = sanitized; this.validate(); } private validate() { 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 { 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(() => this.checkOwnership(tag)); } this.refreshError(); } // Resolves once the latest validate() chain finishes — either the debounce // timer + ownership check, or immediately if the input is invalid/empty. 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(); } } public showValidationFeedback() { const message = this.validationError || translateText("username.tag_invalid_chars"); window.dispatchEvent( new CustomEvent("show-message", { detail: { message, color: "red", duration: 2500 }, }), ); } public validateOrShowError(): boolean { if (this.isValid()) return true; this.showValidationFeedback(); return false; } }