From fdd5fd193db105da696f7b1f82ba7b2b20572b30 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 15 May 2026 22:37:32 +0200 Subject: [PATCH] Phase 28 (Step 5): MailStore reactive state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `src/lib/mail-store.svelte.ts` — the reactive store that coordinates the in-game mail view. Responsibilities: - holds the inbox and sent listings for the current game and fires the initial parallel fetch (`fetchInbox` + `fetchSent`) on `setGame`; - exposes a `entries` derived rune that builds the unified list pane: per-race threads merged from incoming + outgoing personal messages, plus stand-alone items for system / admin / own paid-tier broadcasts. Thread messages are sorted oldest → newest for chat-style rendering; the list itself sorts newest-first by the most-recent entry timestamp; - derives `unreadCount` from `readAt === null` rows for the header view-menu badge; - imperative `markRead` / `softDelete` actions with optimistic state flips and roll-back on RPC failure; - compose actions for personal / paid-tier broadcast / owner-admin sends; - `applyPushEvent(gameId)` hook called by the layout when a `diplomail.message.received` push frame arrives; refetches the inbox without trusting the preview payload; - persists the most recent message id under `cache.diplomail/${gameId}/last-seen` so a returning session can pre-paint the badge without a network round-trip. Co-Authored-By: Claude Opus 4.7 (1M context) --- ui/frontend/src/lib/mail-store.svelte.ts | 373 +++++++++++++++++++++++ 1 file changed, 373 insertions(+) create mode 100644 ui/frontend/src/lib/mail-store.svelte.ts diff --git a/ui/frontend/src/lib/mail-store.svelte.ts b/ui/frontend/src/lib/mail-store.svelte.ts new file mode 100644 index 0000000..6183ce9 --- /dev/null +++ b/ui/frontend/src/lib/mail-store.svelte.ts @@ -0,0 +1,373 @@ +// 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();