Phase 28: diplomatic mail UI (work in progress) #11
@@ -1270,6 +1270,20 @@ The message detail response includes both the original body and,
|
|||||||
when available, the cached translation; the client UI defaults to
|
when available, the cached translation; the client UI defaults to
|
||||||
the translated text and offers a "show original" toggle.
|
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
|
### 11.5 Lifecycle hooks
|
||||||
|
|
||||||
Three lobby transitions land as system mail in the affected
|
Three lobby transitions land as system mail in the affected
|
||||||
|
|||||||
@@ -1309,6 +1309,20 @@ bulk-purge всей почты соответствующей партии.
|
|||||||
кэш) перевод; UI по умолчанию показывает перевод и предлагает
|
кэш) перевод; UI по умолчанию показывает перевод и предлагает
|
||||||
переключение «показать оригинал».
|
переключение «показать оригинал».
|
||||||
|
|
||||||
|
Внутриигровой UI группирует личную почту по веткам по расам —
|
||||||
|
каждая личная переписка между локальным игроком и другой расой
|
||||||
|
оказывается в одной ветке, ключевая по расе собеседника.
|
||||||
|
Системные сообщения, административные уведомления и собственные
|
||||||
|
рассылки игрока (платный тариф) показываются отдельными
|
||||||
|
автономными записями в том же списке и никогда не группируются.
|
||||||
|
`read_at` и `deleted_at` поддерживают локальный счётчик
|
||||||
|
непрочитанного и кнопку удаления, но не показываются игроку —
|
||||||
|
дипломатическая почта не обещает уведомления о прочтении. Форма
|
||||||
|
compose выбирает получателя по имени расы (сервер резолвит через
|
||||||
|
`Memberships.ListMembers(game_id, "active")`); клиент не тянет
|
||||||
|
отдельный список членов. Подробнее — в
|
||||||
|
[`ui/docs/diplomail-ui.md`](../ui/docs/diplomail-ui.md).
|
||||||
|
|
||||||
### 11.5 Хуки жизненного цикла
|
### 11.5 Хуки жизненного цикла
|
||||||
|
|
||||||
Три транзитных перехода в лобби порождают system mail в inbox
|
Три транзитных перехода в лобби порождают system mail в inbox
|
||||||
|
|||||||
+66
-2
@@ -3070,9 +3070,73 @@ bottom):
|
|||||||
- animated transitions when survivors re-distribute after an
|
- animated transitions when survivors re-distribute after an
|
||||||
elimination (currently hard-jumps).
|
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
|
Goal: implement a mail inbox and compose flow as a dedicated view that
|
||||||
replaces the map.
|
replaces the map.
|
||||||
|
|||||||
@@ -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 `<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.
|
||||||
Reference in New Issue
Block a user