mail UI: dedupe broadcast fan-out and drop in-game admin compose
Two issues surfaced once the long-lived dev environment finally
reached the diplomail view:
1. `/sent` returns one row per recipient for broadcast and admin
fan-outs (so the admin tooling can render the materialised
audience). The list pane fed all rows into the stand-alone
bucket, so the `{#each entries as e (entryKey(e))}` key in
`thread-list.svelte` collapsed to the same `standalone:${id}`
for every recipient and Svelte 5 aborted the render with
`each_key_duplicate`. Dedupe stand-alones by `message_id` in
`buildEntries`.
2. The compose dialog exposed an `admin` kind toggle gated on
"owner of game". That was a Phase 28 plan decision, but admin
compose is an operator tool (server admin), not an in-game
action — every game owner should not be able to broadcast
admin notifications. Drop the admin option, the audience
sub-toggles, and the admin path through `submit`. The
`MailStore.composeAdmin` wrapper and the backend RPC stay so
the future admin UI can call them.
Vitest covers the fan-out dedup with three rows sharing one
`message_id` collapsing to a single stand-alone entry.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
Reference in New Issue
Block a user