feat: extract all user-facing strings into a Fluent catalogue

This commit is contained in:
randogoth 2026-10-08 22:10:29 +03:00
parent 4c5665a6ff
commit 57e75af3ac
8 changed files with 1024 additions and 326 deletions

153
Cargo.lock generated
View file

@ -118,6 +118,7 @@ dependencies = [
name = "carrier" name = "carrier"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"fluent-bundle",
"fumi-core", "fumi-core",
"gtk4", "gtk4",
"libadwaita", "libadwaita",
@ -125,6 +126,7 @@ dependencies = [
"rand_core", "rand_core",
"relm4", "relm4",
"tempfile", "tempfile",
"unic-langid",
"winresource", "winresource",
] ]
@ -278,6 +280,17 @@ dependencies = [
"subtle", "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]] [[package]]
name = "ed25519" name = "ed25519"
version = "2.2.3" version = "2.2.3"
@ -359,6 +372,41 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" 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]] [[package]]
name = "flume" name = "flume"
version = "0.11.1" version = "0.11.1"
@ -845,6 +893,25 @@ dependencies = [
"generic-array", "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]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
@ -1127,6 +1194,12 @@ dependencies = [
"smallvec", "smallvec",
] ]
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
@ -1161,6 +1234,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "self_cell"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813"
[[package]] [[package]]
name = "semver" name = "semver"
version = "1.0.28" version = "1.0.28"
@ -1352,6 +1431,37 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "tokio" name = "tokio"
version = "1.53.1" version = "1.53.1"
@ -1443,12 +1553,39 @@ dependencies = [
"once_cell", "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]] [[package]]
name = "typenum" name = "typenum"
version = "1.20.1" version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" 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]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.26" version = "1.0.26"
@ -1673,6 +1810,12 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "zerofrom"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
[[package]] [[package]]
name = "zeroize" name = "zeroize"
version = "1.9.0" version = "1.9.0"
@ -1693,6 +1836,16 @@ dependencies = [
"syn 2.0.119", "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]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.23" version = "1.0.23"

View file

@ -15,6 +15,8 @@ libadwaita = { version = "0.7", features = ["v1_5"] }
relm4 = { version = "0.9", features = ["libadwaita"] } relm4 = { version = "0.9", features = ["libadwaita"] }
rand_core = { version = "0.6", features = ["getrandom"] } rand_core = { version = "0.6", features = ["getrandom"] }
qrcodegen = "1" qrcodegen = "1"
fluent-bundle = "0.16.0"
unic-langid = "0.9.6"
[build-dependencies] [build-dependencies]
winresource = "0.1" winresource = "0.1"

343
locales/en-US/main.ftl Normal file
View file

@ -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

View file

@ -15,6 +15,7 @@ use fumi_core::crypto::{fingerprint, unb32, KEY_LEN};
use fumi_core::store::Store; use fumi_core::store::Store;
use gtk::prelude::*; use gtk::prelude::*;
use crate::i18n;
use crate::ops::{self, Draft, Folder, OpenedMsg, Row, Snapshot}; use crate::ops::{self, Draft, Folder, OpenedMsg, Row, Snapshot};
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -514,7 +515,7 @@ impl Carrier {
at: fumi_core::store::now(), at: fumi_core::store::now(),
}); });
ops::save_drafts(&self.drafts_path(), &self.drafts); 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 /// 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()); let (app, _) = Carrier::boot(self.dir.clone());
*self = app; *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 /// Cancelling out of a not-yet-registered identity removes the key
@ -853,9 +854,7 @@ impl Carrier {
self.master = Some(master); self.master = Some(master);
self.onboard.seed_shown = ops::seed_text(&master); self.onboard.seed_shown = ops::seed_text(&master);
self.onboard.mode = OnboardMode::Created; self.onboard.mode = OnboardMode::Created;
self.note( self.note(i18n::t("identity-created-note"));
"Identity created. Write the seed down; it is the only secret.".into(),
);
} }
} }
Message::OnboardRestore => { Message::OnboardRestore => {
@ -877,7 +876,7 @@ impl Carrier {
} else { } else {
self.master = Some(master); self.master = Some(master);
self.page = Page::Signup; 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), Err(e) => self.fail(e),
@ -904,14 +903,14 @@ impl Carrier {
return Vec::new(); return Vec::new();
} }
let Some(addr) = self.signup_host_address() else { 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::new();
}; };
return vec![PendingOp::LearnKey { addr }]; return vec![PendingOp::LearnKey { addr }];
} }
Message::SignupRegister => { Message::SignupRegister => {
let Some(addr) = self.signup_address() else { 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(); return Vec::new();
}; };
let invite = if self.signup.invite.is_empty() { let invite = if self.signup.invite.is_empty() {
@ -926,7 +925,7 @@ impl Carrier {
} }
Message::SignupRecall => { Message::SignupRecall => {
let Some(addr) = self.signup_address() else { 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(); return Vec::new();
}; };
if !self.start("restoring") { if !self.start("restoring") {
@ -993,7 +992,7 @@ impl Carrier {
let to = if let Some(uri) = &msg.reply_to { let to = if let Some(uri) = &msg.reply_to {
uri.clone() uri.clone()
} else if msg.from.starts_with('<') { } 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(); return Vec::new();
} else { } else {
msg.from.clone() msg.from.clone()
@ -1001,7 +1000,7 @@ impl Carrier {
let subject = if msg.subject.is_empty() || msg.subject.starts_with("Re:") { let subject = if msg.subject.is_empty() || msg.subject.starts_with("Re:") {
msg.subject.clone() msg.subject.clone()
} else { } 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 // The sender's own fields are the one thing a reply has to
// decide about: carry copies, or start clean. Only the // decide about: carry copies, or start clean. Only the
@ -1057,7 +1056,7 @@ impl Carrier {
return Vec::new(); return Vec::new();
}; };
if compose.to.trim().is_empty() { if compose.to.trim().is_empty() {
self.fail("enter a recipient".into()); self.fail(i18n::t("compose-enter-recipient-error"));
return Vec::new(); return Vec::new();
} }
let mut headers = Vec::new(); let mut headers = Vec::new();
@ -1066,7 +1065,10 @@ impl Carrier {
Ok(Some(pair)) => headers.push(pair), Ok(Some(pair)) => headers.push(pair),
Ok(None) => {} Ok(None) => {}
Err(reason) => { 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(); return Vec::new();
} }
} }
@ -1109,11 +1111,10 @@ impl Carrier {
} }
if self.strict_pinning { if self.strict_pinning {
if let Some(addr) = self.unpinned_first_contact(&to) { if let Some(addr) = self.unpinned_first_contact(&to) {
self.fail(format!( let mut args = fluent_bundle::FluentArgs::new();
"{} is not a contact yet and {} is not pinned — pin their server's key to send.", args.set("address", addr.short());
addr.short(), args.set("host", trust_label(&addr.host, addr.port));
trust_label(&addr.host, addr.port), self.fail(i18n::t_args("first-contact-blocked-error", Some(&args)));
));
self.pin_prompt = Some(PinPrompt { self.pin_prompt = Some(PinPrompt {
addr, addr,
offered: None, offered: None,
@ -1139,7 +1140,7 @@ impl Carrier {
return Vec::new(); return Vec::new();
}; };
let Ok(addr) = Address::parse(&to) else { 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(); return Vec::new();
}; };
let fetched = self let fetched = self
@ -1203,17 +1204,17 @@ impl Carrier {
return Vec::new(); return Vec::new();
} }
let Some(uri) = msg.reply_to.clone() else { 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(); return Vec::new();
}; };
let Ok(addr) = Address::parse(&uri) else { 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::new();
}; };
let Ok(identity) = let Ok(identity) =
<[u8; KEY_LEN]>::try_from(unb32(&msg.sender).unwrap_or_default()) <[u8; KEY_LEN]>::try_from(unb32(&msg.sender).unwrap_or_default())
else { 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::new();
}; };
return vec![PendingOp::Accept { return vec![PendingOp::Accept {
@ -1226,11 +1227,11 @@ impl Carrier {
return Vec::new(); return Vec::new();
}; };
let Some(uri) = msg.reply_to.clone() else { 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(); return Vec::new();
}; };
let Ok(addr) = Address::parse(&uri) else { 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::new();
}; };
return vec![PendingOp::Block { return vec![PendingOp::Block {
@ -1414,11 +1415,7 @@ impl Carrier {
Some(prompt) => prompt.offered = Some(key), Some(prompt) => prompt.offered = Some(key),
None => self.pin_offer = Some((addr, key)), None => self.pin_offer = Some((addr, key)),
} }
self.note( self.note(i18n::t("server-key-offered-note"));
"The server presented this key. Pin it only if the fingerprint \
matches what the operator published."
.into(),
);
None None
} }
OpResult::PinOffered(Ok(None)) => { OpResult::PinOffered(Ok(None)) => {
@ -1428,7 +1425,7 @@ impl Carrier {
if self.pin_prompt.take().is_none() { if self.pin_prompt.take().is_none() {
self.signup.key_trusted = true; 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 None
} }
OpResult::PinOffered(Err(e)) => { OpResult::PinOffered(Err(e)) => {
@ -1438,7 +1435,7 @@ impl Carrier {
} }
OpResult::Pinned(Ok(())) => { OpResult::Pinned(Ok(())) => {
self.done(); 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 // The contact detail shows the pin state, so that pane
// needs the fresh row; signup's own pin changes nothing // needs the fresh row; signup's own pin changes nothing
// that is on screen. // that is on screen.

64
src/i18n.rs Normal file
View file

@ -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<FluentResource> {
static BUNDLE: OnceLock<FluentBundle<FluentResource>> = 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<FluentValue<'static>>) -> 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()
}

View file

@ -6,6 +6,7 @@
mod app; mod app;
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
mod demo; mod demo;
mod i18n;
mod ops; mod ops;
mod view; mod view;

View file

@ -15,6 +15,8 @@ use fumi_core::message::{FRONTMATTER_KEYS, FRONTMATTER_MAX};
use fumi_core::store::Store; use fumi_core::store::Store;
use fumi_core::{account, client, export}; use fumi_core::{account, client, export};
use crate::i18n;
/// One message in a folder list, already opened for its metadata. /// One message in a folder list, already opened for its metadata.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Row { pub struct Row {
@ -316,6 +318,19 @@ fn explain(e: Error) -> String {
e.to_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] { pub fn new_master() -> [u8; KEY_LEN] {
use rand_core::RngCore; use rand_core::RngCore;
let mut master = [0u8; KEY_LEN]; 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 bytes = if text.len() == 64 && text.chars().all(|c| c.is_ascii_hexdigit()) {
let mut raw = Vec::with_capacity(32); let mut raw = Vec::with_capacity(32);
for pair in text.as_bytes().chunks(2) { for pair in text.as_bytes().chunks(2) {
let hi = (pair[0] as char).to_digit(16).ok_or("bad hex")?; let hi = (pair[0] as char)
let lo = (pair[1] as char).to_digit(16).ok_or("bad hex")?; .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.push((hi * 16 + lo) as u8);
} }
raw raw
} else { } else {
unb32(text).map_err(|e| explain(e))? unb32(text).map_err(|e| explain(e))?
}; };
bytes bytes.try_into().map_err(|_| i18n::t("seed-wrong-length"))
.try_into()
.map_err(|_| "a seed is 32 bytes".to_string())
} }
pub fn load_key(path: &Path) -> Result<[u8; KEY_LEN], String> { 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() 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> { 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) { match std::fs::remove_file(path) {
Ok(()) => Ok(()), Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => 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); let path = dir.join(name);
if let Err(e) = std::fs::remove_file(&path) { if let Err(e) = std::fs::remove_file(&path) {
if e.kind() != std::io::ErrorKind::NotFound { 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), cancel: Some(&cancel),
}; };
let fetched = client::fetch_with(store, &me, opts).map_err(explain)?; let fetched = client::fetch_with(store, &me, opts).map_err(explain)?;
let mut note = format!("{} fetched, {} stored", fetched.total, fetched.stored); let variant = match (!fetched.rejected.is_empty(), fetched.cancelled) {
if !fetched.rejected.is_empty() { (true, true) => "rejected-cancelled",
note.push_str(&format!(", {} rejected", fetched.rejected.len())); (true, false) => "rejected",
} (false, true) => "cancelled",
if fetched.cancelled { (false, false) => "plain",
note.push_str(" (cancelled)"); };
} let mut args = fluent_bundle::FluentArgs::new();
Ok(note) 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 /// Fields fumi itself attaches to every message (sec 5.7, 5.8); a
@ -506,14 +531,18 @@ pub fn header_row(text: &str) -> Result<Option<(String, String)>, String> {
return Ok(None); return Ok(None);
} }
let Some((key, value)) = text.split_once(':') else { 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(); let key = key.trim();
if RESERVED_HEADERS if RESERVED_HEADERS
.iter() .iter()
.any(|reserved| key.eq_ignore_ascii_case(reserved)) .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() if key.is_empty()
|| key.len() > 64 || key.len() > 64
@ -521,10 +550,10 @@ pub fn header_row(text: &str) -> Result<Option<(String, String)>, String> {
.bytes() .bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') .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') { 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()))) 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<String> = std::collections::HashSet::new(); let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
for (key, _) in headers { for (key, _) in headers {
if !seen.insert(key.to_ascii_lowercase()) { 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 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 // 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. // Accept token on top of the user's rows; 512 bytes covers them all.
if budget + 512 > FRONTMATTER_MAX { 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 { if headers.len() + 4 > FRONTMATTER_KEYS {
return Err("too many headers".into()); return Err(i18n::t("compose-headers-too-many"));
} }
Ok(()) Ok(())
} }
@ -592,20 +621,27 @@ pub fn send(
no_pad: false, no_pad: false,
}; };
let sent = client::send(store, &me, &draft, timeout).map_err(explain)?; let sent = client::send(store, &me, &draft, timeout).map_err(explain)?;
let change = match sent.change { let trust = match sent.change {
TrustChange::New { unverified: true } => { TrustChange::New { unverified: true } => Some("unverified"),
"; new contact learned from an unpinned server (unverified)" TrustChange::New { unverified: false } => Some("tofu"),
} TrustChange::Rotated => Some("rotated"),
TrustChange::New { unverified: false } => "; new contact learned (trust on first use)", TrustChange::None => None,
TrustChange::Rotated => "; contact rotated to a new key (chain verified)",
TrustChange::None => "",
}; };
let mut note = format!("sent to {} ({} bytes)", addr.short(), sent.bytes); let variant = match (trust, sent.token_used) {
if sent.token_used { (Some(trust), true) => format!("{trust}-token"),
note.push_str(", accept token attached"); (Some(trust), false) => trust.to_string(),
} (None, true) => "token".to_string(),
note.push_str(change); (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 { 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}")); note.push_str(&format!("; {warning}"));
} }
Ok(note) Ok(note)
@ -669,7 +705,7 @@ pub fn import_contact(store: &Store, uri: &str) -> Result<(String, String), Stri
let addr = parse_address(uri)?; let addr = parse_address(uri)?;
let key = addr let key = addr
.identity .identity
.ok_or("import needs a self-certifying smol:// address carrying a key")?; .ok_or_else(|| i18n::t("contacts-import-needs-key"))?;
store store
.save_contact(&addr.short(), &key, true) .save_contact(&addr.short(), &key, true)
.map_err(explain)?; .map_err(explain)?;
@ -723,8 +759,13 @@ pub fn rotate(
/// Writes the gsmol backup to the chosen path. /// Writes the gsmol backup to the chosen path.
pub fn export_backup(store: &Store, master: &[u8; KEY_LEN], path: &Path) -> Result<String, String> { pub fn export_backup(store: &Store, master: &[u8; KEY_LEN], path: &Path) -> Result<String, String> {
let text = export::export(store, master).map_err(explain)?; let text = export::export(store, master).map_err(explain)?;
std::fs::write(path, &text).map_err(|e| format!("{}: {e}", path.display()))?; std::fs::write(path, &text)
Ok(format!("backup written to {}", path.display())) .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<String, String> { pub fn import_backup(store: &Store, master: &[u8; KEY_LEN], text: &str) -> Result<String, String> {
@ -739,10 +780,10 @@ fn hex32(text: &str) -> Result<[u8; KEY_LEN], String> {
.collect::<Result<Vec<_>, _>>() .collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
} else { } else {
return Err("message ids are 64 hex characters".to_string()); return Err(i18n::t("mail-message-id-wrong-length"));
}; };
raw.try_into() 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. /// Lists one folder as rows, marking unread against the local read set.
@ -796,7 +837,7 @@ pub fn open(
.map_err(explain)? .map_err(explain)?
.into_iter() .into_iter()
.find(|m| m.id == raw) .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)?; let described = client::describe(store, &me, &stored).map_err(explain)?;
// The subject sits in the header, the reply address powers Reply and // The subject sits in the header, the reply address powers Reply and
// the accept/block actions, and the Accept token is consumed by the // 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") 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] #[test]
fn seed_round_trips_through_base32_and_hex() { fn seed_round_trips_through_base32_and_hex() {
let m = master(); let m = master();

File diff suppressed because it is too large Load diff