// LobbyDataStore is the session-wide cache for the four lobby panels // (active-past / recruitment / invitations / private-games). It owns the // GalaxyClient instance used by lobby HTTP commands, the result of the // `lobby.*.list` fan-out, and the loading / error flags every panel // reads. Sub-screens that need to mutate (submit application, redeem // invite) go through the store so the optimistic state stays consistent // across navigations. // // The store is built around F8-04b's split of the old single // `lobby-screen.svelte` into per-panel screens — the prior design fetched // everything on every panel mount, and refetching on each navigation // flash-cleared the UI. A singleton with $state runes keeps the four // lists alive while the user moves between subpages. // // `clear()` resets the store on signOut; the matching plumbing lives in // `session-store.svelte.ts::signOut`. import { createGatewayClient } from "../api/connect"; import { GalaxyClient } from "../api/galaxy-client"; import { LobbyError, listMyApplications, listMyGames, listMyInvites, listPublicGames, type ApplicationSummary, type GameSummary, type InviteSummary, } from "../api/lobby"; import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "./env"; import { i18n, type TranslationKey } from "./i18n/index.svelte"; import { loadCore } from "../platform/core/index"; import { session } from "./session-store.svelte"; async function sha256(payload: Uint8Array): Promise { const digest = await crypto.subtle.digest("SHA-256", payload as BufferSource); return new Uint8Array(digest); } export function describeLobbyError(err: unknown): string { if (err instanceof LobbyError) { const key = `lobby.error.${err.code}` as TranslationKey; const translated = i18n.t(key); if (translated !== key) { return translated; } return i18n.t("lobby.error.unknown", { message: err.message }); } return err instanceof Error ? err.message : "request failed"; } class LobbyDataStore { myGames = $state([]); invitations = $state([]); applications = $state([]); publicGames = $state([]); loading = $state(true); error: string | null = $state(null); configError: string | null = $state(null); #client: GalaxyClient | null = null; #bootstrap: Promise | null = null; #refresh: Promise | null = null; get client(): GalaxyClient | null { return this.#client; } // ensure resolves to the cached GalaxyClient, building one on first // call and triggering the initial `lobby.*.list` fan-out. Concurrent // callers from sibling screens share the same in-flight bootstrap. ensure(): Promise { if (this.#client !== null) { return Promise.resolve(this.#client); } if (this.#bootstrap !== null) { return this.#bootstrap; } this.#bootstrap = this.#bootstrapClient(); return this.#bootstrap; } async #bootstrapClient(): Promise { try { if ( session.keypair === null || session.deviceSessionId === null || GATEWAY_RESPONSE_PUBLIC_KEY.length === 0 ) { this.loading = false; if (GATEWAY_RESPONSE_PUBLIC_KEY.length === 0) { this.configError = "VITE_GATEWAY_RESPONSE_PUBLIC_KEY is not configured"; } return null; } const keypair = session.keypair; const core = await loadCore(); this.#client = new GalaxyClient({ core, edge: createGatewayClient(gatewayRpcBaseUrl()), signer: (canonical) => keypair.sign(canonical), sha256, deviceSessionId: session.deviceSessionId, gatewayResponsePublicKey: GATEWAY_RESPONSE_PUBLIC_KEY, }); await this.refresh(); return this.#client; } catch (err) { this.error = describeLobbyError(err); this.loading = false; return null; } finally { this.#bootstrap = null; } } // refresh re-runs the four `lobby.*.list` fan-out. Concurrent callers // share the same in-flight promise. refresh(): Promise { if (this.#client === null) { return Promise.resolve(); } if (this.#refresh !== null) { return this.#refresh; } const client = this.#client; this.loading = true; this.error = null; this.#refresh = (async () => { try { const [games, invites, apps, publicPage] = await Promise.all([ listMyGames(client), listMyInvites(client), listMyApplications(client), listPublicGames(client), ]); this.myGames = games; this.invitations = invites.filter((invite) => invite.status === "pending"); this.applications = apps; this.publicGames = publicPage.items; } catch (err) { this.error = describeLobbyError(err); } finally { this.loading = false; this.#refresh = null; } })(); return this.#refresh; } prependApplication(app: ApplicationSummary): void { this.applications = [app, ...this.applications]; } removeInvitation(inviteId: string): void { this.invitations = this.invitations.filter((i) => i.inviteId !== inviteId); } setMyGames(games: GameSummary[]): void { this.myGames = games; } clear(): void { this.#client = null; this.#bootstrap = null; this.#refresh = null; this.myGames = []; this.invitations = []; this.applications = []; this.publicGames = []; this.loading = true; this.error = null; this.configError = null; } } export const lobbyData = new LobbyDataStore();