Files
galaxy-game/ui/frontend/src/lib/toast.svelte.ts
T
Ilia Denisov 5b07bb4e14 ui/phase-24: push events, turn-ready toast, single SubscribeEvents consumer
Wires the gateway's signed SubscribeEvents stream end-to-end:

- backend: emit game.turn.ready from lobby.OnRuntimeSnapshot on every
  current_turn advance, addressed to every active membership, push-only
  channel, idempotency key turn-ready:<game_id>:<turn>;
- ui: single EventStream singleton replaces revocation-watcher.ts and
  carries both per-event dispatch and revocation detection; toast
  primitive (store + host) lives in lib/; GameStateStore gains
  pendingTurn/markPendingTurn/advanceToPending and a persisted
  lastViewedTurn so a return after multiple turns surfaces the same
  "view now" affordance as a live push event;
- mandatory event-signature verification through ui/core
  (verifyPayloadHash + verifyEvent), full-jitter exponential backoff
  1s -> 30s on transient failure, signOut("revoked") on
  Unauthenticated or clean end-of-stream;
- catalog and migration accept the new kind; tests cover producer
  (testcontainers + capturing publisher), consumer (Vitest event
  stream, toast, game-state extensions), and a Playwright e2e
  delivering a signed frame to the live UI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 16:16:31 +02:00

98 lines
2.8 KiB
TypeScript

// `ToastStore` is the single transient-notification primitive for the
// SvelteKit shell. Phase 24 ships it together with the push-event
// dispatch: the per-game layout shows one `Turn N is ready. View now.`
// toast on a verified `game.turn.ready` event. Later phases reuse the
// same store for mail / battle / lobby toasts (PLAN.md §"cross-cutting
// shell").
//
// The store keeps **one** active toast at a time: a fresh `show()`
// replaces the previous descriptor. This matches the UX intent of
// "one loud notification at a time" — the rare cases where several
// events arrive in quick succession are still observable, because
// each replacement re-arms the timer and the user sees every payload
// in turn.
import type { TranslationKey } from "./i18n/index.svelte";
/**
* ToastDescriptor describes one toast in flight. `messageKey` and
* `actionLabelKey` are typed against the i18n catalog so a missing
* translation key fails at compile time. `durationMs === null` (or
* `undefined`) makes the toast sticky — the user must dismiss it
* through the action button or another `show()` call.
*/
export interface ToastDescriptor {
id: string;
messageKey: TranslationKey;
messageParams?: Record<string, string>;
actionLabelKey?: TranslationKey;
onAction?: () => void;
durationMs?: number | null;
}
class ToastStore {
current: ToastDescriptor | null = $state(null);
private timer: ReturnType<typeof setTimeout> | null = null;
private counter = 0;
/**
* show replaces the active toast with descriptor and returns its
* fresh id. Pass that id to `dismiss(id)` from a delayed callback
* to avoid dismissing a newer toast that already took its slot.
*/
show(descriptor: Omit<ToastDescriptor, "id">): string {
this.clearTimer();
this.counter += 1;
const id = String(this.counter);
const full: ToastDescriptor = { ...descriptor, id };
this.current = full;
if (
full.durationMs !== null &&
full.durationMs !== undefined &&
full.durationMs > 0
) {
const duration = full.durationMs;
this.timer = setTimeout(() => {
this.dismiss(id);
}, duration);
}
return id;
}
/**
* dismiss clears the active toast. With an id, the call is a
* no-op when the active toast has a different id — this guards
* the auto-dismiss timer from clobbering a newer descriptor.
*/
dismiss(id?: string): void {
if (
id !== undefined &&
(this.current === null || this.current.id !== id)
) {
return;
}
this.clearTimer();
this.current = null;
}
/**
* resetForTests forgets every in-flight descriptor and the id
* counter. Production code never calls this.
*/
resetForTests(): void {
this.clearTimer();
this.current = null;
this.counter = 0;
}
private clearTimer(): void {
if (this.timer !== null) {
clearTimeout(this.timer);
this.timer = null;
}
}
}
export const toast = new ToastStore();