Phase 28: diplomatic mail UI (work in progress) #11
@@ -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