// Phase 28 reactive store for the in-game diplomatic-mail view. Owns // the inbox / sent listings, the per-race threading projection, the // unread badge counter, and the imperative compose / mark-read / // delete actions. The companion API wrappers live in // `src/api/diplomail.ts`; this store coordinates them with the rest // of the in-game shell. import type { GalaxyClient } from "../api/galaxy-client"; import type { Cache } from "../platform/store/index"; import { deleteMessage, fetchInbox, fetchMessage, fetchSent, markRead, sendAdmin, sendBroadcast, sendPersonal, type MailMessage, type SendAdminArgs, type SendBroadcastArgs, type SendPersonalArgs, } from "../api/diplomail"; /** * MailThread groups personal messages exchanged with a single other * race into one entry. The local player's outgoing messages live * alongside incoming messages from the same race so the UI renders a * chat-style transcript. `unreadCount` counts only incoming messages * with `readAt === null`. */ export interface MailThread { kind: "thread"; raceName: string; messages: MailMessage[]; unreadCount: number; latestAt: Date; } /** * MailStandalone wraps a single message that does not participate in * a race-thread: system mail, admin notifications, and the caller's * own paid-tier broadcasts. The UI renders these as read-only items * in the same list as the per-race threads. */ export interface MailStandalone { kind: "standalone"; message: MailMessage; latestAt: Date; } export type MailListEntry = MailThread | MailStandalone; const CACHE_NAMESPACE = "diplomail"; /** * MailStore is the reactive surface consumed by the active view, the * header badge, and the push-event handler. One instance per signed- * in session is enough — the rune fields are scoped to the current * game and replaced on every `setGame` call so navigating between * games stays clean. */ export class MailStore { gameId = $state(""); status: "idle" | "loading" | "ready" | "error" = $state("idle"); error: string | null = $state(null); inbox: MailMessage[] = $state([]); sent: MailMessage[] = $state([]); private client: GalaxyClient | null = null; private cache: Cache | null = null; /** * entries surfaces the unified list-pane projection: per-race * threads built from incoming + outgoing personal messages plus * stand-alone items for system / admin / own-broadcast rows. * Sorted newest-first by the latest message inside each entry. */ entries: MailListEntry[] = $derived.by(() => buildEntries(this.inbox, this.sent)); /** * unreadCount drives the header view-menu badge. Counts only * incoming personal / admin / system messages with `readAt === null`. * `read_at` is not surfaced to the user in the UI but still * drives this counter. */ unreadCount = $derived.by(() => this.inbox.reduce((acc, m) => (m.readAt === null ? acc + 1 : acc), 0)); /** * init configures the dependencies and fires the initial fetch. * Safe to call multiple times — calls after the first one are * routed to `setGame`. `localUserId` is captured so the threading * projection can tell outgoing messages from incoming when the * inbox and sent lists are unified. */ async init(opts: { client: GalaxyClient; cache: Cache; gameId: string; }): Promise { this.client = opts.client; this.cache = opts.cache; await this.setGame(opts.gameId); } /** * setGame switches the store to a different game id and refreshes * its inbox / sent state. Idempotent on the same id — the network * fetch fires only when the id actually changed or the previous * load ended in `error`. */ async setGame(gameId: string): Promise { if (this.client === null) { throw new Error("mail-store: setGame called before init"); } if (this.gameId === gameId && this.status === "ready") { return; } this.gameId = gameId; this.status = "loading"; this.error = null; this.inbox = []; this.sent = []; try { const [inbox, sent] = await Promise.all([ fetchInbox(this.client, gameId), fetchSent(this.client, gameId), ]); this.inbox = inbox; this.sent = sent; this.status = "ready"; await this.rememberLastSeen(); } catch (err) { this.status = "error"; this.error = errorMessage(err); } } /** refresh re-fetches inbox + sent for the active game. */ async refresh(): Promise { if (this.gameId === "") { return; } await this.setGame(this.gameId); } /** * applyPushEvent reacts to a verified `diplomail.message.received` * push frame by refetching the inbox for the active game. The * payload carries only a preview, so the store hits the server for * the canonical row. */ async applyPushEvent(payloadGameId: string): Promise { if (payloadGameId !== this.gameId || this.client === null) { return; } try { this.inbox = await fetchInbox(this.client, this.gameId); await this.rememberLastSeen(); } catch (err) { this.error = errorMessage(err); } } /** * markRead transitions an incoming message to `read`. The local * inbox row is flipped optimistically; on failure the previous * state is restored and the error surfaces via `error`. */ async markRead(messageId: string): Promise { if (this.client === null) { return; } const before = this.inbox; this.inbox = before.map((m) => { if (m.messageId !== messageId) { return m; } return { ...m, readAt: m.readAt ?? new Date() }; }); try { await markRead(this.client, this.gameId, messageId); } catch (err) { this.inbox = before; this.error = errorMessage(err); } } /** * softDelete removes a read incoming message from the inbox. The * server enforces "read before delete"; on conflict the row is * restored and the error surfaces. */ async softDelete(messageId: string): Promise { if (this.client === null) { return; } const before = this.inbox; this.inbox = before.filter((m) => m.messageId !== messageId); try { await deleteMessage(this.client, this.gameId, messageId); } catch (err) { this.inbox = before; this.error = errorMessage(err); } } /** * composePersonal sends a single-recipient personal message, * addressed by race name (resolved server-side). On success the * resulting row is appended to the sent list so the matching * thread surfaces it immediately. Throws on failure so callers * can render inline form errors. */ async composePersonal(input: Omit): Promise { if (this.client === null) { throw new Error("mail-store: composePersonal called before init"); } const created = await sendPersonal(this.client, { ...input, gameId: this.gameId }); this.sent = [created, ...this.sent]; return created; } /** * composeBroadcast posts a paid-tier player broadcast. The sent * list is refreshed to surface the new entries. */ async composeBroadcast(input: Omit): Promise { if (this.client === null) { throw new Error("mail-store: composeBroadcast called before init"); } await sendBroadcast(this.client, { ...input, gameId: this.gameId }); this.sent = await fetchSent(this.client, this.gameId); } /** * composeAdmin posts an owner-only admin notification. Single * sends refresh the sent list; broadcasts also refresh the sent * list (the author does not appear as a recipient and is excluded * from the resulting fan-out). */ async composeAdmin(input: Omit): Promise { if (this.client === null) { throw new Error("mail-store: composeAdmin called before init"); } await sendAdmin(this.client, { ...input, gameId: this.gameId }); this.sent = await fetchSent(this.client, this.gameId); } /** * loadMessage fetches a single message detail (used when the UI * needs the freshest translation status for a specific row). * The returned row is merged into the inbox copy if it lives * there; sent rows are not refreshed here. */ async loadMessage(messageId: string): Promise { if (this.client === null) { return null; } try { const fresh = await fetchMessage(this.client, this.gameId, messageId); this.inbox = this.inbox.map((m) => (m.messageId === messageId ? fresh : m)); return fresh; } catch (err) { this.error = errorMessage(err); return null; } } private async rememberLastSeen(): Promise { if (this.cache === null || this.gameId === "" || this.inbox.length === 0) { return; } const last = this.inbox[0]; await this.cache.put(CACHE_NAMESPACE, `${this.gameId}/last-seen`, last.messageId); } } function buildEntries(inbox: MailMessage[], sent: MailMessage[]): MailListEntry[] { // Each personal message keyed by another race contributes to a // race thread. Other shapes become stand-alone entries. const threadsByRace = new Map(); const standalones: MailStandalone[] = []; for (const m of inbox) { if (isStandaloneIncoming(m)) { standalones.push({ kind: "standalone", message: m, latestAt: m.createdAt }); continue; } const race = m.senderRaceName ?? ""; if (race === "") { standalones.push({ kind: "standalone", message: m, latestAt: m.createdAt }); continue; } mergeIntoThread(threadsByRace, race, m, /*isIncoming=*/true); } for (const m of sent) { if (isStandaloneOutgoing(m)) { standalones.push({ kind: "standalone", message: m, latestAt: m.createdAt }); continue; } const race = m.recipientRaceName ?? ""; if (race === "") { standalones.push({ kind: "standalone", message: m, latestAt: m.createdAt }); continue; } mergeIntoThread(threadsByRace, race, m, /*isIncoming=*/false); } // Sort each thread's messages oldest → newest for chat-style // rendering; the entry list itself sorts newest-first by the // most-recent message timestamp. for (const thread of threadsByRace.values()) { thread.messages.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); const last = thread.messages[thread.messages.length - 1]; thread.latestAt = last.createdAt; } const entries: MailListEntry[] = [ ...Array.from(threadsByRace.values()), ...standalones, ]; entries.sort((a, b) => b.latestAt.getTime() - a.latestAt.getTime()); return entries; } function mergeIntoThread( threads: Map, race: string, message: MailMessage, isIncoming: boolean, ): void { let thread = threads.get(race); if (thread === undefined) { thread = { kind: "thread", raceName: race, messages: [], unreadCount: 0, latestAt: message.createdAt, }; threads.set(race, thread); } thread.messages.push(message); if (isIncoming && message.readAt === null) { thread.unreadCount += 1; } if (message.createdAt.getTime() > thread.latestAt.getTime()) { thread.latestAt = message.createdAt; } } function isStandaloneIncoming(m: MailMessage): boolean { // System / admin notifications never thread by race even when a // snapshot is available — they are one-way operational mail. return m.senderKind !== "player"; } function isStandaloneOutgoing(m: MailMessage): boolean { // Paid-tier broadcasts that the caller authored target many // recipients; the UI renders them once as a stand-alone item. return m.broadcastScope !== "single"; } function errorMessage(err: unknown): string { if (err instanceof Error) { return err.message; } return String(err); } export const mailStore = new MailStore();