From f7300f25a37ec6ec9c0c30701177da7bbf67fdc5 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 15 May 2026 22:43:09 +0200 Subject: [PATCH] Phase 28 (Steps 6+9): mail active view + i18n keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 6 — mail active view + subcomponents. - `lib/active-view/mail.svelte` replaces the Phase 10 stub with the list / detail layout: two-pane on desktop, one-pane stack on mobile (CSS media query, no separate route). - `lib/active-view/mail/thread-list.svelte` renders per-race threads collapsed to their last message plus stand-alone system / admin / outgoing-broadcast items, with unread badges. - `lib/active-view/mail/thread-pane.svelte` is the chat-style transcript for one race; bodies render through `textContent`, per-message Show original / translation toggles flip the rendering when a translated body is present, and a persistent reply box at the bottom calls `mailStore.composePersonal`. - `lib/active-view/mail/system-item-pane.svelte` renders one stand-alone item read-only with the same translation toggle. - `lib/active-view/mail/compose.svelte` is the compose dialog: recipient race picker fed from `report.races[]`, kind toggle (personal / broadcast / admin), admin sub-toggle for target user / all and recipient-scope picker. Server-side enforces paid-tier and owner gating; the UI surfaces 403 inline. - `lib/active-view/mail/system-titles.ts` keeps the keyword → i18n-title mapping for lifecycle-hook system mail so both the list and the detail pane pick the same canonical title. Step 9 — i18n strings (en + ru). `game.mail.*`, `game.view.mail.badge`, `game.events.mail_new.*`, `game.mail.system.*` keys added in lockstep across both locales covering compose labels / validation copy / per-system titles / translation toggle / reply / delete affordances. Co-Authored-By: Claude Opus 4.7 (1M context) --- ui/frontend/src/lib/active-view/mail.svelte | 204 ++++++++++++- .../src/lib/active-view/mail/compose.svelte | 273 ++++++++++++++++++ .../active-view/mail/system-item-pane.svelte | 106 +++++++ .../src/lib/active-view/mail/system-titles.ts | 30 ++ .../lib/active-view/mail/thread-list.svelte | 130 +++++++++ .../lib/active-view/mail/thread-pane.svelte | 255 ++++++++++++++++ ui/frontend/src/lib/i18n/locales/en.ts | 40 +++ ui/frontend/src/lib/i18n/locales/ru.ts | 40 +++ 8 files changed, 1066 insertions(+), 12 deletions(-) create mode 100644 ui/frontend/src/lib/active-view/mail/compose.svelte create mode 100644 ui/frontend/src/lib/active-view/mail/system-item-pane.svelte create mode 100644 ui/frontend/src/lib/active-view/mail/system-titles.ts create mode 100644 ui/frontend/src/lib/active-view/mail/thread-list.svelte create mode 100644 ui/frontend/src/lib/active-view/mail/thread-pane.svelte diff --git a/ui/frontend/src/lib/active-view/mail.svelte b/ui/frontend/src/lib/active-view/mail.svelte index 2c1bb68..edb6e15 100644 --- a/ui/frontend/src/lib/active-view/mail.svelte +++ b/ui/frontend/src/lib/active-view/mail.svelte @@ -1,27 +1,207 @@ -
-

{i18n.t("game.view.mail")}

-

{i18n.t("game.shell.coming_soon")}

+
+
+

{i18n.t("game.view.mail")}

+ +
+ + {#if mailStore.status === "loading"} +

+ {i18n.t("game.mail.loading")} +

+ {:else if mailStore.status === "error"} +

+ {mailStore.error ?? i18n.t("game.mail.load_failed")} +

+ {:else if entries.length === 0} +

+ {i18n.t("game.mail.empty")} +

+ {:else} +
+
+ +
+
+ + {#if selected === null} +

+ {i18n.t("game.mail.select_thread")} +

+ {:else if selected.kind === "thread"} + + {:else} + + {/if} +
+
+ {/if} + + {#if composeOpen} + (composeOpen = false)} + onSent={(raceName: string | null) => { + composeOpen = false; + if (raceName !== null) { + selectedKey = `thread:${raceName}`; + } + }} + /> + {/if}
diff --git a/ui/frontend/src/lib/active-view/mail/compose.svelte b/ui/frontend/src/lib/active-view/mail/compose.svelte new file mode 100644 index 0000000..3cdb3b5 --- /dev/null +++ b/ui/frontend/src/lib/active-view/mail/compose.svelte @@ -0,0 +1,273 @@ + + + +
+
+
+

{i18n.t("game.mail.compose_action")}

+ +
+ + + + {#if kind === "admin"} + + {#if adminTarget === "all"} + + {/if} + {/if} + + {#if kind === "personal" || (kind === "admin" && adminTarget === "user")} + + {/if} + + + + + + {#if error} +

{error}

+ {/if} + +
+ + +
+
+
+ + diff --git a/ui/frontend/src/lib/active-view/mail/system-item-pane.svelte b/ui/frontend/src/lib/active-view/mail/system-item-pane.svelte new file mode 100644 index 0000000..d573bbb --- /dev/null +++ b/ui/frontend/src/lib/active-view/mail/system-item-pane.svelte @@ -0,0 +1,106 @@ + + + +
+

{i18n.t(headerKey)}

+ {#if displaySubject} +
{displaySubject}
+ {/if} +

{displayBody}

+ {#if entry.message.translatedBody} + + {/if} + {#if incoming} + + {/if} +
+ + diff --git a/ui/frontend/src/lib/active-view/mail/system-titles.ts b/ui/frontend/src/lib/active-view/mail/system-titles.ts new file mode 100644 index 0000000..d2954f8 --- /dev/null +++ b/ui/frontend/src/lib/active-view/mail/system-titles.ts @@ -0,0 +1,30 @@ +// Maps a system-mail message (lifecycle hook) to its i18n title key. +// Kept as a typed helper so the thread-list and detail panes pick the +// same title even when the body templates evolve. + +import type { TranslationKey } from "$lib/i18n/index.svelte"; +import type { MailMessage } from "../../../api/diplomail"; + +const KEYWORDS: Array<{ test: RegExp; key: TranslationKey }> = [ + { test: /game[._ ]paused/i, key: "game.mail.system.game_paused.title" }, + { test: /game[._ ]cancelled|cancelled/i, key: "game.mail.system.game_cancelled.title" }, + { test: /membership[._ ]removed|kicked/i, key: "game.mail.system.membership_removed.title" }, + { test: /membership[._ ]blocked|blocked/i, key: "game.mail.system.membership_blocked.title" }, +]; + +/** + * systemTitleKey returns the localised title key for a system mail + * row. The lobby renders these messages through templated subjects; + * the UI matches on the subject to pick a canonical title regardless + * of language. Falls back to a generic system-mail title when no + * pattern matches. + */ +export function systemTitleKey(message: MailMessage): TranslationKey { + const subject = message.subject ?? ""; + for (const { test, key } of KEYWORDS) { + if (test.test(subject)) { + return key; + } + } + return "game.mail.system.generic.title"; +} diff --git a/ui/frontend/src/lib/active-view/mail/thread-list.svelte b/ui/frontend/src/lib/active-view/mail/thread-list.svelte new file mode 100644 index 0000000..bc933e9 --- /dev/null +++ b/ui/frontend/src/lib/active-view/mail/thread-list.svelte @@ -0,0 +1,130 @@ + + + +
    + {#each entries as entry (entryKey(entry))} +
  • 0} + data-testid="mail-list-row" + > + +
  • + {/each} +
+ + diff --git a/ui/frontend/src/lib/active-view/mail/thread-pane.svelte b/ui/frontend/src/lib/active-view/mail/thread-pane.svelte new file mode 100644 index 0000000..8e3977e --- /dev/null +++ b/ui/frontend/src/lib/active-view/mail/thread-pane.svelte @@ -0,0 +1,255 @@ + + + +
+

{thread.raceName}

+
    + {#each thread.messages as m (m.messageId)} +
  1. +
    + + {#if isOutgoing(m)} + {i18n.t("game.mail.outgoing_label")} + {:else} + {thread.raceName} + {/if} + + +
    + {#if displaySubject(m)} +
    {displaySubject(m)}
    + {/if} +

    {displayBody(m)}

    + {#if m.translatedBody} + + {/if} + {#if !isOutgoing(m)} + + {/if} +
  2. + {/each} +
+ +
+ + + {#if replyError} +

{replyError}

+ {/if} + +
+
+ + diff --git a/ui/frontend/src/lib/i18n/locales/en.ts b/ui/frontend/src/lib/i18n/locales/en.ts index ee65623..be0bca3 100644 --- a/ui/frontend/src/lib/i18n/locales/en.ts +++ b/ui/frontend/src/lib/i18n/locales/en.ts @@ -123,6 +123,46 @@ const en = { "game.view.report": "turn report", "game.view.battle": "battle log", "game.view.mail": "diplomatic mail", + "game.view.mail.badge": "{count}", + "game.events.mail_new.message": "new mail from {from}", + "game.events.mail_new.action": "view", + "game.mail.loading": "loading mail…", + "game.mail.load_failed": "could not load mail", + "game.mail.empty": "no diplomatic messages yet", + "game.mail.back": "back", + "game.mail.compose_action": "compose", + "game.mail.select_thread": "pick a thread on the left to read it", + "game.mail.broadcast.title": "your broadcast", + "game.mail.admin.title": "admin notification", + "game.mail.system.generic.title": "system message", + "game.mail.system.game_paused.title": "game paused", + "game.mail.system.game_cancelled.title": "game cancelled", + "game.mail.system.membership_removed.title": "membership removed", + "game.mail.system.membership_blocked.title": "membership blocked", + "game.mail.subject_placeholder": "subject (optional)", + "game.mail.body_placeholder": "your message…", + "game.mail.recipient_label": "race", + "game.mail.recipient_required": "pick a recipient race", + "game.mail.body_required": "the message body cannot be empty", + "game.mail.body_too_long": "the body exceeds the {limit} byte limit", + "game.mail.subject_too_long": "the subject exceeds the {limit} byte limit", + "game.mail.compose.send": "send", + "game.mail.compose.cancel": "cancel", + "game.mail.compose.target_personal": "personal", + "game.mail.compose.target_broadcast": "broadcast", + "game.mail.compose.target_admin": "admin", + "game.mail.compose.recipients_active": "active members", + "game.mail.compose.recipients_active_and_removed": "active + removed", + "game.mail.compose.recipients_all_members": "all members", + "game.mail.compose.target_label": "kind", + "game.mail.compose.recipients_label": "audience", + "game.mail.compose.send_failed": "send failed", + "game.mail.show_original": "show original", + "game.mail.show_translation": "show translation", + "game.mail.translation_unavailable": "translation unavailable", + "game.mail.reply_label": "reply", + "game.mail.delete_action": "delete", + "game.mail.outgoing_label": "you", "game.view.designer.ship_class": "ship-class designer", "game.view.designer.science": "science designer", "game.sidebar.tab.calculator": "calculator", diff --git a/ui/frontend/src/lib/i18n/locales/ru.ts b/ui/frontend/src/lib/i18n/locales/ru.ts index eafd66c..25dee81 100644 --- a/ui/frontend/src/lib/i18n/locales/ru.ts +++ b/ui/frontend/src/lib/i18n/locales/ru.ts @@ -124,6 +124,46 @@ const ru: Record = { "game.view.report": "отчёт хода", "game.view.battle": "журнал боёв", "game.view.mail": "дипломатическая почта", + "game.view.mail.badge": "{count}", + "game.events.mail_new.message": "новое письмо от {from}", + "game.events.mail_new.action": "открыть", + "game.mail.loading": "загрузка почты…", + "game.mail.load_failed": "не удалось загрузить почту", + "game.mail.empty": "дипломатических сообщений пока нет", + "game.mail.back": "назад", + "game.mail.compose_action": "написать", + "game.mail.select_thread": "выбери ветку слева", + "game.mail.broadcast.title": "твоя рассылка", + "game.mail.admin.title": "административное уведомление", + "game.mail.system.generic.title": "системное сообщение", + "game.mail.system.game_paused.title": "игра поставлена на паузу", + "game.mail.system.game_cancelled.title": "игра отменена", + "game.mail.system.membership_removed.title": "членство удалено", + "game.mail.system.membership_blocked.title": "членство заблокировано", + "game.mail.subject_placeholder": "тема (необязательно)", + "game.mail.body_placeholder": "твоё сообщение…", + "game.mail.recipient_label": "раса", + "game.mail.recipient_required": "выбери расу-получателя", + "game.mail.body_required": "тело сообщения не может быть пустым", + "game.mail.body_too_long": "длина тела превышает лимит {limit} байт", + "game.mail.subject_too_long": "длина темы превышает лимит {limit} байт", + "game.mail.compose.send": "отправить", + "game.mail.compose.cancel": "отмена", + "game.mail.compose.target_personal": "личное", + "game.mail.compose.target_broadcast": "рассылка", + "game.mail.compose.target_admin": "админ.", + "game.mail.compose.recipients_active": "активным членам", + "game.mail.compose.recipients_active_and_removed": "активным + удалённым", + "game.mail.compose.recipients_all_members": "всем членам", + "game.mail.compose.target_label": "тип", + "game.mail.compose.recipients_label": "адресаты", + "game.mail.compose.send_failed": "отправка не удалась", + "game.mail.show_original": "показать оригинал", + "game.mail.show_translation": "показать перевод", + "game.mail.translation_unavailable": "перевод недоступен", + "game.mail.reply_label": "ответить", + "game.mail.delete_action": "удалить", + "game.mail.outgoing_label": "ты", "game.view.designer.ship_class": "конструктор класса кораблей", "game.view.designer.science": "редактор наук", "game.sidebar.tab.calculator": "калькулятор",