Phase 28: diplomatic mail UI (work in progress) #11

Merged
developer merged 19 commits from feat/ui-stage-28 into development 2026-05-16 20:56:17 +00:00
3 changed files with 52 additions and 44 deletions
Showing only changes of commit 2119f825d6 - Show all commits
@@ -1,8 +1,10 @@
<!-- <!--
Phase 28 — compose dialog for diplomatic mail. The recipient picker Phase 28 — compose dialog for diplomatic mail. The recipient picker
reads `gameState.report.races[]` (Phase 22), the kind toggle exposes reads `gameState.report.races[]` (Phase 22); the kind toggle exposes
personal / broadcast / admin; broadcast and admin sends are gated personal / broadcast. The admin compose path lives in the
server-side, the UI surfaces the resulting 403 inline. server-side admin tooling, not in the in-game UI, so it is not
surfaced here. Broadcast sends are gated server-side; the UI
surfaces the resulting 403 inline.
--> -->
<script lang="ts"> <script lang="ts">
import { getContext } from "svelte"; import { getContext } from "svelte";
@@ -14,8 +16,7 @@ server-side, the UI surfaces the resulting 403 inline.
type RenderedReportSource, type RenderedReportSource,
} from "$lib/rendered-report.svelte"; } from "$lib/rendered-report.svelte";
type ComposeKind = "personal" | "broadcast" | "admin"; type ComposeKind = "personal" | "broadcast";
type AdminAudience = "active" | "active_and_removed" | "all_members";
let { let {
onClose, onClose,
@@ -39,8 +40,6 @@ server-side, the UI surfaces the resulting 403 inline.
let kind = $state<ComposeKind>("personal"); let kind = $state<ComposeKind>("personal");
let raceName = $state(""); let raceName = $state("");
let adminTarget = $state<"user" | "all">("user");
let adminAudience = $state<AdminAudience>("active");
let subject = $state(""); let subject = $state("");
let body = $state(""); let body = $state("");
let error = $state<string | null>(null); let error = $state<string | null>(null);
@@ -60,8 +59,7 @@ server-side, the UI surfaces the resulting 403 inline.
error = i18n.t("game.mail.body_required"); error = i18n.t("game.mail.body_required");
return; return;
} }
const needsRecipient = kind === "personal" || (kind === "admin" && adminTarget === "user"); if (kind === "personal" && raceName === "") {
if (needsRecipient && raceName === "") {
error = i18n.t("game.mail.recipient_required"); error = i18n.t("game.mail.recipient_required");
return; return;
} }
@@ -76,19 +74,8 @@ server-side, the UI surfaces the resulting 403 inline.
onSent(raceName); onSent(raceName);
return; return;
} }
if (kind === "broadcast") {
await mailStore.composeBroadcast({ subject, body: bodyText }); await mailStore.composeBroadcast({ subject, body: bodyText });
onSent(null); onSent(null);
return;
}
await mailStore.composeAdmin({
target: adminTarget,
raceName: adminTarget === "user" ? raceName : undefined,
recipients: adminTarget === "all" ? adminAudience : undefined,
subject,
body: bodyText,
});
onSent(null);
} catch (err) { } catch (err) {
error = err instanceof Error ? err.message : String(err); error = err instanceof Error ? err.message : String(err);
} finally { } finally {
@@ -109,31 +96,10 @@ server-side, the UI surfaces the resulting 403 inline.
<select bind:value={kind} data-testid="mail-compose-kind"> <select bind:value={kind} data-testid="mail-compose-kind">
<option value="personal">{i18n.t("game.mail.compose.target_personal")}</option> <option value="personal">{i18n.t("game.mail.compose.target_personal")}</option>
<option value="broadcast">{i18n.t("game.mail.compose.target_broadcast")}</option> <option value="broadcast">{i18n.t("game.mail.compose.target_broadcast")}</option>
<option value="admin">{i18n.t("game.mail.compose.target_admin")}</option>
</select> </select>
</label> </label>
{#if kind === "admin"} {#if kind === "personal"}
<label>
{i18n.t("game.mail.compose.recipients_label")}
<select bind:value={adminTarget} data-testid="mail-compose-admin-target">
<option value="user">{i18n.t("game.mail.compose.target_personal")}</option>
<option value="all">{i18n.t("game.mail.compose.target_broadcast")}</option>
</select>
</label>
{#if adminTarget === "all"}
<label>
{i18n.t("game.mail.compose.recipients_label")}
<select bind:value={adminAudience} data-testid="mail-compose-admin-audience">
<option value="active">{i18n.t("game.mail.compose.recipients_active")}</option>
<option value="active_and_removed">{i18n.t("game.mail.compose.recipients_active_and_removed")}</option>
<option value="all_members">{i18n.t("game.mail.compose.recipients_all_members")}</option>
</select>
</label>
{/if}
{/if}
{#if kind === "personal" || (kind === "admin" && adminTarget === "user")}
<label> <label>
{i18n.t("game.mail.recipient_label")} {i18n.t("game.mail.recipient_label")}
<select bind:value={raceName} data-testid="mail-compose-recipient"> <select bind:value={raceName} data-testid="mail-compose-recipient">
+16 -1
View File
@@ -317,9 +317,24 @@ function buildEntries(inbox: MailMessage[], sent: MailMessage[]): MailListEntry[
thread.latestAt = last.createdAt; thread.latestAt = last.createdAt;
} }
// Broadcast and admin fan-outs return one row per recipient from
// the `/sent` endpoint (so the admin UI sees the materialised
// audience). The in-game list pane collapses them by `message_id`
// — without this dedupe the {#each} key in `thread-list.svelte`
// repeats and Svelte 5 aborts the render with `each_key_duplicate`.
const seen = new Set<string>();
const dedupedStandalones: MailStandalone[] = [];
for (const s of standalones) {
if (seen.has(s.message.messageId)) {
continue;
}
seen.add(s.message.messageId);
dedupedStandalones.push(s);
}
const entries: MailListEntry[] = [ const entries: MailListEntry[] = [
...Array.from(threadsByRace.values()), ...Array.from(threadsByRace.values()),
...standalones, ...dedupedStandalones,
]; ];
entries.sort((a, b) => b.latestAt.getTime() - a.latestAt.getTime()); entries.sort((a, b) => b.latestAt.getTime() - a.latestAt.getTime());
return entries; return entries;
+27
View File
@@ -113,6 +113,33 @@ describe("MailStore.entries", () => {
expect(entries[0].kind).toBe("standalone"); expect(entries[0].kind).toBe("standalone");
}); });
it("collapses fan-out rows for the same broadcast message_id", () => {
// `/sent` returns one row per recipient for broadcast and admin
// sends so the admin tooling can render the materialised
// audience. The list pane must collapse the duplicates by
// message_id, otherwise the {#each} key in `thread-list.svelte`
// repeats and Svelte 5 surfaces `each_key_duplicate`.
const store = new MailStore();
const base = {
messageId: "br-shared",
senderKind: "player" as const,
senderRaceName: "Self",
broadcastScope: "game_broadcast" as const,
createdAt: new Date("2026-05-15T14:00:00Z"),
};
store.sent = [
makeMessage({ ...base, recipientRaceName: "Greys" }),
makeMessage({ ...base, recipientRaceName: "Bajori" }),
makeMessage({ ...base, recipientRaceName: "Romulans" }),
];
const entries = store.entries;
expect(entries.length).toBe(1);
expect(entries[0].kind).toBe("standalone");
const standalone = entries[0] as MailStandalone;
expect(standalone.message.messageId).toBe("br-shared");
});
it("counts unread incoming messages in unreadCount", () => { it("counts unread incoming messages in unreadCount", () => {
const store = new MailStore(); const store = new MailStore();
store.inbox = [ store.inbox = [