Files
galaxy-game/ui/frontend/src/lib/mail-store.svelte.ts
T
Ilia Denisov fdd5fd193d
Tests · UI / test (pull_request) Waiting to run
Tests · UI / test (push) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m38s
Tests · Go / test (pull_request) Successful in 3m19s
Phase 28 (Step 5): MailStore reactive state
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) <noreply@anthropic.com>
2026-05-15 22:37:32 +02:00

374 lines
11 KiB
TypeScript

// 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<SendPersonalArgs, "gameId">): Promise<MailMessage> {
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<SendBroadcastArgs, "gameId">): Promise<void> {
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<SendAdminArgs, "gameId">): Promise<void> {
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<MailMessage | null> {
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<void> {
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<string, MailThread>();
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<string, MailThread>,
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();