Phase 28 (Steps 7+8): header unread badge + push/init wiring
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m46s
Tests · Go / test (pull_request) Successful in 3m25s

Step 7 — header view-menu badge.

`view-menu.svelte` reads `mailStore.unreadCount` and renders an
inline pill next to the "diplomatic mail" entry whenever the
counter is non-zero. The badge styling matches the per-row dot in
`thread-list.svelte` so the two surfaces feel consistent.

Step 8 — push event handler + MailStore init in the in-game layout.

`routes/games/[id]/+layout.svelte`:

- registers a `diplomail.message.received` handler alongside the
  existing `game.turn.ready` / `game.paused` ones, parses the
  signed payload, calls `mailStore.applyPushEvent` to refresh the
  inbox for the matching game, and raises a toast with a "view"
  deep-link that navigates to `/games/:id/mail`;
- adds `mailStore.init({ client, cache, gameId })` to the boot
  `Promise.all` so the inbox + sent lists are warm by the time the
  view mounts, and the badge counter is populated before any user
  interaction;
- disposes the new subscription in the `onDestroy` block so a game
  switch does not leak handlers across navigations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-15 22:46:00 +02:00
parent f7300f25a3
commit db81bd8e08
2 changed files with 77 additions and 1 deletions
+25 -1
View File
@@ -15,10 +15,13 @@ polishes microcopy.
import { onMount } from "svelte"; import { onMount } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte"; import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { mailStore } from "$lib/mail-store.svelte";
type Props = { gameId: string }; type Props = { gameId: string };
let { gameId }: Props = $props(); let { gameId }: Props = $props();
const mailUnread = $derived(mailStore.unreadCount);
let open = $state(false); let open = $state(false);
let rootEl: HTMLDivElement | null = $state(null); let rootEl: HTMLDivElement | null = $state(null);
@@ -122,9 +125,15 @@ polishes microcopy.
type="button" type="button"
role="menuitem" role="menuitem"
data-testid="view-menu-item-mail" data-testid="view-menu-item-mail"
class="with-badge"
onclick={() => go(`/games/${gameId}/mail`)} onclick={() => go(`/games/${gameId}/mail`)}
> >
{i18n.t("game.view.mail")} <span>{i18n.t("game.view.mail")}</span>
{#if mailUnread > 0}
<span class="badge" data-testid="view-menu-item-mail-badge">
{i18n.t("game.view.mail.badge", { count: String(mailUnread) })}
</span>
{/if}
</button> </button>
<button <button
type="button" type="button"
@@ -200,6 +209,21 @@ polishes microcopy.
border: 0; border: 0;
cursor: pointer; cursor: pointer;
} }
.surface > button.with-badge {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.surface > button.with-badge .badge {
min-width: 1.5rem;
padding: 0 0.4rem;
text-align: center;
font-size: 0.75rem;
border-radius: 999px;
background: #2a4d7d;
color: #fff;
}
.surface > button:hover, .surface > button:hover,
.surface > details > summary:hover { .surface > details > summary:hover {
background: #1c2238; background: #1c2238;
@@ -96,6 +96,7 @@ fresh.
type VerifiedEvent, type VerifiedEvent,
} from "../../../api/events.svelte"; } from "../../../api/events.svelte";
import { toast } from "$lib/toast.svelte"; import { toast } from "$lib/toast.svelte";
import { mailStore } from "$lib/mail-store.svelte";
let { children } = $props(); let { children } = $props();
@@ -241,6 +242,7 @@ fresh.
// `currentTurn` is known cannot misfire. // `currentTurn` is known cannot misfire.
let unsubTurnReady: (() => void) | null = null; let unsubTurnReady: (() => void) | null = null;
let unsubGamePaused: (() => void) | null = null; let unsubGamePaused: (() => void) | null = null;
let unsubMailReceived: (() => void) | null = null;
const turnReadyDecoder = new TextDecoder("utf-8"); const turnReadyDecoder = new TextDecoder("utf-8");
function parseTurnReadyPayload( function parseTurnReadyPayload(
@@ -268,6 +270,32 @@ fresh.
} }
} }
function parseMailReceivedPayload(
event: VerifiedEvent,
): { gameId: string; from: string } | null {
try {
const text = turnReadyDecoder.decode(event.payloadBytes);
const json: unknown = JSON.parse(text);
if (typeof json !== "object" || json === null) {
return null;
}
const record = json as Record<string, unknown>;
const eventGameId = record.game_id;
if (typeof eventGameId !== "string") {
return null;
}
const subject =
typeof record.subject === "string" && record.subject !== ""
? record.subject
: typeof record.preview === "string"
? record.preview
: "";
return { gameId: eventGameId, from: subject };
} catch {
return null;
}
}
function parseGamePausedPayload( function parseGamePausedPayload(
event: VerifiedEvent, event: VerifiedEvent,
): { gameId: string; reason: string } | null { ): { gameId: string; reason: string } | null {
@@ -408,9 +436,29 @@ fresh.
} }
orderDraft.markPaused({ reason: parsed.reason }); orderDraft.markPaused({ reason: parsed.reason });
}); });
unsubMailReceived = eventStream.on(
"diplomail.message.received",
(event) => {
const parsed = parseMailReceivedPayload(event);
if (parsed === null || parsed.gameId !== gameId) {
return;
}
void mailStore.applyPushEvent(parsed.gameId);
toast.show({
messageKey: "game.events.mail_new.message",
messageParams: { from: parsed.from },
actionLabelKey: "game.events.mail_new.action",
onAction: () => {
void goto(`/games/${gameId}/mail`);
},
durationMs: 8000,
});
},
);
await Promise.all([ await Promise.all([
gameState.init({ client, cache, gameId }), gameState.init({ client, cache, gameId }),
orderDraft.init({ cache, gameId }), orderDraft.init({ cache, gameId }),
mailStore.init({ client, cache, gameId }),
]); ]);
galaxyClient.set(client); galaxyClient.set(client);
orderDraft.bindClient(client, { orderDraft.bindClient(client, {
@@ -442,6 +490,10 @@ fresh.
unsubGamePaused(); unsubGamePaused();
unsubGamePaused = null; unsubGamePaused = null;
} }
if (unsubMailReceived !== null) {
unsubMailReceived();
unsubMailReceived = null;
}
gameState.dispose(); gameState.dispose();
orderDraft.dispose(); orderDraft.dispose();
selection.dispose(); selection.dispose();