a89048f6c5
MVP web client (Phases 1-30) is complete; reorganize planning + living docs around that. - PLAN.md kept as the staged MVP record (1-30) with a status block + pointers; removed the 31-36 stages, regression scenarios, and deferred-TODO section (moved out); fixed a stale cross-machine plan path. - ui/PLAN-finalize.md (new): active web-finalization plan in 8 stages (visual system, a11y, i18n, error UX, PWA, build hygiene, docs, owner manual-QA loop); absorbs former Phases 33 and 35. - ui/ROADMAP.md (new): post-MVP (Wails, Capacitor, realistic projection, acceptance + regression scenarios) and triaged deferred follow-ups. - ui/docs/README.md (new): grouped topic-doc index. - De-archaeologized all 20 ui/docs topic docs + ui/README.md + ui/core/README.md: stripped Phase-N build history, rewritten as current-state; deferred work now points at ROADMAP.md / PLAN-finalize.md. Docs-only; no code change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
98 lines
4.1 KiB
Markdown
98 lines
4.1 KiB
Markdown
# In-game diplomatic mail UI
|
|
|
|
The in-game mail view consumes the `diplomail` subsystem in the
|
|
backend. The route lives at `/games/:id/mail` 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`](../../pkg/schema/fbs/diplomail.fbs);
|
|
the gateway translation lives in
|
|
[`gateway/internal/backendclient/mail_commands.go`](../../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 on
|
|
`recipient_race_name`. Thread messages are sorted oldest → newest
|
|
for chat-style rendering; the unread badge counts incoming
|
|
`read_at === null` rows 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 by `message_id` into 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 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.
|