- `ui/docs/diplomail-ui.md`: new topic doc covering the wire surface, recipient-by-race-name decision, threading model, translation toggle, push events, badge, layout, and accessibility. - `docs/FUNCTIONAL.md` §11.4 grows a paragraph that records the UI's per-race threading rule, the absent read-receipt UX, and the recipient-by-race-name compose path. Mirrored verbatim into `docs/FUNCTIONAL_ru.md`. - `ui/PLAN.md` Phase 28 marked done with a "Decisions during stage" block matching the implementation plan, and the artifact list updated to the actual file set. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4.2 KiB
In-game diplomatic mail UI
Phase 28 wires the in-game mail view that consumes the diplomail
subsystem in the backend. The route lives at /games/:id/mail
(registered in Phase 10) and replaces the active view when the user
opens the "diplomatic mail" entry in the header menu.
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 (the Phase 22
projection of 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 (per Phase 28 decisions): 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 layout (routes/games/[id]/+layout.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 with a "view"
deep-link to /games/:id/mail.
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.