From 57e75af3ac7d74e53c037a5663105603fe466fa2 Mon Sep 17 00:00:00 2001 From: randogoth Date: Thu, 8 Oct 2026 22:10:29 +0300 Subject: [PATCH] feat: extract all user-facing strings into a Fluent catalogue --- Cargo.lock | 153 +++++++++++ Cargo.toml | 2 + locales/en-US/main.ftl | 343 ++++++++++++++++++++++++ src/app.rs | 59 ++--- src/i18n.rs | 64 +++++ src/main.rs | 1 + src/ops.rs | 142 +++++++--- src/view.rs | 586 +++++++++++++++++++++++------------------ 8 files changed, 1024 insertions(+), 326 deletions(-) create mode 100644 locales/en-US/main.ftl create mode 100644 src/i18n.rs diff --git a/Cargo.lock b/Cargo.lock index 6644ddf..411e8bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,6 +118,7 @@ dependencies = [ name = "carrier" version = "0.1.0" dependencies = [ + "fluent-bundle", "fumi-core", "gtk4", "libadwaita", @@ -125,6 +126,7 @@ dependencies = [ "rand_core", "relm4", "tempfile", + "unic-langid", "winresource", ] @@ -278,6 +280,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -359,6 +372,41 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fluent-bundle" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01203cb8918f5711e73891b347816d932046f95f54207710bda99beaeb423bf4" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash", + "self_cell", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198" +dependencies = [ + "memchr", + "thiserror", +] + [[package]] name = "flume" version = "0.11.1" @@ -845,6 +893,25 @@ dependencies = [ "generic-array", ] +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1127,6 +1194,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -1161,6 +1234,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + [[package]] name = "semver" version = "1.0.28" @@ -1352,6 +1431,37 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + [[package]] name = "tokio" version = "1.53.1" @@ -1443,12 +1553,39 @@ dependencies = [ "once_cell", ] +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash", +] + [[package]] name = "typenum" version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "tinystr", +] + [[package]] name = "unicode-ident" version = "1.0.26" @@ -1673,6 +1810,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" + [[package]] name = "zeroize" version = "1.9.0" @@ -1693,6 +1836,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "serde", + "zerofrom", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 19f960e..6aa56dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,8 @@ libadwaita = { version = "0.7", features = ["v1_5"] } relm4 = { version = "0.9", features = ["libadwaita"] } rand_core = { version = "0.6", features = ["getrandom"] } qrcodegen = "1" +fluent-bundle = "0.16.0" +unic-langid = "0.9.6" [build-dependencies] winresource = "0.1" diff --git a/locales/en-US/main.ftl b/locales/en-US/main.ftl new file mode 100644 index 0000000..03a3bc1 --- /dev/null +++ b/locales/en-US/main.ftl @@ -0,0 +1,343 @@ +## Loaded once at startup by `src/i18n.rs`; every user-facing string in +## the app lives here, grouped by the feature it belongs to. + +## Seed parsing — restoring an identity from a pasted seed + +seed-bad-hex = bad hex +seed-wrong-length = a seed is 32 bytes + +## Local files — errors reading/writing the key and its sidecars + +file-error = { $path }: { $reason } +key-file-wrong-length = { $path }: not a 32-byte master + +## Compose — validating a typed `Key: value` header row + +compose-header-colon-missing = a header is `Key: value` — the colon is missing +compose-header-reserved-key = { $key } is attached by the client and cannot be set +compose-header-invalid-key = keys are 1-64 characters of letters, digits and dashes +compose-header-value-multiline = a value must stay on one line +compose-header-duplicate-key = { $key } is set twice +compose-headers-too-large = the headers exceed the frontmatter budget +compose-headers-too-many = too many headers + +## Contacts — importing a smol:// address + +contacts-import-needs-key = import needs a self-certifying smol:// address carrying a key + +## Mail — opening a message by id + +mail-message-id-wrong-length = message ids are 64 hex characters +mail-message-id-wrong-bytes = message ids are 32 bytes +mail-message-not-found = message not found + +## Fetch — the status note after a fetch completes +## +## `$variant` picks the one combination of "N rejected" and "(cancelled)" +## that applies, so each case reads as one complete sentence rather than +## stitched-together fragments. + +fetch-result-note = { $variant -> + [rejected] { $total } fetched, { $stored } stored, { $rejected } rejected + [cancelled] { $total } fetched, { $stored } stored (cancelled) + [rejected-cancelled] { $total } fetched, { $stored } stored, { $rejected } rejected (cancelled) + *[plain] { $total } fetched, { $stored } stored +} + +## Send — the status note after a send completes +## +## `$variant` picks the one combination of trust-change clause and +## accept-token clause that applies, each spelled out as a full sentence. +## Any warning fumi itself reports is appended separately in code, since +## that text is fumi's own and not part of this catalogue. + +send-result-note = { $variant -> + [token] Sent to { $address } ({ $bytes } bytes), accept token attached + [unverified] Sent to { $address } ({ $bytes } bytes); new contact learned from an unpinned server (unverified) + [unverified-token] Sent to { $address } ({ $bytes } bytes), accept token attached; new contact learned from an unpinned server (unverified) + [tofu] Sent to { $address } ({ $bytes } bytes); new contact learned (trust on first use) + [tofu-token] Sent to { $address } ({ $bytes } bytes), accept token attached; new contact learned (trust on first use) + [rotated] Sent to { $address } ({ $bytes } bytes); contact rotated to a new key (chain verified) + [rotated-token] Sent to { $address } ({ $bytes } bytes), accept token attached; contact rotated to a new key (chain verified) + *[plain] Sent to { $address } ({ $bytes } bytes) +} + +## Background-operation status notes +## +## Built in `spawn_op` (src/view.rs) rather than ops.rs, since that is +## where each `PendingOp` result is assembled for the worker thread. + +status-registered-note = registered +status-restored-note = restored at rotation { $index } +status-deleted-note = deleted +status-accepted-note = accepted { $address } +status-blocked-note = blocked { $address } +status-not-blocked-note = { $address } was not blocked +status-imported-note = imported { $address } (verified) +status-rotated-note = rotated to index { $index }; new key { $key_prefix }… + +## Backup + +backup-written-note = backup written to { $path } + +## Drafts + +draft-saved-note = draft saved + +## Log out + +logged-out-note = logged out. Create a new identity or restore a seed. + +## Onboarding + +identity-created-note = Identity created. Write the seed down; it is the only secret. +seed-restored-note = Seed restored. Bind a username or recall an address. + +## Signup + +signup-enter-host-error = enter the server host first +signup-enter-user-host-error = enter user and host + +## Compose + +compose-enter-recipient-error = enter a recipient +compose-header-row-error = header { $index }: { $reason } +reply-no-address-error = this sender left no address to reply to +reply-subject-prefix = Re: { $subject } +address-invalid-error = that address does not parse + +## Contacts + +contacts-reply-address-invalid-error = the sender's reply address does not parse +contacts-sender-key-invalid-error = the sender's key does not parse +contacts-accept-no-address-error = this sender left no address to accept +contacts-block-no-address-error = this sender left no address to block + +## Common — short words reused verbatim across several buttons/dialogs + +action-cancel = Cancel +action-back = Back +action-block = Block +action-unblock = Unblock +action-accept = Accept +action-reply = Reply +action-delete = Delete +# Shown wherever a fetched server key is on screen before it's pinned: +# the compose's pin card, the signup server step, and the contact detail +# body when a key was fetched for that contact. +trust-fingerprint-hint = If the server operator published a key or fingerprint, make sure it matches before trusting it. + +## Sidebar + +mail-fetch-button = Fetch +mail-write-button = Write +folder-inbox = Inbox +folder-requests = Requests +folder-sent = Sent +folder-drafts = Drafts +settings-nav-label = Settings +identity-you-caption = you +contacts-caption = contacts +contacts-import-placeholder = Import a smol:// address +contacts-import-button = Import + +## Mail — the open-message meta line +## +## `$kept` picks the one variant that applies, each a full sentence, +## rather than an optional clause spliced onto a shared prefix. + +mail-meta-to = to +mail-meta-from = from +mail-meta-line = { $kept -> + *[no] { $label } { $from } · { $time } UTC · id { $id }… + [yes] { $label } { $from } · { $time } UTC · id { $id }… · a server copy still exists +} +mail-no-subject = (no subject) +mail-no-reply-address = no reply address: cannot accept or block +contacts-empty-hint = no contacts + +## Mail list — empty-folder messages, and the own-identity detail + +mail-inbox-empty-title = Inbox is empty +mail-inbox-empty-description = Fetch to check the mailbox. +mail-requests-empty-title = No requests +mail-requests-empty-description = Mail from senders you have not accepted yet arrives here. +mail-sent-empty-title = Nothing sent yet +mail-sent-empty-description = Messages you send keep a sealed copy here. +mail-drafts-empty-title = No drafts +mail-drafts-empty-description = A compose closed from the sidebar is kept here. +own-identity-meta = you · rotation { $rotations } +contacts-pin-button-fetched = Pin this key +contacts-pin-button-unfetched = Pin server + +mail-empty-title = No message selected +mail-empty-description = Pick a message from the list above to read it. +# The two sentences shown under Fetch when the inbox is empty. +mail-empty-hint = Fetching removes mail from the server once it's stored here. + Turn on "Leave mail on server" in Settings to keep it there instead. +detail-qr-tooltip = Show the address as a QR code +detail-copy-tooltip = Copy the address + +## Compose + +compose-to-label = To +compose-subject-label = Subject +compose-add-header-button = + header +compose-send-button = Send +compose-header-placeholder = Key: value +compose-header-remove-tooltip = Remove this header + +## Contacts — trust state shown in the sidebar and the detail pane + +contact-state-accepted = accepted +contact-state-blocked = blocked +contact-state-verified = verified +contact-state-tofu = trust on first use +# `$state` is one of the four words above, already resolved by the caller +# through `contact_state`; this message only adds the server's pin state. +contact-meta-line = { $state } · server { $pinned -> + [yes] pinned + *[no] not pinned +} + +## Detail pane — the monospace key/value block shared by contacts and the +## own identity view. Column widths are padded to these labels in code +## (14 characters), so a much longer translation would throw the +## alignment off; flagged in the extraction report. + +detail-key-label = key +detail-fingerprint-label = fingerprint +detail-server-label = server +contact-server-key-offered = this server presents + +## Settings — tab switcher labels + +settings-tab-identity = Identity +settings-tab-server = Server +settings-tab-backup = Backup +settings-tab-advanced = Advanced + +## Settings — Identity tab + +settings-field-address = Address +settings-field-key = Key +settings-field-fingerprint = Fingerprint +settings-field-uri = URI +property-row-copy-tooltip = Copy the { $field } +settings-recovery-secret-title = Recovery Secret +settings-secret-reveal-tooltip = Reveal the recovery secret +settings-secret-hide-tooltip = Hide the recovery secret +settings-secret-copy-tooltip = Copy the recovery secret + +## Settings — Server tab + +settings-field-host = Host +settings-field-public-key = Public Key +settings-public-key-not-pinned = not pinned yet +server-connection-title = Connection +server-timeout-title = Timeout +server-timeout-subtitle = Seconds before a network call gives up +server-fetching-title = Fetching +server-leave-mail-title = Leave mail on server +server-leave-mail-subtitle = Keep a copy on the server after fetching, so another client can fetch it too. When this is enabled, deleting a message removes it from this client and the server. + +## Settings — Backup tab + +backup-page-description = One file holding your mail, contacts and server info for moving to another client, or for keeping what the server no longer has. It is sealed to this identity and deliberately leaves the secret itself out. +backup-export-title = Export +backup-export-subtitle = Write a backup file +backup-import-title = Import +backup-import-subtitle = Restore from a backup file +backup-export-dialog-title = Export Backup +backup-import-dialog-title = Import Backup + +## Settings — Advanced tab + +advanced-rotations-title = Rotations +advanced-rotations-description = Issues a fresh key from your secret with a signed certificate proving it replaces this one. Contacts accept the change from that chain, and the old key stays readable. Rotation is not revocation. +advanced-rotations-completed = Completed +advanced-rotate-button = Rotate identity +advanced-first-contact-title = First Contact +advanced-first-contact-description = A recipient who is not a contact yet, and whose address carries no key, has to be looked up on their own server. That answer is only as good as the server: one with no pinned key can return a key of its own and read what you send. +advanced-strict-pinning-title = Require a pinned server +advanced-strict-pinning-subtitle = Stop such a send and offer to pin the server's key first, so it can be checked against what the operator published. With this off, the key is taken on trust and the send is only reported as unverified afterwards. +advanced-logout-title = Log Out +advanced-logout-description = Removes this identity from this client entirely: the secret, pins, contacts and every cached message. Without a backup of the secret there is no way back in. Mail already fetched here may have no other copy anywhere, unless "leave mail on server" was on. Whatever is still on the server comes back on the first fetch after logging back in. +advanced-logout-button = Log out and delete all data + +## Onboarding +## +## The app name itself ("carrier") is a brand, not translated prose, and +## is left as a literal in code — see the extraction report. + +onboard-tagline = smolmail: one seed, sealed mail, five operations +onboard-create-button = Create a new identity +onboard-restore-button = Restore existing identity from seed +onboard-created-title = Write this seed down +onboard-created-description = It is the only secret, and it is shown once. +onboard-saved-button = I saved it +onboard-restore-title = Restore from a seed +onboard-seed-entry-title = Seed — 32 bytes, base32 or hex +onboard-restore-go-button = Restore + +## Signup + +signup-server-title = Set up the server +signup-fetch-key-button = Fetch the server's key +signup-continue-button = Continue +signup-start-over-button = Start over +signup-account-title = Choose your address +signup-mode-new-label = Create a new address +signup-mode-existing-label = I already have this address +signup-user-label = User +signup-invite-label = Invite token (if required by the server) +signup-register-button = Register +signup-recall-button = Restore access + +## Compose — the inline pin card shown after a blocked send + +compose-pin-title = { $host } is not pinned +compose-pin-body = { $address } is not a contact yet, so carrier would have to ask this server for their key — and a server with no pinned key can answer with one of its own and read the message. +compose-pin-button-fetch = Fetch their server's key +compose-pin-button-send = Pin and send + +## Dialogs + +delete-dialog-title = Delete this message? +delete-dialog-body-kept = Deleting it here leaves the server's copy in place. Deleting it everywhere asks the server to drop that copy too. +delete-dialog-body-gone = The sealed copy in the local store is removed. +delete-dialog-everywhere-button = Delete Everywhere +rotate-dialog-title = Rotate the identity? +reset-dialog-title = Log out and delete all data? +# `$list` is the sender's own frontmatter rows, already typed by a human +# and reproduced verbatim — not translatable prose itself, just data +# interpolated into this one sentence. +reply-dialog-title = Carry the sender's fields into the reply? +reply-dialog-body = This message carries its own frontmatter:{"\u000A"}{"\u000A"}{ $list }{"\u000A"}{"\u000A"}Reply with copies of these fields, or start with none. The fields stay editable either way. +reply-dialog-cancel-button = Cancel Reply +reply-dialog-without-button = Reply Without +reply-dialog-copy-button = Copy Fields +key-change-dialog-title = Replace this contact's key? +key-change-dialog-body = { $address } is already on file with a different key.{"\u000A"}{"\u000A"}known { $known_fingerprint }{"\u000A"}new { $offered_fingerprint }{"\u000A"}{"\u000A"}The address you were given carries the new key and would replace the stored one. If this contact did not hand it to you themselves, stop: a key swapped in on the way reads everything you send them. +key-change-dialog-replace-button = Replace Key + +## Status bar +## +## `model.busy` also doubles as an internal state-machine key compared +## against literal English in app.rs (e.g. `self.busy.as_deref() == +## Some("fetching")`), so that value itself is not translated — only its +## display here is, via this lookup from the internal label to a phrase. +## See the extraction report for the coupling this implies. + +status-busy-fetching = fetching… +status-busy-refreshing = refreshing… +status-busy-registering = registering… +status-busy-restoring = restoring… +status-fetching-progress = fetching… { $count } in inbox + +## First contact / server trust + +first-contact-blocked-error = { $address } is not a contact yet and { $host } is not pinned — pin their server's key to send. +server-key-offered-note = The server presented this key. Pin it only if the fingerprint matches what the operator published. +server-key-already-pinned-note = This server's key is already pinned. +server-key-pinned-note = server key pinned diff --git a/src/app.rs b/src/app.rs index 9cd9dee..4ccc2d4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -15,6 +15,7 @@ use fumi_core::crypto::{fingerprint, unb32, KEY_LEN}; use fumi_core::store::Store; use gtk::prelude::*; +use crate::i18n; use crate::ops::{self, Draft, Folder, OpenedMsg, Row, Snapshot}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -514,7 +515,7 @@ impl Carrier { at: fumi_core::store::now(), }); ops::save_drafts(&self.drafts_path(), &self.drafts); - self.note("draft saved".into()); + self.note(i18n::t("draft-saved-note")); } /// Tapping a draft in the folder puts it back in the compose it came @@ -555,7 +556,7 @@ impl Carrier { } let (app, _) = Carrier::boot(self.dir.clone()); *self = app; - self.note("logged out. Create a new identity or restore a seed.".into()); + self.note(i18n::t("logged-out-note")); } /// Cancelling out of a not-yet-registered identity removes the key @@ -853,9 +854,7 @@ impl Carrier { self.master = Some(master); self.onboard.seed_shown = ops::seed_text(&master); self.onboard.mode = OnboardMode::Created; - self.note( - "Identity created. Write the seed down; it is the only secret.".into(), - ); + self.note(i18n::t("identity-created-note")); } } Message::OnboardRestore => { @@ -877,7 +876,7 @@ impl Carrier { } else { self.master = Some(master); self.page = Page::Signup; - self.note("Seed restored. Bind a username or recall an address.".into()); + self.note(i18n::t("seed-restored-note")); } } Err(e) => self.fail(e), @@ -904,14 +903,14 @@ impl Carrier { return Vec::new(); } let Some(addr) = self.signup_host_address() else { - self.fail("enter the server host first".into()); + self.fail(i18n::t("signup-enter-host-error")); return Vec::new(); }; return vec![PendingOp::LearnKey { addr }]; } Message::SignupRegister => { let Some(addr) = self.signup_address() else { - self.fail("enter user and host".into()); + self.fail(i18n::t("signup-enter-user-host-error")); return Vec::new(); }; let invite = if self.signup.invite.is_empty() { @@ -926,7 +925,7 @@ impl Carrier { } Message::SignupRecall => { let Some(addr) = self.signup_address() else { - self.fail("enter user and host".into()); + self.fail(i18n::t("signup-enter-user-host-error")); return Vec::new(); }; if !self.start("restoring") { @@ -993,7 +992,7 @@ impl Carrier { let to = if let Some(uri) = &msg.reply_to { uri.clone() } else if msg.from.starts_with('<') { - self.fail("this sender left no address to reply to".into()); + self.fail(i18n::t("reply-no-address-error")); return Vec::new(); } else { msg.from.clone() @@ -1001,7 +1000,7 @@ impl Carrier { let subject = if msg.subject.is_empty() || msg.subject.starts_with("Re:") { msg.subject.clone() } else { - format!("Re: {}", msg.subject) + i18n::t1("reply-subject-prefix", "subject", msg.subject.clone()) }; // The sender's own fields are the one thing a reply has to // decide about: carry copies, or start clean. Only the @@ -1057,7 +1056,7 @@ impl Carrier { return Vec::new(); }; if compose.to.trim().is_empty() { - self.fail("enter a recipient".into()); + self.fail(i18n::t("compose-enter-recipient-error")); return Vec::new(); } let mut headers = Vec::new(); @@ -1066,7 +1065,10 @@ impl Carrier { Ok(Some(pair)) => headers.push(pair), Ok(None) => {} Err(reason) => { - self.fail(format!("header {}: {reason}", index + 1)); + let mut args = fluent_bundle::FluentArgs::new(); + args.set("index", (index + 1) as i64); + args.set("reason", reason); + self.fail(i18n::t_args("compose-header-row-error", Some(&args))); return Vec::new(); } } @@ -1109,11 +1111,10 @@ impl Carrier { } if self.strict_pinning { if let Some(addr) = self.unpinned_first_contact(&to) { - self.fail(format!( - "{} is not a contact yet and {} is not pinned — pin their server's key to send.", - addr.short(), - trust_label(&addr.host, addr.port), - )); + let mut args = fluent_bundle::FluentArgs::new(); + args.set("address", addr.short()); + args.set("host", trust_label(&addr.host, addr.port)); + self.fail(i18n::t_args("first-contact-blocked-error", Some(&args))); self.pin_prompt = Some(PinPrompt { addr, offered: None, @@ -1139,7 +1140,7 @@ impl Carrier { return Vec::new(); }; let Ok(addr) = Address::parse(&to) else { - self.fail("that address does not parse".into()); + self.fail(i18n::t("address-invalid-error")); return Vec::new(); }; let fetched = self @@ -1203,17 +1204,17 @@ impl Carrier { return Vec::new(); } let Some(uri) = msg.reply_to.clone() else { - self.fail("this sender left no address to accept".into()); + self.fail(i18n::t("contacts-accept-no-address-error")); return Vec::new(); }; let Ok(addr) = Address::parse(&uri) else { - self.fail("the sender's reply address does not parse".into()); + self.fail(i18n::t("contacts-reply-address-invalid-error")); return Vec::new(); }; let Ok(identity) = <[u8; KEY_LEN]>::try_from(unb32(&msg.sender).unwrap_or_default()) else { - self.fail("the sender's key does not parse".into()); + self.fail(i18n::t("contacts-sender-key-invalid-error")); return Vec::new(); }; return vec![PendingOp::Accept { @@ -1226,11 +1227,11 @@ impl Carrier { return Vec::new(); }; let Some(uri) = msg.reply_to.clone() else { - self.fail("this sender left no address to block".into()); + self.fail(i18n::t("contacts-block-no-address-error")); return Vec::new(); }; let Ok(addr) = Address::parse(&uri) else { - self.fail("the sender's reply address does not parse".into()); + self.fail(i18n::t("contacts-reply-address-invalid-error")); return Vec::new(); }; return vec![PendingOp::Block { @@ -1414,11 +1415,7 @@ impl Carrier { Some(prompt) => prompt.offered = Some(key), None => self.pin_offer = Some((addr, key)), } - self.note( - "The server presented this key. Pin it only if the fingerprint \ - matches what the operator published." - .into(), - ); + self.note(i18n::t("server-key-offered-note")); None } OpResult::PinOffered(Ok(None)) => { @@ -1428,7 +1425,7 @@ impl Carrier { if self.pin_prompt.take().is_none() { self.signup.key_trusted = true; } - self.note("This server's key is already pinned.".into()); + self.note(i18n::t("server-key-already-pinned-note")); None } OpResult::PinOffered(Err(e)) => { @@ -1438,7 +1435,7 @@ impl Carrier { } OpResult::Pinned(Ok(())) => { self.done(); - self.note("server key pinned".into()); + self.note(i18n::t("server-key-pinned-note")); // The contact detail shows the pin state, so that pane // needs the fresh row; signup's own pin changes nothing // that is on screen. diff --git a/src/i18n.rs b/src/i18n.rs new file mode 100644 index 0000000..bda0fbe --- /dev/null +++ b/src/i18n.rs @@ -0,0 +1,64 @@ +//! A single Fluent bundle, loaded once from `locales/en-US/main.ftl` and +//! baked into the binary via `include_str!`. Every user-facing string in +//! the app is looked up through `t`/`t1`/`t_args` rather than written +//! inline, so copy review and translation both have one place to work +//! from. There is only one locale today; a future one would plug in here +//! without touching a call site. + +use std::sync::OnceLock; + +use fluent_bundle::concurrent::FluentBundle; +use fluent_bundle::{FluentArgs, FluentResource, FluentValue}; + +fn bundle() -> &'static FluentBundle { + static BUNDLE: OnceLock> = OnceLock::new(); + BUNDLE.get_or_init(|| { + let ftl = include_str!("../locales/en-US/main.ftl"); + let resource = + FluentResource::try_new(ftl.to_string()).expect("main.ftl must parse as Fluent"); + let locale = "en-US".parse().expect("\"en-US\" is a valid language tag"); + let mut bundle = FluentBundle::new_concurrent(vec![locale]); + // Bidi isolation marks are invisible but real characters; with a + // single left-to-right locale they only cost correctness — + // copy/paste, clipboard round-trips and plain string equality all + // pick them up — for no visible benefit. + bundle.set_use_isolating(false); + bundle + .add_resource(resource) + .expect("main.ftl must not redefine a message id"); + bundle + }) +} + +/// Looks up a message with no variables. +pub fn t(id: &str) -> String { + t_args(id, None) +} + +/// Looks up a message with one variable — the common case, since most +/// interpolated strings here carry a single value. +pub fn t1(id: &str, key: &'static str, value: impl Into>) -> String { + let mut args = FluentArgs::new(); + args.set(key, value); + t_args(id, Some(&args)) +} + +/// Looks up a message against a caller-built argument set, for the rare +/// string that interpolates more than one value. +pub fn t_args(id: &str, args: Option<&FluentArgs>) -> String { + let bundle = bundle(); + // A missing id or a value-less term is a copy bug, not a runtime + // condition to hide: surface it in the string itself rather than + // silently falling back to English prose that isn't there to fall + // back to (there's only the one locale). + let Some(message) = bundle.get_message(id) else { + return format!("???{id}???"); + }; + let Some(pattern) = message.value() else { + return format!("???{id}???"); + }; + let mut errors = Vec::new(); + bundle + .format_pattern(pattern, args, &mut errors) + .into_owned() +} diff --git a/src/main.rs b/src/main.rs index a2c2c78..6b112e9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod app; #[cfg(debug_assertions)] mod demo; +mod i18n; mod ops; mod view; diff --git a/src/ops.rs b/src/ops.rs index 7764681..5226335 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -15,6 +15,8 @@ use fumi_core::message::{FRONTMATTER_KEYS, FRONTMATTER_MAX}; use fumi_core::store::Store; use fumi_core::{account, client, export}; +use crate::i18n; + /// One message in a folder list, already opened for its metadata. #[derive(Debug, Clone)] pub struct Row { @@ -316,6 +318,19 @@ fn explain(e: Error) -> String { e.to_string() } +/// `{ $path }: { $reason }` — the one shared shape every local file error +/// here takes; `reason` is whatever the OS says, which this catalogue +/// cannot translate, so only the separator around it is localized. +fn file_error_args( + path: &Path, + reason: &dyn std::fmt::Display, +) -> fluent_bundle::FluentArgs<'static> { + let mut args = fluent_bundle::FluentArgs::new(); + args.set("path", path.display().to_string()); + args.set("reason", reason.to_string()); + args +} + pub fn new_master() -> [u8; KEY_LEN] { use rand_core::RngCore; let mut master = [0u8; KEY_LEN]; @@ -333,23 +348,26 @@ pub fn parse_seed(text: &str) -> Result<[u8; KEY_LEN], String> { let bytes = if text.len() == 64 && text.chars().all(|c| c.is_ascii_hexdigit()) { let mut raw = Vec::with_capacity(32); for pair in text.as_bytes().chunks(2) { - let hi = (pair[0] as char).to_digit(16).ok_or("bad hex")?; - let lo = (pair[1] as char).to_digit(16).ok_or("bad hex")?; + let hi = (pair[0] as char) + .to_digit(16) + .ok_or_else(|| i18n::t("seed-bad-hex"))?; + let lo = (pair[1] as char) + .to_digit(16) + .ok_or_else(|| i18n::t("seed-bad-hex"))?; raw.push((hi * 16 + lo) as u8); } raw } else { unb32(text).map_err(|e| explain(e))? }; - bytes - .try_into() - .map_err(|_| "a seed is 32 bytes".to_string()) + bytes.try_into().map_err(|_| i18n::t("seed-wrong-length")) } pub fn load_key(path: &Path) -> Result<[u8; KEY_LEN], String> { - let raw = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; + let raw = std::fs::read(path) + .map_err(|e| i18n::t_args("file-error", Some(&file_error_args(path, &e))))?; raw.try_into() - .map_err(|_| format!("{}: not a 32-byte master", path.display())) + .map_err(|_| i18n::t1("key-file-wrong-length", "path", path.display().to_string())) } pub fn save_key(path: &Path, master: &[u8; KEY_LEN]) -> Result<(), String> { @@ -377,7 +395,7 @@ pub fn delete_key(path: &Path) -> Result<(), String> { match std::fs::remove_file(path) { Ok(()) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(e) => Err(format!("{}: {e}", path.display())), + Err(e) => Err(i18n::t_args("file-error", Some(&file_error_args(path, &e)))), } } @@ -397,7 +415,10 @@ pub fn factory_reset(dir: &Path) -> Result<(), String> { let path = dir.join(name); if let Err(e) = std::fs::remove_file(&path) { if e.kind() != std::io::ErrorKind::NotFound { - return Err(format!("{}: {e}", path.display())); + return Err(i18n::t_args( + "file-error", + Some(&file_error_args(&path, &e)), + )); } } } @@ -481,14 +502,18 @@ pub fn fetch( cancel: Some(&cancel), }; let fetched = client::fetch_with(store, &me, opts).map_err(explain)?; - let mut note = format!("{} fetched, {} stored", fetched.total, fetched.stored); - if !fetched.rejected.is_empty() { - note.push_str(&format!(", {} rejected", fetched.rejected.len())); - } - if fetched.cancelled { - note.push_str(" (cancelled)"); - } - Ok(note) + let variant = match (!fetched.rejected.is_empty(), fetched.cancelled) { + (true, true) => "rejected-cancelled", + (true, false) => "rejected", + (false, true) => "cancelled", + (false, false) => "plain", + }; + let mut args = fluent_bundle::FluentArgs::new(); + args.set("variant", variant); + args.set("total", fetched.total as i64); + args.set("stored", fetched.stored as i64); + args.set("rejected", fetched.rejected.len() as i64); + Ok(i18n::t_args("fetch-result-note", Some(&args))) } /// Fields fumi itself attaches to every message (sec 5.7, 5.8); a @@ -506,14 +531,18 @@ pub fn header_row(text: &str) -> Result, String> { return Ok(None); } let Some((key, value)) = text.split_once(':') else { - return Err("a header is `Key: value` — the colon is missing".into()); + return Err(i18n::t("compose-header-colon-missing")); }; let key = key.trim(); if RESERVED_HEADERS .iter() .any(|reserved| key.eq_ignore_ascii_case(reserved)) { - return Err(format!("{key} is attached by the client and cannot be set")); + return Err(i18n::t1( + "compose-header-reserved-key", + "key", + key.to_string(), + )); } if key.is_empty() || key.len() > 64 @@ -521,10 +550,10 @@ pub fn header_row(text: &str) -> Result, String> { .bytes() .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') { - return Err("keys are 1-64 characters of letters, digits and dashes".into()); + return Err(i18n::t("compose-header-invalid-key")); } if value.contains('\n') || value.contains('\r') { - return Err("a value must stay on one line".into()); + return Err(i18n::t("compose-header-value-multiline")); } Ok(Some((key.to_string(), value.trim().to_string()))) } @@ -536,7 +565,7 @@ pub fn validate_headers(headers: &[(String, String)]) -> Result<(), String> { let mut seen: std::collections::HashSet = std::collections::HashSet::new(); for (key, _) in headers { if !seen.insert(key.to_ascii_lowercase()) { - return Err(format!("{key} is set twice")); + return Err(i18n::t1("compose-header-duplicate-key", "key", key.clone())); } } let budget: usize = headers @@ -546,10 +575,10 @@ pub fn validate_headers(headers: &[(String, String)]) -> Result<(), String> { // The client adds Subject, In-Reply-To, a Reply-To URI and possibly an // Accept token on top of the user's rows; 512 bytes covers them all. if budget + 512 > FRONTMATTER_MAX { - return Err("the headers exceed the frontmatter budget".into()); + return Err(i18n::t("compose-headers-too-large")); } if headers.len() + 4 > FRONTMATTER_KEYS { - return Err("too many headers".into()); + return Err(i18n::t("compose-headers-too-many")); } Ok(()) } @@ -592,20 +621,27 @@ pub fn send( no_pad: false, }; let sent = client::send(store, &me, &draft, timeout).map_err(explain)?; - let change = match sent.change { - TrustChange::New { unverified: true } => { - "; new contact learned from an unpinned server (unverified)" - } - TrustChange::New { unverified: false } => "; new contact learned (trust on first use)", - TrustChange::Rotated => "; contact rotated to a new key (chain verified)", - TrustChange::None => "", + let trust = match sent.change { + TrustChange::New { unverified: true } => Some("unverified"), + TrustChange::New { unverified: false } => Some("tofu"), + TrustChange::Rotated => Some("rotated"), + TrustChange::None => None, }; - let mut note = format!("sent to {} ({} bytes)", addr.short(), sent.bytes); - if sent.token_used { - note.push_str(", accept token attached"); - } - note.push_str(change); + let variant = match (trust, sent.token_used) { + (Some(trust), true) => format!("{trust}-token"), + (Some(trust), false) => trust.to_string(), + (None, true) => "token".to_string(), + (None, false) => "plain".to_string(), + }; + let mut args = fluent_bundle::FluentArgs::new(); + args.set("variant", variant); + args.set("address", addr.short()); + args.set("bytes", sent.bytes as i64); + let mut note = i18n::t_args("send-result-note", Some(&args)); if let Some(warning) = sent.warning { + // Fumi's own warning text, appended as a second, independent + // clause rather than folded into the catalogue — it isn't ours + // to translate. note.push_str(&format!("; {warning}")); } Ok(note) @@ -669,7 +705,7 @@ pub fn import_contact(store: &Store, uri: &str) -> Result<(String, String), Stri let addr = parse_address(uri)?; let key = addr .identity - .ok_or("import needs a self-certifying smol:// address carrying a key")?; + .ok_or_else(|| i18n::t("contacts-import-needs-key"))?; store .save_contact(&addr.short(), &key, true) .map_err(explain)?; @@ -723,8 +759,13 @@ pub fn rotate( /// Writes the gsmol backup to the chosen path. pub fn export_backup(store: &Store, master: &[u8; KEY_LEN], path: &Path) -> Result { let text = export::export(store, master).map_err(explain)?; - std::fs::write(path, &text).map_err(|e| format!("{}: {e}", path.display()))?; - Ok(format!("backup written to {}", path.display())) + std::fs::write(path, &text) + .map_err(|e| i18n::t_args("file-error", Some(&file_error_args(path, &e))))?; + Ok(i18n::t1( + "backup-written-note", + "path", + path.display().to_string(), + )) } pub fn import_backup(store: &Store, master: &[u8; KEY_LEN], text: &str) -> Result { @@ -739,10 +780,10 @@ fn hex32(text: &str) -> Result<[u8; KEY_LEN], String> { .collect::, _>>() .map_err(|e| e.to_string())? } else { - return Err("message ids are 64 hex characters".to_string()); + return Err(i18n::t("mail-message-id-wrong-length")); }; raw.try_into() - .map_err(|_| "message ids are 32 bytes".to_string()) + .map_err(|_| i18n::t("mail-message-id-wrong-bytes")) } /// Lists one folder as rows, marking unread against the local read set. @@ -796,7 +837,7 @@ pub fn open( .map_err(explain)? .into_iter() .find(|m| m.id == raw) - .ok_or_else(|| "message not found".to_string())?; + .ok_or_else(|| i18n::t("mail-message-not-found"))?; let described = client::describe(store, &me, &stored).map_err(explain)?; // The subject sits in the header, the reply address powers Reply and // the accept/block actions, and the Accept token is consumed by the @@ -1064,6 +1105,23 @@ mod tests { Store::open_in_memory().expect("in-memory store") } + /// Fluent wraps every interpolated value in invisible bidi-isolation + /// marks by default; disabled in `i18n::bundle()` because this app + /// has one left-to-right locale and those marks would otherwise leak + /// into copy/paste, the clipboard and plain string comparisons. + #[test] + fn interpolated_messages_carry_no_bidi_isolation_marks() { + let mut args = fluent_bundle::FluentArgs::new(); + args.set("variant", "plain"); + args.set("total", 5i64); + args.set("stored", 5i64); + args.set("rejected", 0i64); + assert_eq!( + i18n::t_args("fetch-result-note", Some(&args)), + "5 fetched, 5 stored" + ); + } + #[test] fn seed_round_trips_through_base32_and_hex() { let m = master(); diff --git a/src/view.rs b/src/view.rs index 190172f..3bddc89 100644 --- a/src/view.rs +++ b/src/view.rs @@ -24,6 +24,7 @@ use crate::app::{ Carrier, Dialog, DialogKind, Message, OnboardMode, OpResult, Page, PendingOp, SignupMode, SignupStep, }; +use crate::i18n; use crate::ops::{self, Contact, Folder, Row}; use relm4::RelmApp; @@ -167,13 +168,14 @@ fn buffer_text(buffer: >k::TextBuffer) -> String { /// The contact's trust state as one word, the same vocabulary the sidebar, /// the detail pane and the contacts page all label it with. -fn contact_state(contact: &Contact) -> &'static str { - match contact.accepted { - Some(true) => "accepted", - Some(false) => "blocked", - None if contact.verified => "verified", - None => "trust on first use", - } +fn contact_state(contact: &Contact) -> String { + let id = match contact.accepted { + Some(true) => "contact-state-accepted", + Some(false) => "contact-state-blocked", + None if contact.verified => "contact-state-verified", + None => "contact-state-tofu", + }; + i18n::t(id) } #[derive(Debug, Clone)] @@ -315,8 +317,16 @@ impl CarrierWidgets { // leave neither of them reading as the primary one. let sidebar_actions = gtk::Box::new(Orientation::Horizontal, 6); sidebar_actions.set_homogeneous(true); - let fetch_button = icon_label_button("mail-send-receive-symbolic", "Fetch", "accent"); - let write_button = icon_label_button("mail-message-new-symbolic", "Write", ""); + let fetch_button = icon_label_button( + "mail-send-receive-symbolic", + &i18n::t("mail-fetch-button"), + "accent", + ); + let write_button = icon_label_button( + "mail-message-new-symbolic", + &i18n::t("mail-write-button"), + "", + ); sidebar_actions.append(&fetch_button); sidebar_actions.append(&write_button); sidebar_top.append(&sidebar_actions); @@ -327,10 +337,10 @@ impl CarrierWidgets { folders.add_css_class("navigation-sidebar"); folders.set_selection_mode(gtk::SelectionMode::Single); let folder_rows = [ - nav_row("folder-download-symbolic", "Inbox"), - nav_row("contact-new-symbolic", "Requests"), - nav_row("mail-send-symbolic", "Sent"), - nav_row("document-edit-symbolic", "Drafts"), + nav_row("folder-download-symbolic", &i18n::t("folder-inbox")), + nav_row("contact-new-symbolic", &i18n::t("folder-requests")), + nav_row("mail-send-symbolic", &i18n::t("folder-sent")), + nav_row("document-edit-symbolic", &i18n::t("folder-drafts")), ]; let mut folder_counts = Vec::new(); let mut drafts_nav = None; @@ -357,7 +367,10 @@ impl CarrierWidgets { let settings_nav = gtk::ListBox::new(); settings_nav.add_css_class("navigation-sidebar"); settings_nav.set_selection_mode(gtk::SelectionMode::Single); - let (settings_row, _) = nav_row("preferences-system-symbolic", "Settings"); + let (settings_row, _) = nav_row( + "preferences-system-symbolic", + &i18n::t("settings-nav-label"), + ); settings_nav.append(&settings_row); sidebar_top.append(&settings_nav); @@ -369,7 +382,7 @@ impl CarrierWidgets { own_text.set_margin_top(8); own_text.set_margin_bottom(8); own_text.set_margin_start(12); - let own_caption = label("you", "caption"); + let own_caption = label(&i18n::t("identity-you-caption"), "caption"); let sidebar_own = label("", "mono heading"); // Not selectable: a selectable label eats clicks on the text with // its selection gesture, and the address is already copyable from @@ -387,14 +400,18 @@ impl CarrierWidgets { // The list's header carries its one action: importing a // smol:// address, in a popover off a plain add button. let contacts_head = gtk::Box::new(Orientation::Horizontal, 6); - let contacts_caption = label("contacts", "caption"); + let contacts_caption = label(&i18n::t("contacts-caption"), "caption"); contacts_caption.set_hexpand(true); contacts_caption.set_valign(Align::Center); contacts_caption.set_margin_start(12); let contact_import = adw::EntryRow::builder() - .title("Import a smol:// address") + .title(i18n::t("contacts-import-placeholder")) .build(); - let import_button = icon_label_button("document-open-symbolic", "Import", "accent"); + let import_button = icon_label_button( + "document-open-symbolic", + &i18n::t("contacts-import-button"), + "accent", + ); import_button.set_valign(Align::Center); contact_import.add_suffix(&import_button); let import_list = boxed_list(); @@ -410,7 +427,7 @@ impl CarrierWidgets { add_contact.set_icon_name("list-add-symbolic"); add_contact.add_css_class("flat"); add_contact.add_css_class("circular"); - add_contact.set_tooltip_text(Some("Import a smol:// address")); + add_contact.set_tooltip_text(Some(&i18n::t("contacts-import-placeholder"))); add_contact.set_popover(Some(&import_popover)); contacts_head.append(&contacts_caption); contacts_head.append(&add_contact); @@ -438,13 +455,13 @@ impl CarrierWidgets { // An empty folder gets a status page rather than a stray line of // dim text, so the pane says what to do about it. let list_empty = adw::StatusPage::new(); - let list_empty_fetch = icon_label_button("mail-send-receive-symbolic", "Fetch", "pill"); - list_empty_fetch.set_halign(Align::Center); - let list_empty_hint = label( - "Fetching removes mail from the server once it's stored here.\n\ - Turn on \"Keep mail on the server\" in Settings to keep it there instead.", - "dim caption", + let list_empty_fetch = icon_label_button( + "mail-send-receive-symbolic", + &i18n::t("mail-fetch-button"), + "pill", ); + list_empty_fetch.set_halign(Align::Center); + let list_empty_hint = label(&i18n::t("mail-empty-hint"), "dim caption"); list_empty_hint.set_wrap(true); list_empty_hint.set_justify(gtk::Justification::Center); list_empty_hint.set_halign(Align::Center); @@ -484,13 +501,13 @@ impl CarrierWidgets { detail_share.set_icon_name("send-to-symbolic"); detail_share.add_css_class("flat"); detail_share.add_css_class("circular"); - detail_share.set_tooltip_text(Some("Show the address as a QR code")); + detail_share.set_tooltip_text(Some(&i18n::t("detail-qr-tooltip"))); detail_share.set_popover(Some(&detail_popover)); detail_share.set_valign(Align::Start); detail_share.set_visible(false); // Copies the address the header shows, so it serves the contact // and the own-identity detail alike. - let detail_copy = icon_button("edit-copy-symbolic", "Copy the address"); + let detail_copy = icon_button("edit-copy-symbolic", &i18n::t("detail-copy-tooltip")); detail_copy.set_valign(Align::Start); detail_copy.set_visible(false); let copy_source = article_subject.clone(); @@ -542,8 +559,8 @@ impl CarrierWidgets { // No icon: the pane sits directly under the list's own empty page, // and two envelopes stacked up the window say nothing twice. let article_empty = adw::StatusPage::builder() - .title("No message selected") - .description("Pick a message from the list above to read it.") + .title(i18n::t("mail-empty-title")) + .description(i18n::t("mail-empty-description")) .build(); let article_stack = gtk::Stack::new(); article_stack.set_hhomogeneous(false); @@ -574,8 +591,12 @@ impl CarrierWidgets { compose.set_margin_start(12); compose.set_margin_end(12); let compose_fields = boxed_list(); - let compose_to = adw::EntryRow::builder().title("To").build(); - let compose_subject = adw::EntryRow::builder().title("Subject").build(); + let compose_to = adw::EntryRow::builder() + .title(i18n::t("compose-to-label")) + .build(); + let compose_subject = adw::EntryRow::builder() + .title(i18n::t("compose-subject-label")) + .build(); compose_fields.append(&compose_to); compose_fields.append(&compose_subject); compose.append(&compose_fields); @@ -595,11 +616,7 @@ impl CarrierWidgets { let compose_pin_fingerprint = label("", "mono dim"); compose_pin_fingerprint.set_selectable(true); compose_pin_fingerprint.set_wrap(true); - let compose_pin_hint = label( - "If the server operator published a key or fingerprint, make \ - sure it matches before trusting it.", - "dim caption", - ); + let compose_pin_hint = label(&i18n::t("trust-fingerprint-hint"), "dim caption"); compose_pin_hint.set_wrap(true); let compose_pin_button = button("", "accent"); compose_pin_button.set_halign(Align::Start); @@ -634,7 +651,7 @@ impl CarrierWidgets { // changes only, so the entries never lose focus while typing. let compose_headers = boxed_list(); compose.append(&compose_headers); - let add_header = button("+ header", "quiet"); + let add_header = button(&i18n::t("compose-add-header-button"), "quiet"); add_header.set_halign(Align::Start); compose.append(&add_header); let compose_view = gtk::TextView::new(); @@ -652,10 +669,10 @@ impl CarrierWidgets { let compose_send_button = button("", "accent"); let compose_send = adw::ButtonContent::builder() .icon_name("mail-send-symbolic") - .label("Send") + .label(i18n::t("compose-send-button")) .build(); compose_send_button.set_child(Some(&compose_send)); - let compose_cancel = button("Cancel", "quiet"); + let compose_cancel = button(&i18n::t("action-cancel"), "quiet"); let compose_actions = gtk::Box::new(Orientation::Horizontal, 6); compose_actions.set_halign(Align::End); compose_actions.append(&compose_cancel); @@ -673,9 +690,13 @@ impl CarrierWidgets { // itself room to wrap instead of being ellipsized into a suffix. // Markup is off so the copy button can hand back exactly what is // on screen. - let property_row = |title: &str| -> adw::ActionRow { + // `id` is a message id (e.g. "settings-field-host"), not literal + // text, so both the title and its "Copy the …" tooltip come from + // the catalogue rather than one being built from the other. + let property_row = |id: &str| -> adw::ActionRow { + let title = i18n::t(id); let row = adw::ActionRow::builder() - .title(title) + .title(&title) .use_markup(false) .subtitle_lines(0) .subtitle_selectable(true) @@ -684,7 +705,7 @@ impl CarrierWidgets { row.add_css_class("monospace"); let copy = icon_button( "edit-copy-symbolic", - &format!("Copy the {}", title.to_lowercase()), + &i18n::t1("property-row-copy-tooltip", "field", title.to_lowercase()), ); let copy_source = row.clone(); copy.connect_clicked(move |_| { @@ -704,8 +725,13 @@ impl CarrierWidgets { let identity_page = adw::PreferencesPage::new(); let identity_group = adw::PreferencesGroup::new(); let mut identity_rows = Vec::new(); - for title in ["Address", "Key", "Fingerprint", "URI"] { - let row = property_row(title); + for field in [ + "settings-field-address", + "settings-field-key", + "settings-field-fingerprint", + "settings-field-uri", + ] { + let row = property_row(field); identity_group.add(&row); identity_rows.push(row); } @@ -714,7 +740,7 @@ impl CarrierWidgets { // The group header names this one, so the row carries only the // secret — masked until the reveal button is pressed. let secret_group = adw::PreferencesGroup::builder() - .title("Recovery Secret") + .title(i18n::t("settings-recovery-secret-title")) .build(); let secret_row = adw::ActionRow::builder() .use_markup(false) @@ -722,8 +748,14 @@ impl CarrierWidgets { .title_selectable(true) .build(); secret_row.add_css_class("monospace"); - let secret_reveal = icon_button("view-reveal-symbolic", "Reveal the recovery secret"); - let secret_copy = icon_button("edit-copy-symbolic", "Copy the recovery secret"); + let secret_reveal = icon_button( + "view-reveal-symbolic", + &i18n::t("settings-secret-reveal-tooltip"), + ); + let secret_copy = icon_button( + "edit-copy-symbolic", + &i18n::t("settings-secret-copy-tooltip"), + ); secret_row.add_suffix(&secret_reveal); secret_row.add_suffix(&secret_copy); secret_group.add(&secret_row); @@ -731,31 +763,31 @@ impl CarrierWidgets { settings_stack.add_titled_with_icon( &identity_page, Some("identity"), - "Identity", + &i18n::t("settings-tab-identity"), "avatar-default-symbolic", ); // -- Server tab ---------------------------------------------------- let server_page = adw::PreferencesPage::new(); - let server_group = adw::PreferencesGroup::builder().title("Connection").build(); - let host_row = property_row("Host"); - let public_key_row = property_row("Public Key"); + let server_group = adw::PreferencesGroup::builder() + .title(i18n::t("server-connection-title")) + .build(); + let host_row = property_row("settings-field-host"); + let public_key_row = property_row("settings-field-public-key"); server_group.add(&host_row); server_group.add(&public_key_row); let timeout_row = adw::SpinRow::with_range(1.0, 600.0, 1.0); - timeout_row.set_title("Timeout"); - timeout_row.set_subtitle("Seconds before a network call gives up"); + timeout_row.set_title(&i18n::t("server-timeout-title")); + timeout_row.set_subtitle(&i18n::t("server-timeout-subtitle")); server_group.add(&timeout_row); server_page.add(&server_group); - let network_group = adw::PreferencesGroup::builder().title("Fetching").build(); + let network_group = adw::PreferencesGroup::builder() + .title(i18n::t("server-fetching-title")) + .build(); let keep_mail_row = adw::ActionRow::builder() - .title("Leave mail on server") - .subtitle( - "Keep a copy on the server after fetching, so another client can \ - fetch it too. When this is enabled, deleting a message removes \ - it from this client and the server.", - ) + .title(i18n::t("server-leave-mail-title")) + .subtitle(i18n::t("server-leave-mail-subtitle")) .subtitle_lines(0) .activatable(true) .build(); @@ -767,30 +799,25 @@ impl CarrierWidgets { settings_stack.add_titled_with_icon( &server_page, Some("server"), - "Server", + &i18n::t("settings-tab-server"), "network-server-symbolic", ); // -- Backup tab ---------------------------------------------------- let backup_page = adw::PreferencesPage::new(); let backup_group = adw::PreferencesGroup::builder() - .description( - "One file holding your mail, contacts and server info for moving \ - to another client, or for keeping what the server no longer \ - has. It is sealed to this identity and deliberately leaves the \ - secret itself out.", - ) + .description(i18n::t("backup-page-description")) .build(); let export_row = adw::ActionRow::builder() - .title("Export") - .subtitle("Write a backup file") + .title(i18n::t("backup-export-title")) + .subtitle(i18n::t("backup-export-subtitle")) .activatable(true) .build(); export_row.add_prefix(>k::Image::from_icon_name("document-save-symbolic")); export_row.add_suffix(>k::Image::from_icon_name("go-next-symbolic")); let import_row = adw::ActionRow::builder() - .title("Import") - .subtitle("Restore from a backup file") + .title(i18n::t("backup-import-title")) + .subtitle(i18n::t("backup-import-subtitle")) .activatable(true) .build(); import_row.add_prefix(>k::Image::from_icon_name("document-open-symbolic")); @@ -801,52 +828,43 @@ impl CarrierWidgets { settings_stack.add_titled_with_icon( &backup_page, Some("backup"), - "Backup", + &i18n::t("settings-tab-backup"), "document-save-symbolic", ); // -- Advanced tab -------------------------------------------------- let advanced_page = adw::PreferencesPage::new(); let rotations_group = adw::PreferencesGroup::builder() - .title("Rotations") - .description( - "Issues a fresh key from your secret with a signed certificate \ - proving it replaces this one. Contacts accept the change from \ - that chain, and the old key stays readable. Rotation is not \ - revocation.", - ) + .title(i18n::t("advanced-rotations-title")) + .description(i18n::t("advanced-rotations-description")) .build(); // Both actions sit in their group's body, centred under the // explanation: a long description wraps under the group title and // would run straight into a header-suffix button. - let rotations_row = adw::ActionRow::builder().title("Completed").build(); + let rotations_row = adw::ActionRow::builder() + .title(i18n::t("advanced-rotations-completed")) + .build(); let rotations_value = label("", "dim numeric"); rotations_value.set_valign(Align::Center); rotations_row.add_suffix(&rotations_value); rotations_group.add(&rotations_row); - let rotate_button = icon_label_button("view-refresh-symbolic", "Rotate identity", ""); + let rotate_button = icon_label_button( + "view-refresh-symbolic", + &i18n::t("advanced-rotate-button"), + "", + ); rotate_button.set_halign(Align::Center); rotate_button.set_margin_top(12); rotations_group.add(&rotate_button); advanced_page.add(&rotations_group); let trust_group = adw::PreferencesGroup::builder() - .title("First Contact") - .description( - "A recipient who is not a contact yet, and whose address carries \ - no key, has to be looked up on their own server. That answer is \ - only as good as the server: one with no pinned key can return a \ - key of its own and read what you send.", - ) + .title(i18n::t("advanced-first-contact-title")) + .description(i18n::t("advanced-first-contact-description")) .build(); let strict_row = adw::ActionRow::builder() - .title("Require a pinned server") - .subtitle( - "Stop such a send and offer to pin the server's key first, so it \ - can be checked against what the operator published. With this \ - off, the key is taken on trust and the send is only reported as \ - unverified afterwards.", - ) + .title(i18n::t("advanced-strict-pinning-title")) + .subtitle(i18n::t("advanced-strict-pinning-subtitle")) .subtitle_lines(0) .activatable(true) .build(); @@ -860,19 +878,12 @@ impl CarrierWidgets { // group's body under the warning rather than squeezed beside the // header where its label would crowd the title. let logout_group = adw::PreferencesGroup::builder() - .title("Log Out") - .description( - "Removes this identity from this client entirely: the secret, \ - pins, contacts and every cached message. Without a backup of \ - the secret there is no way back in. Mail already fetched here \ - may have no other copy anywhere, unless \"leave mail on \ - server\" was on. Whatever is still on the server comes back \ - on the first fetch after logging back in.", - ) + .title(i18n::t("advanced-logout-title")) + .description(i18n::t("advanced-logout-description")) .build(); let reset_button = icon_label_button( "system-log-out-symbolic", - "Log out and delete all data", + &i18n::t("advanced-logout-button"), "danger", ); reset_button.set_halign(Align::Center); @@ -882,7 +893,7 @@ impl CarrierWidgets { settings_stack.add_titled_with_icon( &advanced_page, Some("advanced"), - "Advanced", + &i18n::t("settings-tab-advanced"), "emblem-system-symbolic", ); @@ -918,23 +929,20 @@ impl CarrierWidgets { let title = label("carrier", "title"); title.set_halign(Align::Center); onboard_start.append(&title); - let tagline = label("smolmail: one seed, sealed mail, five operations", "dim"); + let tagline = label(&i18n::t("onboard-tagline"), "dim"); tagline.set_halign(Align::Center); tagline.set_margin_bottom(12); onboard_start.append(&tagline); - let create_button = button("Create a new identity", "pill"); - let restore_button = button("Restore existing identity from seed", "pill-quiet"); + let create_button = button(&i18n::t("onboard-create-button"), "pill"); + let restore_button = button(&i18n::t("onboard-restore-button"), "pill-quiet"); onboard_start.append(&create_button); onboard_start.append(&restore_button); stack.add_named(¢er(&onboard_start), Some("onboard")); let onboard_created = gtk::Box::new(Orientation::Vertical, 12); - let created_title = label("Write this seed down", "subtitle"); + let created_title = label(&i18n::t("onboard-created-title"), "subtitle"); onboard_created.append(&created_title); - onboard_created.append(&label( - "It is the only secret, and it is shown once.", - "dim", - )); + onboard_created.append(&label(&i18n::t("onboard-created-description"), "dim")); let onboard_seed_label = label("", "mono subtitle"); onboard_seed_label.set_selectable(true); onboard_seed_label.set_wrap(true); @@ -948,31 +956,33 @@ impl CarrierWidgets { seed_card.add_css_class("card"); seed_card.append(&onboard_seed_label); onboard_created.append(&seed_card); - let saved_button = button("I saved it", "pill"); - let created_back = button("Back", "pill-quiet"); + let saved_button = button(&i18n::t("onboard-saved-button"), "pill"); + let created_back = button(&i18n::t("action-back"), "pill-quiet"); onboard_created.append(&saved_button); onboard_created.append(&created_back); stack.add_named(¢er(&onboard_created), Some("onboard-created")); let onboard_restore = gtk::Box::new(Orientation::Vertical, 12); - onboard_restore.append(&label("Restore from a seed", "subtitle")); + onboard_restore.append(&label(&i18n::t("onboard-restore-title"), "subtitle")); let restore_list = boxed_list(); let onboard_seed_entry = adw::EntryRow::builder() - .title("Seed — 32 bytes, base32 or hex") + .title(i18n::t("onboard-seed-entry-title")) .build(); restore_list.append(&onboard_seed_entry); onboard_restore.append(&restore_list); - let restore_go = button("Restore", "pill"); - let restore_back = button("Back", "pill-quiet"); + let restore_go = button(&i18n::t("onboard-restore-go-button"), "pill"); + let restore_back = button(&i18n::t("action-back"), "pill-quiet"); onboard_restore.append(&restore_go); onboard_restore.append(&restore_back); stack.add_named(¢er(&onboard_restore), Some("onboard-restore")); // signup: step 1 — the server this address lives on let signup_server = gtk::Box::new(Orientation::Vertical, 12); - signup_server.append(&label("Set up the server", "subtitle")); + signup_server.append(&label(&i18n::t("signup-server-title"), "subtitle")); let signup_server_group = adw::PreferencesGroup::new(); - let signup_host = adw::EntryRow::builder().title("Host").build(); + let signup_host = adw::EntryRow::builder() + .title(i18n::t("settings-field-host")) + .build(); signup_server_group.add(&signup_host); signup_server.append(&signup_server_group); @@ -997,31 +1007,27 @@ impl CarrierWidgets { signup_key_card.append(&signup_key_value); signup_key_card.append(&signup_key_fingerprint); signup_server.append(&signup_key_card); - let signup_key_hint = label( - "If the server operator published a key or fingerprint, make \ - sure it matches before trusting it.", - "dim", - ); + let signup_key_hint = label(&i18n::t("trust-fingerprint-hint"), "dim"); signup_key_hint.set_wrap(true); signup_key_hint.set_visible(false); signup_server.append(&signup_key_hint); - let signup_key_button = button("Fetch the server's key", "pill"); - let signup_start_over = button("Start over", "pill-quiet"); + let signup_key_button = button(&i18n::t("signup-fetch-key-button"), "pill"); + let signup_start_over = button(&i18n::t("signup-start-over-button"), "pill-quiet"); signup_server.append(&signup_key_button); signup_server.append(&signup_start_over); stack.add_named(¢er(&signup_server), Some("signup-server")); // signup: step 2 — bind a username, or recall an existing address let signup_account = gtk::Box::new(Orientation::Vertical, 12); - signup_account.append(&label("Choose your address", "subtitle")); + signup_account.append(&label(&i18n::t("signup-account-title"), "subtitle")); let signup_mode_new = gtk::ToggleButton::builder() - .label("Create a new address") + .label(i18n::t("signup-mode-new-label")) .active(true) .hexpand(true) .build(); let signup_mode_existing = gtk::ToggleButton::builder() - .label("I already have this address") + .label(i18n::t("signup-mode-existing-label")) .hexpand(true) .build(); signup_mode_existing.set_group(Some(&signup_mode_new)); @@ -1032,18 +1038,20 @@ impl CarrierWidgets { signup_mode_box.append(&signup_mode_existing); signup_account.append(&signup_mode_box); let signup_account_group = adw::PreferencesGroup::new(); - let signup_user = adw::EntryRow::builder().title("User").build(); + let signup_user = adw::EntryRow::builder() + .title(i18n::t("signup-user-label")) + .build(); let signup_invite = adw::EntryRow::builder() - .title("Invite token (if required by the server)") + .title(i18n::t("signup-invite-label")) .build(); signup_account_group.add(&signup_user); signup_account_group.add(&signup_invite); signup_account.append(&signup_account_group); - let register_button = button("Register", "pill"); - let recall_button = button("Restore access", "pill"); + let register_button = button(&i18n::t("signup-register-button"), "pill"); + let recall_button = button(&i18n::t("signup-recall-button"), "pill"); signup_account.append(®ister_button); signup_account.append(&recall_button); - let signup_step_back = button("Back", "quiet"); + let signup_step_back = button(&i18n::t("action-back"), "quiet"); signup_account.append(&signup_step_back); stack.add_named(¢er(&signup_account), Some("signup-account")); @@ -1057,7 +1065,7 @@ impl CarrierWidgets { spinner.set_visible(false); let status_label = label("", "caption"); status_label.set_hexpand(true); - let cancel_button = button("Cancel", ""); + let cancel_button = button(&i18n::t("action-cancel"), ""); cancel_button.set_visible(false); status_bar.append(&spinner); status_bar.append(&status_label); @@ -1221,7 +1229,7 @@ impl CarrierWidgets { let export_window = root.clone(); export_row.connect_activated(move |_| { let dialog = gtk::FileDialog::builder() - .title("Export Backup") + .title(i18n::t("backup-export-dialog-title")) .initial_name("carrier-backup.gsmol") .build(); let sender = export_sender.clone(); @@ -1240,7 +1248,9 @@ impl CarrierWidgets { let backup_import_sender = sender.clone(); let backup_import_window = root.clone(); import_row.connect_activated(move |_| { - let dialog = gtk::FileDialog::builder().title("Import Backup").build(); + let dialog = gtk::FileDialog::builder() + .title(i18n::t("backup-import-dialog-title")) + .build(); let sender = backup_import_sender.clone(); dialog.open( Some(&backup_import_window), @@ -1445,11 +1455,13 @@ fn spawn_op(model: &Carrier, op: PendingOp, sender: &ComponentSender) { }), PendingOp::Register { addr, invite } => sender.spawn_command(move |out| { let res = ops::register(&store, &master, &addr, invite.as_deref(), timeout); - let _ = out.send(OpResult::Generic(res.map(|_| "registered".to_string()))); + let _ = out.send(OpResult::Generic( + res.map(|_| i18n::t("status-registered-note")), + )); }), PendingOp::Restore { addr } => sender.spawn_command(move |out| { let res = ops::restore(&store, &master, &addr, timeout) - .map(|index| format!("restored at rotation {index}")); + .map(|index| i18n::t1("status-restored-note", "index", index as i64)); let _ = out.send(OpResult::Generic(res)); }), PendingOp::LearnKey { addr } => sender.spawn_command(move |out| { @@ -1507,32 +1519,36 @@ fn spawn_op(model: &Carrier, op: PendingOp, sender: &ComponentSender) { everywhere, } => sender.spawn_command(move |out| { let res = ops::delete(&store, &master, &id, folder, everywhere, timeout) - .map(|_| "deleted".to_string()); + .map(|_| i18n::t("status-deleted-note")); let _ = out.send(OpResult::Generic(res)); }), PendingOp::Accept { address, identity } => sender.spawn_command(move |out| { let res = ops::accept_contact(&store, &master, &address, &identity, timeout) - .map(|_| format!("accepted {address}")); + .map(|_| i18n::t1("status-accepted-note", "address", address.clone())); let _ = out.send(OpResult::Generic(res)); }), PendingOp::Block { address } => sender.spawn_command(move |out| { let res = ops::block_contact(&store, &master, &address, timeout).map(|blocked| { if blocked { - format!("blocked {address}") + i18n::t1("status-blocked-note", "address", address.clone()) } else { - format!("{address} was not blocked") + i18n::t1("status-not-blocked-note", "address", address.clone()) } }); let _ = out.send(OpResult::Generic(res)); }), PendingOp::ImportContact { uri } => sender.spawn_command(move |out| { let res = ops::import_contact(&store, &uri) - .map(|(address, _)| format!("imported {address} (verified)")); + .map(|(address, _)| i18n::t1("status-imported-note", "address", address)); let _ = out.send(OpResult::Generic(res)); }), PendingOp::Rotate => sender.spawn_command(move |out| { - let res = ops::rotate(&store, &master, timeout) - .map(|(index, key)| format!("rotated to index {index}; new key {}…", &key[..12])); + let res = ops::rotate(&store, &master, timeout).map(|(index, key)| { + let mut args = fluent_bundle::FluentArgs::new(); + args.set("index", index as i64); + args.set("key_prefix", key[..12].to_string()); + i18n::t_args("status-rotated-note", Some(&args)) + }); let _ = out.send(OpResult::Generic(res)); }), PendingOp::Export { path } => sender.spawn_command(move |out| { @@ -1607,23 +1623,26 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender { w.compose_pin_key.set_text(&b32(&key)); w.compose_pin_fingerprint.set_text(&fingerprint(&key)); - w.compose_pin_button.set_label("Pin and send"); + w.compose_pin_button + .set_label(&i18n::t("compose-pin-button-send")); } - None => w.compose_pin_button.set_label("Fetch their server's key"), + None => w + .compose_pin_button + .set_label(&i18n::t("compose-pin-button-fetch")), } let fetched = prompt.offered.is_some(); for line in [ @@ -1702,27 +1721,31 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender (None, "Inbox is empty", "Fetch to check the mailbox."), + let (icon, title_id, description_id) = match model.folder { + Folder::Inbox => ( + None, + "mail-inbox-empty-title", + "mail-inbox-empty-description", + ), Folder::Requests => ( Some("contact-new-symbolic"), - "No requests", - "Mail from senders you have not accepted yet arrives here.", + "mail-requests-empty-title", + "mail-requests-empty-description", ), Folder::Sent => ( Some("mail-send-symbolic"), - "Nothing sent yet", - "Messages you send keep a sealed copy here.", + "mail-sent-empty-title", + "mail-sent-empty-description", ), Folder::Drafts => ( Some("document-edit-symbolic"), - "No drafts", - "A compose closed from the sidebar is kept here.", + "mail-drafts-empty-title", + "mail-drafts-empty-description", ), }; w.list_empty.set_icon_name(icon); - w.list_empty.set_title(title); - w.list_empty.set_description(Some(description)); + w.list_empty.set_title(&i18n::t(title_id)); + w.list_empty.set_description(Some(&i18n::t(description_id))); let inbox_empty = model.folder == Folder::Inbox; w.list_empty_fetch.set_visible(inbox_empty); w.list_empty_hint @@ -1768,8 +1791,11 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender { - let block = icon_label_button("action-unavailable-symbolic", "Block", "danger"); + let block = icon_label_button( + "action-unavailable-symbolic", + &i18n::t("action-block"), + "danger", + ); let msg = Message::ContactBlock(contact.address.clone()); let block_sender = sender.clone(); block.connect_clicked(move |_| block_sender.input(msg.clone())); w.actions.append(&block); } Some(false) => { - let unblock = icon_label_button("object-select-symbolic", "Unblock", "accent"); + let unblock = icon_label_button( + "object-select-symbolic", + &i18n::t("action-unblock"), + "accent", + ); let msg = Message::ContactUnblock(contact.address.clone()); let unblock_sender = sender.clone(); unblock.connect_clicked(move |_| unblock_sender.input(msg.clone())); w.actions.append(&unblock); } None => { - let accept = icon_label_button("object-select-symbolic", "Accept", "accent"); + let accept = icon_label_button( + "object-select-symbolic", + &i18n::t("action-accept"), + "accent", + ); let msg = Message::ContactAccept(contact.address.clone(), contact.key.clone()); let accept_sender = sender.clone(); accept.connect_clicked(move |_| accept_sender.input(msg.clone())); @@ -1876,16 +1922,16 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender{address}") + let from = if known { + format!("{address}") } else { - format!("{label} {address}") + address }; - meta.push_str(&format!(" · {} UTC", escape(&ops::when(msg.time)))); - meta.push_str(&format!(" · id {}…", &msg.id[..msg.id.len().min(8)])); - if msg.kept { - meta.push_str(" · a server copy still exists"); - } + let mut meta_args = fluent_bundle::FluentArgs::new(); + meta_args.set("label", label); + meta_args.set("from", from); + meta_args.set("time", escape(&ops::when(msg.time))); + meta_args.set("id", msg.id[..msg.id.len().min(8)].to_string()); + meta_args.set("kept", if msg.kept { "yes" } else { "no" }); + let meta = i18n::t_args("mail-meta-line", Some(&meta_args)); w.article_meta.set_markup(&meta); let headers: Vec = msg .headers @@ -1914,20 +1962,29 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender { let alert = adw::AlertDialog::new( - Some("Delete this message?"), - Some(if *kept { - "Deleting it here leaves the server's copy in place. Deleting it everywhere asks the server to drop that copy too." + Some(&i18n::t("delete-dialog-title")), + Some(&i18n::t(if *kept { + "delete-dialog-body-kept" } else { - "The sealed copy in the local store is removed." - }), + "delete-dialog-body-gone" + })), ); - alert.add_response("cancel", "Cancel"); - alert.add_response("here", "Delete"); + alert.add_response("cancel", &i18n::t("action-cancel")); + alert.add_response("here", &i18n::t("action-delete")); alert.set_response_appearance("here", adw::ResponseAppearance::Destructive); if *kept { - alert.add_response("everywhere", "Delete Everywhere"); + alert.add_response( + "everywhere", + &i18n::t("delete-dialog-everywhere-button"), + ); alert.set_response_appearance( "everywhere", adw::ResponseAppearance::Destructive, @@ -2124,21 +2191,21 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender { let alert = adw::AlertDialog::new( - Some("Rotate the identity?"), - Some("Issues a fresh key from your secret with a signed certificate proving it replaces this one. Contacts accept the change from that chain, and the old key stays readable. Rotation is not revocation."), + Some(&i18n::t("rotate-dialog-title")), + Some(&i18n::t("advanced-rotations-description")), ); - alert.add_response("cancel", "Cancel"); - alert.add_response("rotate", "Rotate"); + alert.add_response("cancel", &i18n::t("action-cancel")); + alert.add_response("rotate", &i18n::t("advanced-rotate-button")); alert.set_response_appearance("rotate", adw::ResponseAppearance::Destructive); alert } Dialog::Reset => { let alert = adw::AlertDialog::new( - Some("Log out and delete all data?"), - Some("Removes this identity from this client entirely: the secret, pins, contacts and every cached message. Without a backup of the secret there is no way back in. Mail already fetched here may have no other copy anywhere, unless \"leave mail on server\" was on. Whatever is still on the server comes back on the first fetch after logging back in."), + Some(&i18n::t("reset-dialog-title")), + Some(&i18n::t("advanced-logout-description")), ); - alert.add_response("cancel", "Cancel"); - alert.add_response("reset", "Log Out"); + alert.add_response("cancel", &i18n::t("action-cancel")); + alert.add_response("reset", &i18n::t("advanced-logout-button")); alert.set_response_appearance("reset", adw::ResponseAppearance::Destructive); alert } @@ -2149,14 +2216,12 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender>() .join("\n"); let alert = adw::AlertDialog::new( - Some("Carry the sender's fields into the reply?"), - Some(&format!( - "This message carries its own frontmatter:\n\n{list}\n\nReply with copies of these fields, or start with none. The fields stay editable either way." - )), + Some(&i18n::t("reply-dialog-title")), + Some(&i18n::t1("reply-dialog-body", "list", list)), ); - alert.add_response("cancel", "Cancel Reply"); - alert.add_response("without", "Reply Without"); - alert.add_response("copy", "Copy Fields"); + alert.add_response("cancel", &i18n::t("reply-dialog-cancel-button")); + alert.add_response("without", &i18n::t("reply-dialog-without-button")); + alert.add_response("copy", &i18n::t("reply-dialog-copy-button")); alert.set_response_appearance("copy", adw::ResponseAppearance::Suggested); alert } @@ -2166,14 +2231,16 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender { + let mut args = fluent_bundle::FluentArgs::new(); + args.set("address", address.clone()); + args.set("known_fingerprint", known_fingerprint.clone()); + args.set("offered_fingerprint", offered_fingerprint.clone()); let alert = adw::AlertDialog::new( - Some("Replace this contact's key?"), - Some(&format!( - "{address} is already on file with a different key.\n\nknown {known_fingerprint}\nnew {offered_fingerprint}\n\nThe address you were given carries the new key and would replace the stored one. If this contact did not hand it to you themselves, stop: a key swapped in on the way reads everything you send them." - )), + Some(&i18n::t("key-change-dialog-title")), + Some(&i18n::t_args("key-change-dialog-body", Some(&args))), ); - alert.add_response("cancel", "Cancel"); - alert.add_response("replace", "Replace Key"); + alert.add_response("cancel", &i18n::t("action-cancel")); + alert.add_response("replace", &i18n::t("key-change-dialog-replace-button")); alert.set_response_appearance("replace", adw::ResponseAppearance::Destructive); alert } @@ -2231,9 +2298,15 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender { let inbox = model.lists.first().map(|rows| rows.len()).unwrap_or(0); - format!("fetching… {inbox} in inbox") + i18n::t1("status-fetching-progress", "count", inbox as i64) } - (Some(busy), false) => format!("{busy}…"), + (Some(busy), false) => match busy.as_str() { + "fetching" => i18n::t("status-busy-fetching"), + "refreshing" => i18n::t("status-busy-refreshing"), + "registering" => i18n::t("status-busy-registering"), + "restoring" => i18n::t("status-busy-restoring"), + other => format!("{other}…"), + }, (None, _) => model.status.clone().unwrap_or_default(), }); let errored = model.status_error && model.busy.is_none(); @@ -2256,7 +2329,7 @@ fn mail_row(folder: Folder, row: &Row) -> adw::ActionRow { "mail-read-symbolic" })); let subject = if row.subject.is_empty() { - "(no subject)".to_string() + i18n::t("mail-no-subject") } else { row.subject.clone() }; @@ -2295,7 +2368,7 @@ fn compose_header_row<'a>( ) -> (gtk::ListBoxRow, gtk::Entry) { let entry = gtk::Entry::new(); entry.set_hexpand(true); - entry.set_placeholder_text(Some("Key: value")); + entry.set_placeholder_text(Some(&i18n::t("compose-header-placeholder"))); entry.set_text(text); let style = |entry: >k::Entry, text: &str| match crate::ops::header_row(text) { Err(reason) => { @@ -2316,7 +2389,10 @@ fn compose_header_row<'a>( change_sender.input(Message::ComposeHeaderChanged(index, text)); }); - let remove = icon_button("user-trash-symbolic", "Remove this header"); + let remove = icon_button( + "user-trash-symbolic", + &i18n::t("compose-header-remove-tooltip"), + ); let remove_sender = sender.clone(); remove.connect_clicked(move |_| { remove_sender.input(Message::ComposeHeaderRemoved(index)); @@ -2352,7 +2428,11 @@ fn sidebar_contact_row(contact: &Contact) -> adw::ActionRow { /// The detail view's body: the three lines an address is made of, in /// the column-aligned form a key block wants. fn detail_body(address: &str, key: &str, fingerprint: &str) -> String { - format!("key {key}\nfingerprint {fingerprint}\nsmol://{address}/{key}") + format!( + "{:<14}{key}\n{:<14}{fingerprint}\nsmol://{address}/{key}", + i18n::t("detail-key-label"), + i18n::t("detail-fingerprint-label"), + ) } /// Puts one URI's QR code into the detail header's share popover. A