Phase 28 (Steps 7+8): header unread badge + push/init wiring
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:
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user