Rewrite ui/docs (navigation, order-composer, auth-flow, pwa-strategy, game-state + secondary topic docs) and ui/README for the single-URL app-shell (in-memory screens/views, Back→lobby via shallow routing, sessionStorage restore + validation, return-to-lobby). ui/PLAN.md gets a Phase-10 supersede note (implemented; standalone-compatible). Fix stale code comments (session-store auth gate, report-sections spec contract). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4.3 KiB
In-game diplomatic mail UI
The in-game mail view consumes the diplomail subsystem in the
backend. It is the mail active view (activeView.view === "mail")
and replaces the active view when the user opens the "diplomatic mail"
entry in the header menu (activeView.select("mail")). The app-shell
has no per-view URL — see navigation.md.
Wire surface
Eight ConnectRPC commands sit between UI and backend, all under the
user.games.mail.* namespace:
| Command | Backend REST endpoint |
|---|---|
user.games.mail.inbox |
GET /api/v1/user/games/{id}/mail/inbox |
user.games.mail.sent |
GET …/mail/sent |
user.games.mail.message.get |
GET …/mail/messages/{message_id} |
user.games.mail.send |
POST …/mail/messages |
user.games.mail.broadcast |
POST …/mail/broadcast |
user.games.mail.admin |
POST …/mail/admin |
user.games.mail.read |
POST …/mail/messages/{id}/read |
user.games.mail.delete |
DELETE …/mail/messages/{id} |
The FlatBuffers schemas live under
pkg/schema/fbs/diplomail.fbs;
the gateway translation lives in
gateway/internal/backendclient/mail_commands.go.
Recipient by race name
The compose flow does not consult a memberships listing. The
recipient picker reads gameState.report.races[].name (projected
from report.player[]), and the send request carries the
chosen race name as recipient_race_name. The backend resolves it
against Memberships.ListMembers(gameID, "active") and rejects with
forbidden if the matching member is no longer active. This keeps
the UI off the lobby surface for the common case.
Threading model
MailStore.entries is the derived rune the active view consumes. It
projects the union of inbox and sent into:
- Per-race threads — every personal message keyed by another
race contributes to a thread keyed on that race name. Incoming is
keyed on
sender_race_name; outgoing is keyed onrecipient_race_name. Thread messages are sorted oldest → newest for chat-style rendering; the unread badge counts incomingread_at === nullrows only. - Stand-alone items — system mail (
sender_kind=system), admin notifications (sender_kind=admin), and the caller's own paid-tier broadcasts (broadcast_scope=game_broadcast). Backend returns one row per recipient for paid-tier broadcasts; the UI collapses them bymessage_idinto a single stand-alone item.
read_at and deleted_at are not surfaced to the user in any pane
— they only drive the badge counter and the optimistic mark-read
state. This is intentional: the user-facing spec for diplomatic mail
does not promise read receipts.
Translation toggle
When a message detail carries translated_body, the body and (if
non-empty) subject default to the translated rendering. Each message
pane exposes a "Show original" / "Show translation" button that
flips the per-message state. Messages without a cached translation
render the original directly with no toggle.
Push events
diplomail.message.received push frames are dispatched from
api/events.svelte.ts via the singleton SubscribeEvents stream. The
in-game shell (lib/game/game-shell.svelte) parses the
verified payload, calls mailStore.applyPushEvent(gameId) (which
re-fetches the inbox — the payload only carries a preview), and
raises a toast through lib/toast.svelte.ts whose "view" action
switches to the mail view in memory (activeView.select("mail")) —
no URL navigation.
The header view-menu's mail entry shows mailStore.unreadCount as
an inline pill — the only chrome the badge needs.
Layout
Desktop (≥ 768 px) renders a two-pane CSS grid: list on the left, detail on the right. Mobile flips to a single-pane stack; tapping a list row hides the list and shows the detail with a back button.
Accessibility
- Bodies render through Svelte's default text-content path (no HTML parsing) per the backend rule of treating message text as plain UTF-8.
- The compose dialog uses native form controls; the recipient
picker is a
<select>so screen-readers and keyboard users get the standard semantics. - The reply box and the compose body are real
<textarea>s so shift-enter newlines, paste, and selection behave correctly.