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>
This commit is contained in:
@@ -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<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();
|
||||
Reference in New Issue
Block a user