diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 054317b..15c4271 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -1270,6 +1270,20 @@ The message detail response includes both the original body and, when available, the cached translation; the client UI defaults to the translated text and offers a "show original" toggle. +The in-game UI groups personal mail into per-race threads — every +personal message exchanged between the local player and another +race lands in one thread keyed on the other party's race. System +mail, admin notifications, and the player's own paid-tier +broadcasts render as stand-alone entries in the same list pane and +are never threaded. `read_at` and `deleted_at` drive the local +unread counter and the soft-delete affordance but are not surfaced +to the user — diplomatic mail does not promise read receipts. The +compose form picks the recipient by race name (resolved +server-side from `Memberships.ListMembers(game_id, "active")`); no +client-side memberships listing is fetched. See +[`ui/docs/diplomail-ui.md`](../ui/docs/diplomail-ui.md) for the +detailed UI breakdown. + ### 11.5 Lifecycle hooks Three lobby transitions land as system mail in the affected diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 77460dd..a3b2d60 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -1309,6 +1309,20 @@ bulk-purge всей почты соответствующей партии. кэш) перевод; UI по умолчанию показывает перевод и предлагает переключение «показать оригинал». +Внутриигровой UI группирует личную почту по веткам по расам — +каждая личная переписка между локальным игроком и другой расой +оказывается в одной ветке, ключевая по расе собеседника. +Системные сообщения, административные уведомления и собственные +рассылки игрока (платный тариф) показываются отдельными +автономными записями в том же списке и никогда не группируются. +`read_at` и `deleted_at` поддерживают локальный счётчик +непрочитанного и кнопку удаления, но не показываются игроку — +дипломатическая почта не обещает уведомления о прочтении. Форма +compose выбирает получателя по имени расы (сервер резолвит через +`Memberships.ListMembers(game_id, "active")`); клиент не тянет +отдельный список членов. Подробнее — в +[`ui/docs/diplomail-ui.md`](../ui/docs/diplomail-ui.md). + ### 11.5 Хуки жизненного цикла Три транзитных перехода в лобби порождают system mail в inbox diff --git a/ui/PLAN.md b/ui/PLAN.md index 78572bf..94cee6f 100644 --- a/ui/PLAN.md +++ b/ui/PLAN.md @@ -3070,9 +3070,73 @@ bottom): - animated transitions when survivors re-distribute after an elimination (currently hard-jumps). -## Phase 28. Diplomatic Mail View +## ~~Phase 28. Diplomatic Mail View~~ -Status: pending. +Status: done (pending CI gate). + +Decisions baked in during implementation: + +1. **Transport: ConnectRPC `user.games.mail.*`.** Eight new + authenticated commands (inbox / sent / message.get / send / + broadcast / admin / read / delete) plumbed end-to-end through + the existing gateway → backend REST surface. Schemas in + `pkg/schema/fbs/diplomail.fbs`; constants in + `pkg/model/diplomail/diplomail.go`; gateway translation in + `gateway/internal/backendclient/mail_commands.go`. +2. **Recipient by race name.** The send / admin endpoints accept + an alternative `recipient_race_name` field; backend resolves it + via `Memberships.ListMembers(gameID, "active")`. The UI feeds + the picker straight off `report.races[].name` — no client-side + memberships RPC. +3. **`sender_race_name` snapshot.** New nullable column on + `diplomail_messages`, populated for `sender_kind='player'` + senders that have an active membership at send time. Drives the + per-race threading on the client. +4. **/sent returns full message detail.** Backend's bulk sent + listing now returns the same `UserMailMessageDetail` shape as + `/inbox`, one row per (message, recipient). The UI collapses + broadcasts by `message_id` into a single stand-alone item. +5. **Threading + stand-alones.** `MailStore.entries` groups + personal messages by the other party's race name. System, + admin, and outgoing broadcasts render as stand-alone items in + the same list pane. +6. **No read receipts.** `read_at` and `deleted_at` drive the + badge counter and soft-delete affordance but are never shown + to the user. +7. **Header badge.** Inline pill on the view-menu "diplomatic + mail" row, fed by `mailStore.unreadCount`. No always-visible + chrome added. +8. **Push event reuse.** A new + `eventStream.on("diplomail.message.received", …)` handler in + `routes/games/[id]/+layout.svelte` parses the verified payload, + refreshes the inbox, and raises a `toast.show` with a "view" + deep-link. +9. **Translation toggle.** Per-message Show original / Show + translation toggle inside both `thread-pane.svelte` and + `system-item-pane.svelte`; the body defaults to the cached + translation when present. + +Artifacts (delivered): + +- backend: `internal/postgres/migrations/00001_init.sql`, + `internal/diplomail/{types.go,store.go,service.go,admin_send.go,diplomail_e2e_test.go,README.md}`, + `internal/server/{handlers_user_mail.go,handlers_admin_diplomail.go}`, + `openapi.yaml`; +- wire: `pkg/schema/fbs/diplomail.fbs` + generated Go and TS + bindings; `pkg/model/diplomail/diplomail.go`; +- gateway: `gateway/internal/backendclient/{mail_commands.go,routes.go,mail_commands_test.go}`, + `gateway/cmd/gateway/main.go`; +- ui: `ui/frontend/src/api/diplomail.ts`, + `ui/frontend/src/lib/mail-store.svelte.ts`, + `ui/frontend/src/lib/active-view/mail.svelte` (+ subdir + `mail/{thread-list,thread-pane,system-item-pane,compose,system-titles}.svelte|.ts`), + `ui/frontend/src/lib/header/view-menu.svelte`, + `ui/frontend/src/routes/games/[id]/+layout.svelte`, + `ui/frontend/src/lib/i18n/locales/{en,ru}.ts`; +- docs: `ui/docs/diplomail-ui.md`, `docs/FUNCTIONAL.md` §11.4 + + mirror in `docs/FUNCTIONAL_ru.md`. + +Original phase brief follows. Goal: implement a mail inbox and compose flow as a dedicated view that replaces the map. diff --git a/ui/docs/diplomail-ui.md b/ui/docs/diplomail-ui.md new file mode 100644 index 0000000..1f71e83 --- /dev/null +++ b/ui/docs/diplomail-ui.md @@ -0,0 +1,97 @@ +# 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`](../../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` (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 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 (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 `