feat: base UI strings on carrier's shared Fluent source
Vendors carrier's locales/en-US/main.ftl (extended to cover gsmol's remaining strings) as the single source both clients draw wording from, adds a dependency-free .ftl-to-JSON build step (devbox run locale) since the file only ever uses Fluent's flat subset, and replaces every hardcoded string in index.html/app.js with a lookup into the generated dictionary. Along the way: removes connect()'s requirePin check, which turned out to be unreachable under the app's own invariants (every call site already guarantees its target host is pinned before calling it), and fixes a leftover call to store.setMasterSaved — removed in the earlier settings redesign — that was silently breaking onboarding's "I saved it" button.
This commit is contained in:
parent
80aca8a29f
commit
70087fa470
7 changed files with 1080 additions and 258 deletions
|
|
@ -13,6 +13,9 @@
|
||||||
"serve": [
|
"serve": [
|
||||||
"uv run bridge.py"
|
"uv run bridge.py"
|
||||||
],
|
],
|
||||||
|
"locale": [
|
||||||
|
"node scripts/build-locale.mjs"
|
||||||
|
],
|
||||||
"test": [
|
"test": [
|
||||||
"uv run test/gen_vectors.py",
|
"uv run test/gen_vectors.py",
|
||||||
"node test/vectors.test.mjs",
|
"node test/vectors.test.mjs",
|
||||||
|
|
|
||||||
449
locales/en-US/main.ftl
Normal file
449
locales/en-US/main.ftl
Normal file
|
|
@ -0,0 +1,449 @@
|
||||||
|
## 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 = That's not valid hex. Check for typos or extra characters.
|
||||||
|
seed-wrong-length = A seed is 32 bytes. Check you copied the whole thing.
|
||||||
|
|
||||||
|
## Local files — errors reading/writing the key and its sidecars
|
||||||
|
|
||||||
|
file-error = { $path }: { $reason }
|
||||||
|
key-file-wrong-length = { $path }: not a 32-byte secret
|
||||||
|
|
||||||
|
## Compose — validating a typed `Key: value` header row
|
||||||
|
|
||||||
|
compose-header-colon-missing = Headers need a colon, like `Key: value`.
|
||||||
|
compose-header-reserved-key = { $key } is set automatically and can't be changed.
|
||||||
|
compose-header-invalid-key = Keys can be 1 to 64 letters, digits, and dashes.
|
||||||
|
compose-header-value-multiline = Values need to stay on one line.
|
||||||
|
compose-header-duplicate-key = { $key } is set twice. Remove one of them.
|
||||||
|
compose-headers-too-large = Your headers take up too much space. Remove some and try again.
|
||||||
|
compose-headers-too-many = You have too many headers. Remove some and try again.
|
||||||
|
|
||||||
|
## Contacts — importing a smol:// address
|
||||||
|
|
||||||
|
contacts-import-needs-key = Import needs a smol:// address that includes 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
|
||||||
|
##
|
||||||
|
## Split from one selector into one message per combination of "N
|
||||||
|
## rejected" and "(cancelled)" that can apply; the caller in ops.rs
|
||||||
|
## picks the id to match, since a flat message can't branch on a
|
||||||
|
## variable itself.
|
||||||
|
|
||||||
|
fetch-result-note-rejected = { $total } fetched, { $stored } stored, { $rejected } rejected
|
||||||
|
fetch-result-note-cancelled = { $total } fetched, { $stored } stored (cancelled)
|
||||||
|
fetch-result-note-rejected-cancelled = { $total } fetched, { $stored } stored, { $rejected } rejected (cancelled)
|
||||||
|
fetch-result-note-plain = { $total } fetched, { $stored } stored
|
||||||
|
mail-envelope-id-mismatch-error = id does not match the envelope
|
||||||
|
mail-fetch-summary-counts = { $stored } new, { $rejected } rejected
|
||||||
|
mail-fetch-summary-verified-one = { $verified } sender verified from a signed Reply-To
|
||||||
|
mail-fetch-summary-verified-other = { $verified } senders verified from a signed Reply-To
|
||||||
|
mail-fetch-summary-left-on-server = left on the server
|
||||||
|
|
||||||
|
## Send — the status note after a send completes
|
||||||
|
##
|
||||||
|
## Split from one selector into one message per combination of
|
||||||
|
## trust-change clause and accept-token clause that can apply; the
|
||||||
|
## caller in ops.rs picks the id to match. 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-token = Sent to { $address } ({ $bytes } bytes), accept token attached
|
||||||
|
send-result-note-unverified = Sent to { $address } ({ $bytes } bytes); new contact learned from an unpinned server (unverified)
|
||||||
|
send-result-note-unverified-token = Sent to { $address } ({ $bytes } bytes), accept token attached; new contact learned from an unpinned server (unverified)
|
||||||
|
send-result-note-tofu = Sent to { $address } ({ $bytes } bytes); new contact learned (trust on first use)
|
||||||
|
send-result-note-tofu-token = Sent to { $address } ({ $bytes } bytes), accept token attached; new contact learned (trust on first use)
|
||||||
|
send-result-note-rotated = Sent to { $address } ({ $bytes } bytes); contact rotated to a new key (chain verified)
|
||||||
|
send-result-note-rotated-token = Sent to { $address } ({ $bytes } bytes), accept token attached; contact rotated to a new key (chain verified)
|
||||||
|
send-result-note-plain = Sent to { $address } ({ $bytes } bytes)
|
||||||
|
mail-sent-note = sent to { $address }
|
||||||
|
mail-sent-note-accepted = sent to { $address } (accepted)
|
||||||
|
|
||||||
|
## 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
|
||||||
|
contacts-no-key-error = no key for { $address } yet
|
||||||
|
# Split from one selector into flat siblings for the token count, matching
|
||||||
|
# this file's existing convention (see fetch-result-note-* above).
|
||||||
|
contacts-accepted-note-one = { $address } accepted; server now holds { $held } accept token
|
||||||
|
contacts-accepted-note-other = { $address } accepted; server now holds { $held } accept tokens
|
||||||
|
contacts-blocked-note-one = { $address } blocked; server now holds { $held } accept token
|
||||||
|
contacts-blocked-note-other = { $address } blocked; server now holds { $held } accept tokens
|
||||||
|
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 = Signed out. Create a new identity or restore a seed.
|
||||||
|
|
||||||
|
## Onboarding
|
||||||
|
|
||||||
|
identity-created-note = Identity created. Write the seed down. It's 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 the user and host.
|
||||||
|
# A pin is keyed by bare host, so these two catch the mistakes that would
|
||||||
|
# otherwise silently pin a string nothing looks up again: a whole address,
|
||||||
|
# or a host with its port still attached.
|
||||||
|
signup-host-looks-like-address-error = "{ $host }" looks like an address, not a server — the part after the @ goes here
|
||||||
|
signup-host-has-port-error = "{ $host }": a pin is by host only, no port — just the part before the colon
|
||||||
|
|
||||||
|
## 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 isn't in the right format.
|
||||||
|
|
||||||
|
## Contacts
|
||||||
|
|
||||||
|
contacts-reply-address-invalid-error = The sender's reply address isn't in the right format.
|
||||||
|
contacts-sender-key-invalid-error = The sender's key isn't in the right format.
|
||||||
|
contacts-accept-no-address-error = This sender left no address to accept.
|
||||||
|
contacts-block-no-address-error = This sender left no address to block.
|
||||||
|
contacts-sender-key-mismatch-error = that address carries a different key than this message's sender
|
||||||
|
# A pinned, conflicting Reply-To claim is refused outright rather than asked
|
||||||
|
# about — see saveContactChecked's dialog for the softer self-certifying case.
|
||||||
|
contacts-reply-key-conflict-error = { $address } is already known with a different key — verify out of band before replying
|
||||||
|
# Shown when a typed address or a smol:// link would overwrite an address
|
||||||
|
# already saved under a different key, and the user declined the prompt
|
||||||
|
# that showed both fingerprints and asked.
|
||||||
|
contacts-key-kept-note = kept the existing key for { $address }
|
||||||
|
contacts-sender-saved-note-verified = { $address } saved for this sender (verified key)
|
||||||
|
contacts-sender-saved-note-unverified = { $address } saved for this sender
|
||||||
|
# Richer than status-imported-note: this client shows the imported key and
|
||||||
|
# its fingerprint alongside the confirmation.
|
||||||
|
contacts-import-detail-note = imported { $address } { $key } (verified){"\u000A"}fingerprint: { $fingerprint }
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
# Reused wherever an action needs an identity that doesn't exist yet
|
||||||
|
# (sending, fetching, pushing accept tokens).
|
||||||
|
identity-missing-error = no identity yet
|
||||||
|
account-not-registered-fetch-warning = not registered yet — claim an address in settings
|
||||||
|
account-not-registered-tokens-note = not registered; the set will be pushed with your first fetch
|
||||||
|
|
||||||
|
## 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
|
||||||
|
settings-close-aria-label = close settings
|
||||||
|
settings-close-tooltip = close
|
||||||
|
identity-you-caption = you
|
||||||
|
contacts-caption = contacts
|
||||||
|
contacts-import-placeholder = Import a smol:// address
|
||||||
|
contacts-import-button = Import
|
||||||
|
# "Follow the system" is a real preference, not the absence of one, so it
|
||||||
|
# gets its own label rather than defaulting silently to one of the other two.
|
||||||
|
theme-auto-label = theme: following the system
|
||||||
|
theme-light-label = theme: light
|
||||||
|
theme-dark-label = theme: dark
|
||||||
|
account-not-registered-value = not registered
|
||||||
|
mail-search-placeholder = search { $folder }
|
||||||
|
|
||||||
|
## Mail — the open-message meta line
|
||||||
|
##
|
||||||
|
## Split from one selector into one message per variant, each a full
|
||||||
|
## sentence; the caller in view.rs picks `-yes`/`-no` to match `$kept`.
|
||||||
|
|
||||||
|
mail-meta-to = to
|
||||||
|
mail-meta-from = from
|
||||||
|
mail-meta-line-no = { $label } { $from } · { $time } UTC · id { $id }…
|
||||||
|
mail-meta-line-yes = { $label } { $from } · { $time } UTC · id { $id }… · a server copy still exists
|
||||||
|
mail-no-subject = (no subject)
|
||||||
|
mail-no-reply-address = no reply address: can't accept or block
|
||||||
|
contacts-empty-hint = no contacts
|
||||||
|
# The §4/§8 trust chip on an open message: a signature always verifies, or
|
||||||
|
# unseal() would have thrown already — what varies is the binding between
|
||||||
|
# the key that signed it and an address.
|
||||||
|
mail-back-to-list-button = messages
|
||||||
|
mail-meta-sent = to: { $recipient }{"\u000A"}id: { $id }{"\u000A"}sent: { $time }
|
||||||
|
mail-meta-received = from: { $from }{"\u000A"}id: { $id }{"\u000A"}date: { $time }
|
||||||
|
mail-trust-own-copy = your own sealed copy
|
||||||
|
mail-trust-verified-sender = verified sender key
|
||||||
|
mail-trust-tofu-sender = key pinned on first use
|
||||||
|
mail-trust-unbound-sender = key bound to no address
|
||||||
|
mail-sender-unbound-tooltip = sender key not bound to an address
|
||||||
|
mail-signed-reply-hint = this message carries the sender's signed address: { $address }
|
||||||
|
mail-save-sender-reply-button = save sender & reply
|
||||||
|
mail-name-sender-button = name sender & reply
|
||||||
|
mail-unknown-sender-hint = The wire carries no sender name, only this signed key. To reply, give the sender's address — it is checked against this key:
|
||||||
|
mail-delete-not-registered-error = not registered; cannot reach the server to delete this message
|
||||||
|
mail-request-unaccepted-hint = unsolicited mail from { $address }, not yet accepted into your main tier
|
||||||
|
mail-accept-button = accept — future mail lands in your main tier
|
||||||
|
|
||||||
|
## 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 haven't 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 = Closing a compose from the sidebar keeps it 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
|
||||||
|
mail-row-unreadable-error = <unreadable: { $error }>
|
||||||
|
mail-contacts-reader-hint = contacts and their keys are listed alongside
|
||||||
|
mail-list-count-one = { $count } message
|
||||||
|
mail-list-count-other = { $count } messages
|
||||||
|
mail-list-shown-of-total = { $shown } of { $total }
|
||||||
|
|
||||||
|
## 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
|
||||||
|
compose-body-placeholder = message
|
||||||
|
compose-anonymous-label = anonymous — omit my address
|
||||||
|
|
||||||
|
## 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`. Split from one selector into one message per
|
||||||
|
# pin state; the caller in view.rs picks `-yes`/`-no` to match `$pinned`.
|
||||||
|
contact-meta-line-yes = { $state } · server pinned
|
||||||
|
contact-meta-line-no = { $state } · server 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-address-label = address
|
||||||
|
detail-server-label = server
|
||||||
|
contact-server-key-offered = this server presents
|
||||||
|
contacts-server-pinned-badge = pinned
|
||||||
|
contacts-server-not-pinned-badge = not pinned
|
||||||
|
contacts-history-badge = { $count } previous
|
||||||
|
contacts-import-empty-hint = no contacts yet — import a smol:// address above, or write to someone
|
||||||
|
contacts-refresh-button = re-resolve
|
||||||
|
contacts-accept-token-title = accept token
|
||||||
|
contacts-accepted-hint = This contact's mail lands in your main tier.
|
||||||
|
contacts-unaccepted-hint = This contact's mail lands in requests until accepted.
|
||||||
|
contacts-history-title = previous keys ({ $count })
|
||||||
|
contacts-history-hint = Replaced by a signed rotation chain. Mail signed with these was sent before the change; rotation is not revocation, so a stolen key can still rotate onward.
|
||||||
|
contacts-history-replaced = replaced { $time }
|
||||||
|
|
||||||
|
## 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-button = reveal
|
||||||
|
settings-secret-hide-button = hide
|
||||||
|
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-secret-warning = Anyone who holds this can read your mail and write as you, and losing it loses the address with it — there is no reset. Keep it in a password manager.
|
||||||
|
# A copy button's own label, flashed on click instead of a toast.
|
||||||
|
copy-button-idle = copy
|
||||||
|
copy-button-success = copied
|
||||||
|
copy-button-failed = failed
|
||||||
|
settings-not-registered-value = not registered yet
|
||||||
|
settings-uri-claim-hint = claim an address to get one
|
||||||
|
|
||||||
|
## 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 you can fetch it again from another app or device. When this is on, deleting a message removes it here and on the server.
|
||||||
|
|
||||||
|
## Settings — Backup tab
|
||||||
|
|
||||||
|
backup-page-description = One file with your mail, contacts, and server info, for moving to another app or keeping what the server no longer has. It's sealed to this identity and never includes the secret itself.
|
||||||
|
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-trust-section-title = trust
|
||||||
|
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-rotate-needs-account-error = rotate needs a registered account
|
||||||
|
advanced-first-contact-title = First contact
|
||||||
|
advanced-first-contact-description = A recipient who isn't 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 that send and offer to pin the server's key first, so you can check it against what the operator published. With this off, the key's taken on trust, and the send is only reported as unverified afterwards.
|
||||||
|
advanced-logout-title = Sign out
|
||||||
|
advanced-logout-description = Removes this identity from this app entirely: the secret, pins, contacts, and every cached message. Without a backup of the secret, you can't get back in. Mail already fetched here may have no other copy anywhere, unless "Leave mail on server" was on. Whatever's still on the server comes back on the first fetch after you sign back in.
|
||||||
|
advanced-logout-button = Sign 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-welcome-title = welcome
|
||||||
|
onboard-welcome-description = Encrypted mail with no account and no password. Your identity is one secret, kept in this browser and nowhere else.
|
||||||
|
onboard-tagline = smolmail: one seed, sealed mail, five operations
|
||||||
|
onboard-create-button = Create a new identity
|
||||||
|
onboard-restore-button = Restore an existing identity from a seed
|
||||||
|
onboard-created-title = Write this seed down
|
||||||
|
onboard-created-description = It's the only secret, and it's shown once.
|
||||||
|
onboard-saved-button = I saved it
|
||||||
|
onboard-restore-title = Restore from a seed
|
||||||
|
onboard-restore-description = Paste the secret you saved. Your keys come back from it — it is all this client needs.
|
||||||
|
onboard-seed-entry-title = Seed: 32 bytes, base32 or hex
|
||||||
|
onboard-restore-go-button = Restore
|
||||||
|
|
||||||
|
## Signup
|
||||||
|
|
||||||
|
signup-server-title = Set up the server
|
||||||
|
signup-host-label = host
|
||||||
|
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 the server requires one)
|
||||||
|
signup-register-button = Register
|
||||||
|
signup-recall-button = Restore access
|
||||||
|
|
||||||
|
## Compose — the inline pin card shown after a blocked send
|
||||||
|
|
||||||
|
compose-pin-title = { $host } isn't pinned
|
||||||
|
compose-pin-body = { $address } isn't a contact yet, so carrier would have to ask this server for its key. A server with no pinned key can answer with one of its own, then 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 = This removes the only copy, which is sealed and stored on this device.
|
||||||
|
delete-dialog-everywhere-button = Delete everywhere
|
||||||
|
rotate-dialog-title = Rotate the identity?
|
||||||
|
rotate-dialog-confirm-button = Rotate
|
||||||
|
reset-dialog-title = Sign out and delete all data?
|
||||||
|
reset-dialog-confirm-button = Sign Out
|
||||||
|
# `$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 has its own headers:{"\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 one on file. If this contact didn't 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-busy-rotating = rotating…
|
||||||
|
status-fetching-progress = fetching… { $count } in inbox
|
||||||
|
|
||||||
|
## First contact / server trust
|
||||||
|
|
||||||
|
first-contact-blocked-error = { $address } isn't a contact yet, and { $host } isn't 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
|
||||||
|
connect-unpinned-warning = { $host } is not pinned; its key is { $key }.{"\u000A"}Lookups from this session are UNVERIFIED.
|
||||||
|
recall-rotation-not-found-error = the key bound to { $address } is not derived from this master within { $max } rotations
|
||||||
|
contacts-refresh-embedded-key-error = that address already carries a key; use import instead
|
||||||
|
# Shared by a first send to a never-before-seen recipient and a contact's
|
||||||
|
# own "re-resolve" button — both land here the same way, by RESOLVE finding
|
||||||
|
# no existing key for the address.
|
||||||
|
contacts-resolved-note = { $address } pinned (trust on first use)
|
||||||
|
contacts-resolved-note-unverified = { $address } pinned (trust on first use, UNVERIFIED server)
|
||||||
|
contacts-refresh-unchanged-note = { $address }: key unchanged
|
||||||
|
contacts-refresh-rotated-note = { $address } rotated its key; a signed chain confirms it.{"\u000A"}now { $key }
|
||||||
|
contacts-refresh-no-chain-warning = { $address } presents a different key with no valid rotation chain.{"\u000A"}Verify out of band, then import the new smol:// address.
|
||||||
46
scripts/build-locale.mjs
Normal file
46
scripts/build-locale.mjs
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// Converts locales/en-US/main.ftl into web/locales/en-US.json: a flat
|
||||||
|
// id -> string dictionary the browser can fetch with no parser of its own.
|
||||||
|
//
|
||||||
|
// Deliberately hand-rolled rather than a dependency on @fluent/syntax: the
|
||||||
|
// source file only ever uses Fluent's simplest shape (comments, flat
|
||||||
|
// `id = value` messages, `{ $var }` references, and `{"literal"}` string
|
||||||
|
// inserts for embedded newlines) — confirmed by grep before writing this,
|
||||||
|
// not assumed. If the file ever gains a select expression, a term, or a
|
||||||
|
// multiline value, this script will need to grow with it; until then a
|
||||||
|
// real parser is more dependency than the input justifies.
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||||
|
const srcPath = join(root, "locales/en-US/main.ftl");
|
||||||
|
const outPath = join(root, "web/locales/en-US.json");
|
||||||
|
|
||||||
|
const MESSAGE = /^([a-zA-Z][a-zA-Z0-9_-]*)\s*=\s*(.*)$/;
|
||||||
|
// A Fluent string literal, e.g. {"\u000A"} — only used in this file to
|
||||||
|
// embed a literal newline inside an otherwise single-line value.
|
||||||
|
const LITERAL = /\{"((?:[^"\\]|\\.)*)"\}/g;
|
||||||
|
|
||||||
|
function unescapeLiteral(text) {
|
||||||
|
return text.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
||||||
|
.replace(/\\(.)/g, "$1");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFtl(text) {
|
||||||
|
const messages = {};
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
if (!line || line.startsWith("#")) continue; // blank line or comment
|
||||||
|
const match = MESSAGE.exec(line);
|
||||||
|
if (!match) continue;
|
||||||
|
const [, id, rawValue] = match;
|
||||||
|
messages[id] = rawValue.replace(LITERAL, (_, inner) => unescapeLiteral(inner));
|
||||||
|
}
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = parseFtl(readFileSync(srcPath, "utf8"));
|
||||||
|
mkdirSync(dirname(outPath), { recursive: true });
|
||||||
|
writeFileSync(outPath, JSON.stringify(messages, null, 2) + "\n");
|
||||||
|
console.log(`wrote ${Object.keys(messages).length} messages to ${outPath}`);
|
||||||
189
web/index.html
189
web/index.html
|
|
@ -30,7 +30,8 @@
|
||||||
spellcheck="false" hidden>
|
spellcheck="false" hidden>
|
||||||
<div id="top-actions" class="top-actions" hidden>
|
<div id="top-actions" class="top-actions" hidden>
|
||||||
<button id="theme" class="icon-only" aria-label="theme"><svg class="icon"><use href="#i-auto"/></svg></button>
|
<button id="theme" class="icon-only" aria-label="theme"><svg class="icon"><use href="#i-auto"/></svg></button>
|
||||||
<button id="settings-open" class="icon-only" aria-label="settings" title="settings"><svg class="icon"><use href="#i-settings"/></svg></button>
|
<button id="settings-open" class="icon-only" data-l10n-aria-label="settings-nav-label"
|
||||||
|
data-l10n-title="settings-nav-label"><svg class="icon"><use href="#i-settings"/></svg></button>
|
||||||
<div id="account" class="account"></div>
|
<div id="account" class="account"></div>
|
||||||
<div id="avatar" class="avatar" aria-hidden="true"></div>
|
<div id="avatar" class="avatar" aria-hidden="true"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -41,128 +42,124 @@
|
||||||
screen's concern — e.g. the server screen never asks for a username. -->
|
screen's concern — e.g. the server screen never asks for a username. -->
|
||||||
<main id="setup" class="setup" hidden>
|
<main id="setup" class="setup" hidden>
|
||||||
<section id="setup-intro" class="setup-card">
|
<section id="setup-intro" class="setup-card">
|
||||||
<h2>welcome</h2>
|
<h2 data-l10n-id="onboard-welcome-title">welcome</h2>
|
||||||
<p class="lead">Encrypted mail with no account and no password. Your identity is one secret,
|
<p class="lead" data-l10n-id="onboard-welcome-description">Encrypted mail with no account and no
|
||||||
kept in this browser and nowhere else.</p>
|
password. Your identity is one secret, kept in this browser and nowhere else.</p>
|
||||||
<button type="button" id="create-identity" class="filled large">create a new identity</button>
|
<button type="button" id="create-identity" class="filled large" data-l10n-id="onboard-create-button">create a new identity</button>
|
||||||
<button type="button" id="show-restore" class="link">restore existing identity from seed</button>
|
<button type="button" id="show-restore" class="link" data-l10n-id="onboard-restore-button">restore existing identity from seed</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="setup-seed" class="setup-card" hidden>
|
<section id="setup-seed" class="setup-card" hidden>
|
||||||
<h2>write this seed down</h2>
|
<h2 data-l10n-id="onboard-created-title">write this seed down</h2>
|
||||||
<p class="lead">It is the only secret, and it is shown once.</p>
|
<p class="lead" data-l10n-id="onboard-created-description">It is the only secret, and it is shown once.</p>
|
||||||
<div class="seed-card">
|
<div class="seed-card">
|
||||||
<div id="setup-seed-value" class="seed"></div>
|
<div id="setup-seed-value" class="seed"></div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" id="seed-saved" class="filled large">I saved it</button>
|
<button type="button" id="seed-saved" class="filled large" data-l10n-id="onboard-saved-button">I saved it</button>
|
||||||
<button type="button" id="seed-back" class="link">back</button>
|
<button type="button" id="seed-back" class="link" data-l10n-id="action-back">back</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="setup-restore" class="setup-card" hidden>
|
<section id="setup-restore" class="setup-card" hidden>
|
||||||
<h2>restore from a seed</h2>
|
<h2 data-l10n-id="onboard-restore-title">restore from a seed</h2>
|
||||||
<p class="lead">Paste the secret you saved. Your keys come back from it — it is all this client
|
<p class="lead" data-l10n-id="onboard-restore-description">Paste the secret you saved. Your keys
|
||||||
needs.</p>
|
come back from it — it is all this client needs.</p>
|
||||||
<form id="restore-form" class="stack">
|
<form id="restore-form" class="stack">
|
||||||
<label>your secret
|
<label><span data-l10n-id="onboard-seed-entry-title">your secret</span>
|
||||||
<input id="restore-master" placeholder="hex or base32" spellcheck="false" required>
|
<input id="restore-master" spellcheck="false" required>
|
||||||
</label>
|
</label>
|
||||||
<span id="restore-error" class="error"></span>
|
<span id="restore-error" class="error"></span>
|
||||||
<button class="filled large">restore</button>
|
<button class="filled large" data-l10n-id="onboard-restore-go-button">restore</button>
|
||||||
</form>
|
</form>
|
||||||
<button type="button" id="restore-back" class="link">back</button>
|
<button type="button" id="restore-back" class="link" data-l10n-id="action-back">back</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="setup-server" class="setup-card" hidden>
|
<section id="setup-server" class="setup-card" hidden>
|
||||||
<h2>set up the server</h2>
|
<h2 data-l10n-id="signup-server-title">set up the server</h2>
|
||||||
<form id="server-form" class="stack">
|
<form id="server-form" class="stack">
|
||||||
<label>host
|
<label><span data-l10n-id="signup-host-label">host</span>
|
||||||
<input id="server-host" placeholder="example.org" spellcheck="false" required>
|
<input id="server-host" placeholder="example.org" spellcheck="false" required>
|
||||||
</label>
|
</label>
|
||||||
<div id="server-key-area" class="seed-card" hidden></div>
|
<div id="server-key-area" class="seed-card" hidden></div>
|
||||||
<span id="server-error" class="error"></span>
|
<span id="server-error" class="error"></span>
|
||||||
<button id="server-action" class="filled large">fetch the server's key</button>
|
<button id="server-action" class="filled large" data-l10n-id="signup-fetch-key-button">fetch the server's key</button>
|
||||||
</form>
|
</form>
|
||||||
<button type="button" id="server-back" class="link">start over</button>
|
<button type="button" id="server-back" class="link" data-l10n-id="signup-start-over-button">start over</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="setup-account" class="setup-card" hidden>
|
<section id="setup-account" class="setup-card" hidden>
|
||||||
<h2>choose your address</h2>
|
<h2 data-l10n-id="signup-account-title">choose your address</h2>
|
||||||
<div id="account-mode" class="mode-toggle" role="group">
|
<div id="account-mode" class="mode-toggle" role="group">
|
||||||
<button type="button" data-mode="new" class="active" aria-current="true">create a new address</button>
|
<button type="button" data-mode="new" class="active" aria-current="true"
|
||||||
<button type="button" data-mode="existing">I already have this address</button>
|
data-l10n-id="signup-mode-new-label">create a new address</button>
|
||||||
|
<button type="button" data-mode="existing" data-l10n-id="signup-mode-existing-label">I already have this address</button>
|
||||||
</div>
|
</div>
|
||||||
<form id="account-form" class="stack">
|
<form id="account-form" class="stack">
|
||||||
<label>user
|
<label><span data-l10n-id="signup-user-label">user</span>
|
||||||
<input id="account-user" placeholder="alice" spellcheck="false" required>
|
<input id="account-user" placeholder="alice" spellcheck="false" required>
|
||||||
</label>
|
</label>
|
||||||
<label id="account-token-label"><span>invite token <span class="hint">(optional)</span></span>
|
<label id="account-token-label"><span data-l10n-id="signup-invite-label">invite token (if the server requires one)</span>
|
||||||
<input id="account-token" placeholder="only if your server asks">
|
<input id="account-token">
|
||||||
</label>
|
</label>
|
||||||
<span id="account-error" class="error"></span>
|
<span id="account-error" class="error"></span>
|
||||||
<button id="account-action" class="filled large">register</button>
|
<button id="account-action" class="filled large" data-l10n-id="signup-register-button">register</button>
|
||||||
</form>
|
</form>
|
||||||
<button type="button" id="account-back" class="link">back</button>
|
<button type="button" id="account-back" class="link" data-l10n-id="action-back">back</button>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<main id="app" hidden>
|
<main id="app" hidden>
|
||||||
<nav class="rail" aria-label="folders">
|
<nav class="rail" aria-label="folders">
|
||||||
<button id="write" class="compose"><svg class="icon"><use href="#i-compose"/></svg>compose</button>
|
<button id="write" class="compose"><svg class="icon"><use href="#i-compose"/></svg><span data-l10n-id="mail-write-button">compose</span></button>
|
||||||
<div id="folders">
|
<div id="folders">
|
||||||
<button data-folder="inbox" aria-label="inbox" class="active" aria-current="true">
|
<button data-folder="inbox" class="active" aria-current="true" data-l10n-aria-label="folder-inbox">
|
||||||
<svg class="icon"><use href="#i-inbox"/></svg><span class="label">inbox</span>
|
<svg class="icon"><use href="#i-inbox"/></svg><span class="label" data-l10n-id="folder-inbox">inbox</span>
|
||||||
<span id="count-inbox" class="count"></span>
|
<span id="count-inbox" class="count"></span>
|
||||||
</button>
|
</button>
|
||||||
<button data-folder="requests" aria-label="requests">
|
<button data-folder="requests" data-l10n-aria-label="folder-requests">
|
||||||
<svg class="icon"><use href="#i-inbox"/></svg><span class="label">requests</span>
|
<svg class="icon"><use href="#i-inbox"/></svg><span class="label" data-l10n-id="folder-requests">requests</span>
|
||||||
<span id="count-requests" class="count"></span>
|
<span id="count-requests" class="count"></span>
|
||||||
</button>
|
</button>
|
||||||
<button data-folder="sent" aria-label="sent"><svg class="icon"><use href="#i-sent"/></svg><span class="label">sent</span></button>
|
<button data-folder="sent" data-l10n-aria-label="folder-sent"><svg class="icon"><use href="#i-sent"/></svg><span class="label" data-l10n-id="folder-sent">sent</span></button>
|
||||||
<button data-folder="contacts" aria-label="contacts"><svg class="icon"><use href="#i-contacts"/></svg><span class="label">contacts</span></button>
|
<button data-folder="contacts" data-l10n-aria-label="contacts-caption"><svg class="icon"><use href="#i-contacts"/></svg><span class="label" data-l10n-id="contacts-caption">contacts</span></button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="listpane">
|
<div class="listpane">
|
||||||
<div id="list-toolbar" class="toolbar">
|
<div class="toolbar">
|
||||||
<div id="mail-tools" class="tools">
|
<div id="mail-tools" class="tools">
|
||||||
<button id="fetch" class="primary"><svg class="icon"><use href="#i-refresh"/></svg><span class="label">fetch</span></button>
|
<button id="fetch" class="primary"><svg class="icon"><use href="#i-refresh"/></svg><span class="label" data-l10n-id="mail-fetch-button">fetch</span></button>
|
||||||
<span id="list-status" class="list-status"></span>
|
<span id="list-status" class="list-status"></span>
|
||||||
</div>
|
</div>
|
||||||
<form id="import-form" class="tools" hidden>
|
<form id="import-form" class="tools" hidden>
|
||||||
<input name="uri" placeholder="smol://alice@example.org/…" aria-label="smol:// address to import"
|
<input name="uri" placeholder="smol://alice@example.org/…" data-l10n-placeholder="contacts-import-placeholder"
|
||||||
spellcheck="false" required>
|
data-l10n-aria-label="contacts-import-placeholder" spellcheck="false" required>
|
||||||
<button class="primary">import</button>
|
<button class="primary" data-l10n-id="contacts-import-button">import</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<section id="list" class="list" aria-label="messages"></section>
|
<section id="list" class="list" aria-label="messages"></section>
|
||||||
</div>
|
</div>
|
||||||
<article id="view" class="view" aria-label="message"><p class="placeholder">select a message</p></article>
|
<article id="view" class="view" aria-label="message"><p class="placeholder" data-l10n-id="mail-empty-title">select a message</p></article>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<dialog id="compose">
|
<dialog id="compose">
|
||||||
<form id="compose-form" method="dialog">
|
<form id="compose-form" method="dialog">
|
||||||
<input name="to" required spellcheck="false" placeholder="to: alice@example.org or smol://…">
|
<input name="to" required spellcheck="false" placeholder="to: alice@example.org or smol://…">
|
||||||
<input name="subject" spellcheck="false" placeholder="subject">
|
<input name="subject" spellcheck="false" data-l10n-placeholder="compose-subject-label" placeholder="subject">
|
||||||
<textarea name="body" rows="12" placeholder="message"></textarea>
|
<textarea name="body" rows="12" data-l10n-placeholder="compose-body-placeholder" placeholder="message"></textarea>
|
||||||
<div id="compose-unpinned" class="seed-card" hidden>
|
<div id="compose-unpinned" class="seed-card" hidden>
|
||||||
<h4>unpinned server</h4>
|
<h4 id="compose-unpinned-title"></h4>
|
||||||
<p class="hint">
|
<p id="compose-unpinned-body" class="hint"></p>
|
||||||
<strong id="compose-unpinned-host"></strong> is not pinned, and "require a pinned
|
|
||||||
server" refuses to trust a key fetched from it for a new recipient. Fetch its key,
|
|
||||||
compare the fingerprint against what the operator published through a trusted
|
|
||||||
channel, then pin it to send.
|
|
||||||
</p>
|
|
||||||
<div id="compose-unpinned-key-area" hidden></div>
|
<div id="compose-unpinned-key-area" hidden></div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="button" id="compose-unpinned-fetch">Fetch their server's key</button>
|
<button type="button" id="compose-unpinned-fetch" data-l10n-id="compose-pin-button-fetch">Fetch their server's key</button>
|
||||||
<button type="button" id="compose-unpinned-pin" class="filled" hidden>Pin and send</button>
|
<button type="button" id="compose-unpinned-pin" class="filled" hidden data-l10n-id="compose-pin-button-send">Pin and send</button>
|
||||||
</div>
|
</div>
|
||||||
<span id="compose-unpinned-error" class="error"></span>
|
<span id="compose-unpinned-error" class="error"></span>
|
||||||
</div>
|
</div>
|
||||||
<footer>
|
<footer>
|
||||||
<label class="anon"><input type="checkbox" name="anonymous"> anonymous — omit my address</label>
|
<label class="anon"><input type="checkbox" name="anonymous"> <span data-l10n-id="compose-anonymous-label">anonymous — omit my address</span></label>
|
||||||
<span id="compose-error" class="error"></span>
|
<span id="compose-error" class="error"></span>
|
||||||
<button type="button" id="compose-cancel">cancel</button>
|
<button type="button" id="compose-cancel" data-l10n-id="action-cancel">cancel</button>
|
||||||
<button id="send" class="filled" value="send">send</button>
|
<button id="send" class="filled" value="send" data-l10n-id="compose-send-button">send</button>
|
||||||
</footer>
|
</footer>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
@ -172,86 +169,88 @@
|
||||||
all four at once was the overwhelming part. -->
|
all four at once was the overwhelming part. -->
|
||||||
<dialog id="settings">
|
<dialog id="settings">
|
||||||
<header class="dialog-head">
|
<header class="dialog-head">
|
||||||
<h2>settings</h2>
|
<h2 data-l10n-id="settings-nav-label">settings</h2>
|
||||||
<button type="button" id="settings-close" class="icon-only" aria-label="close settings"
|
<button type="button" id="settings-close" class="icon-only" data-l10n-aria-label="settings-close-aria-label"
|
||||||
title="close"><svg class="icon"><use href="#i-close"/></svg></button>
|
data-l10n-title="settings-close-tooltip"><svg class="icon"><use href="#i-close"/></svg></button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="settings-body">
|
<div class="settings-body">
|
||||||
<nav id="settings-tabs" class="settings-tabs" aria-label="settings sections">
|
<nav id="settings-tabs" class="settings-tabs" aria-label="settings sections">
|
||||||
<button type="button" data-tab="identity" class="active" aria-current="true">identity</button>
|
<button type="button" data-tab="identity" class="active" aria-current="true"
|
||||||
<button type="button" data-tab="server">server</button>
|
data-l10n-id="settings-tab-identity">identity</button>
|
||||||
<button type="button" data-tab="backup">backup</button>
|
<button type="button" data-tab="server" data-l10n-id="settings-tab-server">server</button>
|
||||||
<button type="button" data-tab="advanced">advanced</button>
|
<button type="button" data-tab="backup" data-l10n-id="settings-tab-backup">backup</button>
|
||||||
|
<button type="button" data-tab="advanced" data-l10n-id="settings-tab-advanced">advanced</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div id="settings-panels" class="settings-panels">
|
<div id="settings-panels" class="settings-panels">
|
||||||
<section class="panel" data-panel="identity">
|
<section class="panel" data-panel="identity">
|
||||||
<div id="identity-fields"></div>
|
<div id="identity-fields"></div>
|
||||||
|
|
||||||
<h3>recovery secret</h3>
|
<h3 data-l10n-id="settings-recovery-secret-title">recovery secret</h3>
|
||||||
<div id="identity-secret"></div>
|
<div id="identity-secret"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel" data-panel="server" hidden>
|
<section class="panel" data-panel="server" hidden>
|
||||||
<h3>connection</h3>
|
<h3 data-l10n-id="server-connection-title">connection</h3>
|
||||||
<div id="server-connection"></div>
|
<div id="server-connection"></div>
|
||||||
|
|
||||||
<h3>fetching</h3>
|
<h3 data-l10n-id="server-fetching-title">fetching</h3>
|
||||||
<label class="check-row">
|
<label class="check-row">
|
||||||
<input type="checkbox" id="leave-on-server">
|
<input type="checkbox" id="leave-on-server">
|
||||||
<span>
|
<span>
|
||||||
<span class="check-title">Leave mail on server</span>
|
<span class="check-title" data-l10n-id="server-leave-mail-title">Leave mail on server</span>
|
||||||
<span class="hint">Keep a copy on the server after fetching, so another client can fetch
|
<span class="hint" data-l10n-id="server-leave-mail-subtitle">Keep a copy on the server after
|
||||||
it too. When this is enabled, deleting a message removes it from this client and the
|
fetching, so another client can fetch it too. When this is enabled, deleting a message
|
||||||
server.</span>
|
removes it from this client and the server.</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel" data-panel="backup" hidden>
|
<section class="panel" data-panel="backup" hidden>
|
||||||
<p class="hint">One file holding your mail, contacts and server info for moving to another
|
<p class="hint" data-l10n-id="backup-page-description">One file holding your mail, contacts and
|
||||||
client, or for keeping what the server no longer has. It is sealed to this identity and
|
server info for moving to another client, or for keeping what the server no longer has. It
|
||||||
deliberately leaves the secret itself out.</p>
|
is sealed to this identity and deliberately leaves the secret itself out.</p>
|
||||||
<div class="action-row">
|
<div class="action-row">
|
||||||
<button type="button" id="export-data" class="filled">Export</button>
|
<button type="button" id="export-data" class="filled" data-l10n-id="backup-export-title">Export</button>
|
||||||
<span class="hint">Write a backup file</span>
|
<span class="hint" data-l10n-id="backup-export-subtitle">Write a backup file</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="action-row">
|
<div class="action-row">
|
||||||
<button type="button" id="import-data-trigger" class="outline">Import</button>
|
<button type="button" id="import-data-trigger" class="outline" data-l10n-id="backup-import-title">Import</button>
|
||||||
<span class="hint">Restore from a backup file</span>
|
<span class="hint" data-l10n-id="backup-import-subtitle">Restore from a backup file</span>
|
||||||
<input id="import-data" type="file" accept="application/json" hidden>
|
<input id="import-data" type="file" accept="application/json" hidden>
|
||||||
</div>
|
</div>
|
||||||
<span id="backup-error" class="error"></span>
|
<span id="backup-error" class="error"></span>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel" data-panel="advanced" hidden>
|
<section class="panel" data-panel="advanced" hidden>
|
||||||
<h3>trust</h3>
|
<h3 data-l10n-id="advanced-trust-section-title">trust</h3>
|
||||||
<label class="check-row">
|
<label class="check-row">
|
||||||
<input type="checkbox" id="require-pinned-server">
|
<input type="checkbox" id="require-pinned-server">
|
||||||
<span>
|
<span>
|
||||||
<span class="check-title">Require a pinned server</span>
|
<span class="check-title" data-l10n-id="advanced-strict-pinning-title">Require a pinned server</span>
|
||||||
<span class="hint">Refuse to look up a new recipient's key from a server you haven't
|
<span class="hint" data-l10n-id="advanced-strict-pinning-subtitle">Refuse to look up a new
|
||||||
pinned. An address that already carries a key, or a recipient you already know, is
|
recipient's key from a server you haven't pinned. An address that already carries a key,
|
||||||
unaffected — this only applies to a new, hand-typed address.</span>
|
or a recipient you already know, is unaffected — this only applies to a new, hand-typed
|
||||||
|
address.</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<h3>rotations</h3>
|
<h3 data-l10n-id="advanced-rotations-title">rotations</h3>
|
||||||
<p id="rotations-description" class="hint">Issues a fresh key from your secret with a signed
|
<p id="rotations-description" class="hint" data-l10n-id="advanced-rotations-description">Issues
|
||||||
certificate proving it replaces this one. Contacts accept the change from that chain, and
|
a fresh key from your secret with a signed certificate proving it replaces this one. Contacts
|
||||||
the old key stays readable. Rotation is not revocation.</p>
|
accept the change from that chain, and the old key stays readable. Rotation is not revocation.</p>
|
||||||
<div id="rotations-count"></div>
|
<div id="rotations-count"></div>
|
||||||
<button type="button" id="rotate-identity" class="outline">Rotate identity</button>
|
<button type="button" id="rotate-identity" class="outline" data-l10n-id="advanced-rotate-button">Rotate identity</button>
|
||||||
<span id="rotate-error" class="error"></span>
|
<span id="rotate-error" class="error"></span>
|
||||||
|
|
||||||
<h3 class="danger-head">log out</h3>
|
<h3 class="danger-head" data-l10n-id="advanced-logout-title">log out</h3>
|
||||||
<p id="logout-description" class="hint">Removes this identity from this client entirely: the
|
<p id="logout-description" class="hint" data-l10n-id="advanced-logout-description">Removes this
|
||||||
secret, pins, contacts and every cached message. Without a backup of the secret there is no
|
identity from this client entirely: the secret, pins, contacts and every cached message.
|
||||||
way back in. Mail already fetched here may have no other copy anywhere, unless "leave mail on
|
Without a backup of the secret there is no way back in. Mail already fetched here may have no
|
||||||
server" was on. Whatever is still on the server comes back on the first fetch after logging
|
other copy anywhere, unless "leave mail on server" was on. Whatever is still on the server
|
||||||
back in.</p>
|
comes back on the first fetch after logging back in.</p>
|
||||||
<button type="button" id="logout" class="danger">Log out and delete all data</button>
|
<button type="button" id="logout" class="danger" data-l10n-id="advanced-logout-button">Log out and delete all data</button>
|
||||||
<span id="logout-error" class="error"></span>
|
<span id="logout-error" class="error"></span>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -264,7 +263,7 @@
|
||||||
<p id="confirm-text"></p>
|
<p id="confirm-text"></p>
|
||||||
</div>
|
</div>
|
||||||
<footer>
|
<footer>
|
||||||
<button type="button" id="confirm-cancel" autofocus>Cancel</button>
|
<button type="button" id="confirm-cancel" autofocus data-l10n-id="action-cancel">Cancel</button>
|
||||||
<button type="button" id="confirm-ok"></button>
|
<button type="button" id="confirm-ok"></button>
|
||||||
</footer>
|
</footer>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
|
||||||
333
web/js/app.js
333
web/js/app.js
|
|
@ -11,9 +11,16 @@ import {
|
||||||
import * as store from "./store.js";
|
import * as store from "./store.js";
|
||||||
import { connectStream } from "./transport.js";
|
import { connectStream } from "./transport.js";
|
||||||
import { PRESET_SERVER } from "./config.js";
|
import { PRESET_SERVER } from "./config.js";
|
||||||
|
import { applyStaticLocale, loadLocale, t } from "./i18n.js";
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
// Blocks the rest of this module's top-level code (including every eager
|
||||||
|
// render below) until the dictionary is in, so nothing races t() before it
|
||||||
|
// has anything to look up.
|
||||||
|
await loadLocale();
|
||||||
|
applyStaticLocale();
|
||||||
|
|
||||||
// Everything is built as nodes rather than markup, so no message content can
|
// Everything is built as nodes rather than markup, so no message content can
|
||||||
// ever reach innerHTML. Null children are dropped, which keeps conditional
|
// ever reach innerHTML. Null children are dropped, which keeps conditional
|
||||||
// pieces inline at the call site.
|
// pieces inline at the call site.
|
||||||
|
|
@ -83,15 +90,16 @@ const fmtRowTime = (seconds) => {
|
||||||
// Flashes its own label rather than a toast — settings' rows need feedback
|
// Flashes its own label rather than a toast — settings' rows need feedback
|
||||||
// that doesn't float away from the row that caused it.
|
// that doesn't float away from the row that caused it.
|
||||||
function copyButton(text, what) {
|
function copyButton(text, what) {
|
||||||
return el("button", { type: "button", className: "copy", title: `copy the ${what}`, textContent: "copy",
|
return el("button", { type: "button", className: "copy", title: t("property-row-copy-tooltip", { field: what }),
|
||||||
|
textContent: t("copy-button-idle"),
|
||||||
onclick: async (event) => {
|
onclick: async (event) => {
|
||||||
const button = event.currentTarget;
|
const button = event.currentTarget;
|
||||||
const resting = button.textContent;
|
const resting = button.textContent;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
button.textContent = "copied";
|
button.textContent = t("copy-button-success");
|
||||||
} catch {
|
} catch {
|
||||||
button.textContent = "failed";
|
button.textContent = t("copy-button-failed");
|
||||||
}
|
}
|
||||||
setTimeout(() => { button.textContent = resting; }, 1500);
|
setTimeout(() => { button.textContent = resting; }, 1500);
|
||||||
} });
|
} });
|
||||||
|
|
@ -111,43 +119,45 @@ function accountAddress() {
|
||||||
// absence of one. The CSS does the rest: color-scheme decides which half of
|
// absence of one. The CSS does the rest: color-scheme decides which half of
|
||||||
// every light-dark() token applies, so nothing here touches a colour.
|
// every light-dark() token applies, so nothing here touches a colour.
|
||||||
const THEMES = [
|
const THEMES = [
|
||||||
{ value: null, icon: "auto", label: "theme: following the system" },
|
{ value: null, icon: "auto", labelId: "theme-auto-label" },
|
||||||
{ value: "light", icon: "sun", label: "theme: light" },
|
{ value: "light", icon: "sun", labelId: "theme-light-label" },
|
||||||
{ value: "dark", icon: "moon", label: "theme: dark" },
|
{ value: "dark", icon: "moon", labelId: "theme-dark-label" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function applyTheme() {
|
function applyTheme() {
|
||||||
const mode = THEMES.find(t => t.value === store.theme()) ?? THEMES[0];
|
const mode = THEMES.find(entry => entry.value === store.theme()) ?? THEMES[0];
|
||||||
if (mode.value) document.documentElement.dataset.theme = mode.value;
|
if (mode.value) document.documentElement.dataset.theme = mode.value;
|
||||||
else delete document.documentElement.dataset.theme;
|
else delete document.documentElement.dataset.theme;
|
||||||
const button = $("theme");
|
const button = $("theme");
|
||||||
button.querySelector("use").setAttribute("href", `#i-${mode.icon}`);
|
button.querySelector("use").setAttribute("href", `#i-${mode.icon}`);
|
||||||
button.title = mode.label;
|
const label = t(mode.labelId);
|
||||||
button.setAttribute("aria-label", mode.label);
|
button.title = label;
|
||||||
|
button.setAttribute("aria-label", label);
|
||||||
}
|
}
|
||||||
|
|
||||||
$("theme").onclick = () => {
|
$("theme").onclick = () => {
|
||||||
const current = THEMES.findIndex(t => t.value === store.theme());
|
const current = THEMES.findIndex(entry => entry.value === store.theme());
|
||||||
store.setTheme(THEMES[(current + 1) % THEMES.length].value);
|
store.setTheme(THEMES[(current + 1) % THEMES.length].value);
|
||||||
applyTheme();
|
applyTheme();
|
||||||
status(THEMES.find(t => t.value === store.theme()).label);
|
status(t(THEMES.find(entry => entry.value === store.theme()).labelId));
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- connection --------------------------------------------------------------
|
// --- connection --------------------------------------------------------------
|
||||||
|
|
||||||
// §4: registration and fetching demand a pinned key; sending to a recipient
|
// Every caller that needs its own account's server reachable here already
|
||||||
// whose key we already hold tolerates an unpinned server.
|
// checked for one before calling (see e.g. fetch's and rotateIdentity's own
|
||||||
async function connect(addr, { requirePin }) {
|
// `!addr` guards) — and that host is always pinned by the time it exists,
|
||||||
|
// since onboarding pins before it ever binds an account. A correspondent's
|
||||||
|
// server, by contrast, legitimately proceeds unpinned (the UNVERIFIED
|
||||||
|
// warning below is the only consequence); there is no live case left where
|
||||||
|
// this needs to refuse a connection outright, so it no longer tries to.
|
||||||
|
async function connect(addr) {
|
||||||
const pinned = store.serverPin(addr.host);
|
const pinned = store.serverPin(addr.host);
|
||||||
if (requirePin && !pinned)
|
|
||||||
throw new SmolError(`no pinned key for ${addr.host}. Obtain it from the operator through a ` +
|
|
||||||
`trusted channel, then pin it under settings → server.`);
|
|
||||||
const timeoutMs = store.timeoutSeconds() * 1000;
|
const timeoutMs = store.timeoutSeconds() * 1000;
|
||||||
const stream = await connectStream(addr.host, addr.port, timeoutMs);
|
const stream = await connectStream(addr.host, addr.port, timeoutMs);
|
||||||
const opened = await openSession(stream, addr.host, pinned, timeoutMs);
|
const opened = await openSession(stream, addr.host, pinned, timeoutMs);
|
||||||
if (!pinned)
|
if (!pinned)
|
||||||
status(`${addr.host} is not pinned; its key is ${b32encode(opened.serverStatic)}.\n` +
|
status(t("connect-unpinned-warning", { host: addr.host, key: b32encode(opened.serverStatic) }), true);
|
||||||
`Lookups from this session are UNVERIFIED.`, true);
|
|
||||||
return opened;
|
return opened;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -214,7 +224,6 @@ $("create-identity").onclick = () => {
|
||||||
$("show-restore").onclick = () => showSetupStage("seed-restore");
|
$("show-restore").onclick = () => showSetupStage("seed-restore");
|
||||||
|
|
||||||
$("seed-saved").onclick = () => {
|
$("seed-saved").onclick = () => {
|
||||||
store.setMasterSaved(true);
|
|
||||||
enterServerSetup();
|
enterServerSetup();
|
||||||
};
|
};
|
||||||
$("seed-back").onclick = () => discardSetupIdentity();
|
$("seed-back").onclick = () => discardSetupIdentity();
|
||||||
|
|
@ -228,7 +237,7 @@ function parseSeedText(text) {
|
||||||
const bytes = b32decode(text);
|
const bytes = b32decode(text);
|
||||||
if (bytes.length === 32) return bytes;
|
if (bytes.length === 32) return bytes;
|
||||||
} catch { /* fall through to the error below */ }
|
} catch { /* fall through to the error below */ }
|
||||||
throw new SmolError("not a valid secret — paste the 64 hex characters or the base32 form");
|
throw new SmolError(t("seed-wrong-length"));
|
||||||
}
|
}
|
||||||
|
|
||||||
$("restore-form").onsubmit = (event) => {
|
$("restore-form").onsubmit = (event) => {
|
||||||
|
|
@ -272,12 +281,11 @@ function renderServerScreen() {
|
||||||
area.replaceChildren();
|
area.replaceChildren();
|
||||||
if (key) {
|
if (key) {
|
||||||
area.append(
|
area.append(
|
||||||
field("fingerprint", fingerprint(key)),
|
field(t("detail-fingerprint-label"), fingerprint(key)),
|
||||||
field("key", b32encode(key)),
|
field(t("detail-key-label"), b32encode(key)),
|
||||||
el("p", { className: "hint", textContent: "If the server operator published a key or " +
|
el("p", { className: "hint", textContent: t("trust-fingerprint-hint") }));
|
||||||
"fingerprint, make sure it matches before trusting it." }));
|
|
||||||
}
|
}
|
||||||
$("server-action").textContent = key ? "continue" : "fetch the server's key";
|
$("server-action").textContent = key ? t("signup-continue-button") : t("signup-fetch-key-button");
|
||||||
$("server-error").textContent = "";
|
$("server-error").textContent = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -334,7 +342,7 @@ function setAccountMode(mode) {
|
||||||
for (const button of document.querySelectorAll("#account-mode button"))
|
for (const button of document.querySelectorAll("#account-mode button"))
|
||||||
button.classList.toggle("active", button.dataset.mode === mode);
|
button.classList.toggle("active", button.dataset.mode === mode);
|
||||||
$("account-token-label").hidden = mode !== "new";
|
$("account-token-label").hidden = mode !== "new";
|
||||||
$("account-action").textContent = mode === "new" ? "register" : "restore access";
|
$("account-action").textContent = mode === "new" ? t("signup-register-button") : t("signup-recall-button");
|
||||||
$("account-error").textContent = "";
|
$("account-error").textContent = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -361,10 +369,10 @@ $("account-form").onsubmit = async (event) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setAccountBusy(true);
|
setAccountBusy(true);
|
||||||
button.textContent = accountMode === "new" ? "registering…" : "restoring…";
|
button.textContent = accountMode === "new" ? t("status-busy-registering") : t("status-busy-restoring");
|
||||||
try {
|
try {
|
||||||
if (accountMode === "new") {
|
if (accountMode === "new") {
|
||||||
const opened = await connect(addr, { requirePin: true });
|
const opened = await connect(addr);
|
||||||
try {
|
try {
|
||||||
await registerOp(opened.session, opened.serverStatic,
|
await registerOp(opened.session, opened.serverStatic,
|
||||||
{ username: addr.user, identity: me, token: $("account-token").value.trim() });
|
{ username: addr.user, identity: me, token: $("account-token").value.trim() });
|
||||||
|
|
@ -372,11 +380,13 @@ $("account-form").onsubmit = async (event) => {
|
||||||
opened.session.stream.close();
|
opened.session.stream.close();
|
||||||
}
|
}
|
||||||
store.setAccount(addr);
|
store.setAccount(addr);
|
||||||
|
showApp();
|
||||||
|
status(t("status-registered-note"));
|
||||||
} else {
|
} else {
|
||||||
await recallAndDiscoverRotation(store.master(), addr.short);
|
await recallAndDiscoverRotation(store.master(), addr.short);
|
||||||
|
showApp();
|
||||||
|
status(t("status-restored-note", { index: store.rotations() }));
|
||||||
}
|
}
|
||||||
showApp();
|
|
||||||
status(`welcome, ${addr.short}`);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
$("account-error").textContent = err.message;
|
$("account-error").textContent = err.message;
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -393,7 +403,7 @@ function renderAccount() {
|
||||||
const me = store.identity();
|
const me = store.identity();
|
||||||
const account = store.account();
|
const account = store.account();
|
||||||
$("account").textContent = me
|
$("account").textContent = me
|
||||||
? (account ? `${account.user}@${account.host}` : "not registered")
|
? (account ? `${account.user}@${account.host}` : t("account-not-registered-value"))
|
||||||
: "";
|
: "";
|
||||||
$("avatar").textContent = account ? account.user[0].toUpperCase() : "·";
|
$("avatar").textContent = account ? account.user[0].toUpperCase() : "·";
|
||||||
}
|
}
|
||||||
|
|
@ -428,8 +438,8 @@ for (const button of $("folders").querySelectorAll("button"))
|
||||||
function resetView() {
|
function resetView() {
|
||||||
$("app").classList.remove("reading");
|
$("app").classList.remove("reading");
|
||||||
$("view").replaceChildren(placeholder(folder === "contacts"
|
$("view").replaceChildren(placeholder(folder === "contacts"
|
||||||
? "contacts and their keys are listed alongside"
|
? t("mail-contacts-reader-hint")
|
||||||
: "select a message"));
|
: t("mail-empty-title")));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Counted from ids and read markers alone, independent of the visible folder —
|
// Counted from ids and read markers alone, independent of the visible folder —
|
||||||
|
|
@ -447,16 +457,17 @@ async function renderFolder() {
|
||||||
$("mail-tools").hidden = contacts;
|
$("mail-tools").hidden = contacts;
|
||||||
$("import-form").hidden = !contacts;
|
$("import-form").hidden = !contacts;
|
||||||
// The filter never reaches past the open folder, so the field says which one.
|
// The filter never reaches past the open folder, so the field says which one.
|
||||||
$("search").placeholder = `search ${folder}`;
|
const searchLabel = t("mail-search-placeholder", { folder });
|
||||||
$("search").setAttribute("aria-label", `search ${folder}`);
|
$("search").placeholder = searchLabel;
|
||||||
|
$("search").setAttribute("aria-label", searchLabel);
|
||||||
if (contacts) return renderContacts(list);
|
if (contacts) return renderContacts(list);
|
||||||
const token = ++renderToken;
|
const token = ++renderToken;
|
||||||
const rows = await store.listMessages(folder); // newest first
|
const rows = await store.listMessages(folder); // newest first
|
||||||
if (token !== renderToken) return;
|
if (token !== renderToken) return;
|
||||||
list.replaceChildren();
|
list.replaceChildren();
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
list.append(placeholder(folder === "inbox" ? "no mail — fetch"
|
list.append(placeholder(folder === "inbox" ? t("mail-inbox-empty-title")
|
||||||
: folder === "requests" ? "no unsolicited mail" : "nothing sent"));
|
: folder === "requests" ? t("mail-requests-empty-title") : t("mail-sent-empty-title")));
|
||||||
return void updateListStatus();
|
return void updateListStatus();
|
||||||
}
|
}
|
||||||
for (let offset = 0; offset < rows.length; offset += BATCH) {
|
for (let offset = 0; offset < rows.length; offset += BATCH) {
|
||||||
|
|
@ -482,11 +493,11 @@ function messageRow(row) {
|
||||||
const who = el("div", { className: "who",
|
const who = el("div", { className: "who",
|
||||||
textContent: folder === "sent" ? row.recipient : (opened.fromShort ?? "?") });
|
textContent: folder === "sent" ? row.recipient : (opened.fromShort ?? "?") });
|
||||||
if (inbound && !opened.error && !opened.knownSender)
|
if (inbound && !opened.error && !opened.knownSender)
|
||||||
who.append(el("span", { className: "dot", title: "sender key not bound to an address" }));
|
who.append(el("span", { className: "dot", title: t("mail-sender-unbound-tooltip") }));
|
||||||
item.append(who,
|
item.append(who,
|
||||||
el("div", { className: "mid" },
|
el("div", { className: "mid" },
|
||||||
el("span", { className: "subject", textContent: opened.error
|
el("span", { className: "subject", textContent: opened.error
|
||||||
? `<unreadable: ${opened.error}>` : (opened.subject || "(no subject)") }),
|
? t("mail-row-unreadable-error", { error: opened.error }) : (opened.subject || t("mail-no-subject")) }),
|
||||||
el("span", { className: "snippet", textContent: opened.error ? "" : ` — ${opened.snippet}` })),
|
el("span", { className: "snippet", textContent: opened.error ? "" : ` — ${opened.snippet}` })),
|
||||||
el("div", { className: "when",
|
el("div", { className: "when",
|
||||||
textContent: fmtRowTime(folder === "sent" ? row.sentAt : (opened.time ?? row.receivedAt)) }));
|
textContent: fmtRowTime(folder === "sent" ? row.sentAt : (opened.time ?? row.receivedAt)) }));
|
||||||
|
|
@ -514,8 +525,9 @@ function updateListStatus() {
|
||||||
const items = [...$("list").querySelectorAll(".item")];
|
const items = [...$("list").querySelectorAll(".item")];
|
||||||
const shown = items.filter(item => !item.hidden).length;
|
const shown = items.filter(item => !item.hidden).length;
|
||||||
$("list-status").textContent = !items.length ? ""
|
$("list-status").textContent = !items.length ? ""
|
||||||
: shown === items.length ? `${items.length} message${items.length === 1 ? "" : "s"}`
|
: shown === items.length
|
||||||
: `${shown} of ${items.length}`;
|
? t(items.length === 1 ? "mail-list-count-one" : "mail-list-count-other", { count: items.length })
|
||||||
|
: t("mail-list-shown-of-total", { shown, total: items.length });
|
||||||
}
|
}
|
||||||
|
|
||||||
$("search").oninput = () => {
|
$("search").oninput = () => {
|
||||||
|
|
@ -595,7 +607,7 @@ async function openMessage(id) {
|
||||||
// Under the narrow breakpoint the reader covers the list, so it needs a way back.
|
// Under the narrow breakpoint the reader covers the list, so it needs a way back.
|
||||||
$("app").classList.add("reading");
|
$("app").classList.add("reading");
|
||||||
const back = el("button", { type: "button", className: "back",
|
const back = el("button", { type: "button", className: "back",
|
||||||
onclick: () => { $("app").classList.remove("reading"); } }, icon("back"), "messages");
|
onclick: () => { $("app").classList.remove("reading"); } }, icon("back"), t("mail-back-to-list-button"));
|
||||||
if (opened.error) return void view.replaceChildren(back, placeholder(opened.error, true));
|
if (opened.error) return void view.replaceChildren(back, placeholder(opened.error, true));
|
||||||
|
|
||||||
// "accept" and "in-reply-to" are protocol machinery (§5.5, §5.8), not content.
|
// "accept" and "in-reply-to" are protocol machinery (§5.5, §5.8), not content.
|
||||||
|
|
@ -603,10 +615,10 @@ async function openMessage(id) {
|
||||||
const actions = el("div", { className: "actions" });
|
const actions = el("div", { className: "actions" });
|
||||||
view.replaceChildren(...[
|
view.replaceChildren(...[
|
||||||
back,
|
back,
|
||||||
el("h2", { textContent: opened.subject || "(no subject)" }),
|
el("h2", { textContent: opened.subject || t("mail-no-subject") }),
|
||||||
el("div", { className: "meta", textContent: folder === "sent"
|
el("div", { className: "meta", textContent: folder === "sent"
|
||||||
? `to: ${row.recipient}\nid: ${row.id}\nsent: ${fmtTime(row.sentAt)}`
|
? t("mail-meta-sent", { recipient: row.recipient, id: row.id, time: fmtTime(row.sentAt) })
|
||||||
: `from: ${opened.from}\nid: ${row.id}\ndate: ${fmtTime(opened.time)}` }),
|
: t("mail-meta-received", { from: opened.from, id: row.id, time: fmtTime(opened.time) }) }),
|
||||||
trustRow(opened),
|
trustRow(opened),
|
||||||
fields.length ? el("div", { className: "fields",
|
fields.length ? el("div", { className: "fields",
|
||||||
textContent: fields.map(([k, v]) => `${k}: ${v}`).join("\n") }) : null,
|
textContent: fields.map(([k, v]) => `${k}: ${v}`).join("\n") }) : null,
|
||||||
|
|
@ -619,39 +631,37 @@ async function openMessage(id) {
|
||||||
actions.append(el("button", { type: "button", onclick: async () => {
|
actions.append(el("button", { type: "button", onclick: async () => {
|
||||||
try {
|
try {
|
||||||
await deleteMessage(folder, row);
|
await deleteMessage(folder, row);
|
||||||
status("message deleted");
|
status(t("status-deleted-note"));
|
||||||
selectedId = null;
|
selectedId = null;
|
||||||
resetView();
|
resetView();
|
||||||
renderFolder();
|
renderFolder();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
status(err.message, true); // the local copy is kept if a needed server delete failed
|
status(err.message, true); // the local copy is kept if a needed server delete failed
|
||||||
}
|
}
|
||||||
} }, "delete"));
|
} }, t("action-delete")));
|
||||||
|
|
||||||
if (folder !== "inbox" && folder !== "requests") return;
|
if (folder !== "inbox" && folder !== "requests") return;
|
||||||
|
|
||||||
if (folder === "requests" && opened.knownSender) {
|
if (folder === "requests" && opened.knownSender) {
|
||||||
const known = store.addressForKey(opened.sender);
|
const known = store.addressForKey(opened.sender);
|
||||||
if (!store.accepted(known)?.active) {
|
if (!store.accepted(known)?.active) {
|
||||||
actions.before(placeholder(
|
actions.before(placeholder(t("mail-request-unaccepted-hint", { address: known })));
|
||||||
`unsolicited mail from ${known}, not yet accepted into your main tier`));
|
|
||||||
actions.append(el("button", { type: "button", onclick: () => acceptContact(known) },
|
actions.append(el("button", { type: "button", onclick: () => acceptContact(known) },
|
||||||
"accept — future mail lands in your main tier"));
|
t("mail-accept-button")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opened.knownSender) {
|
if (opened.knownSender) {
|
||||||
actions.append(el("button", { type: "button", onclick: () => startReply(row) },
|
actions.append(el("button", { type: "button", onclick: () => startReply(row) },
|
||||||
icon("reply"), "reply"));
|
icon("reply"), t("action-reply")));
|
||||||
} else if (opened.replyAddress) {
|
} else if (opened.replyAddress) {
|
||||||
// The sender signed their own smol:// address into the payload, so binding
|
// The sender signed their own smol:// address into the payload, so binding
|
||||||
// it is one click. A pinned conflicting key is refused (§8).
|
// it is one click. A pinned conflicting key is refused (§8).
|
||||||
actions.before(placeholder(
|
actions.before(placeholder(t("mail-signed-reply-hint", { address: opened.replyAddress.short })));
|
||||||
`this message carries the sender's signed address: ${opened.replyAddress.short}`));
|
actions.append(el("button", { type: "button", textContent: t("mail-save-sender-reply-button"), onclick: () => {
|
||||||
actions.append(el("button", { type: "button", textContent: "save sender & reply", onclick: () => {
|
|
||||||
try {
|
try {
|
||||||
bindReplyAddress(opened.replyAddress, opened.sender);
|
bindReplyAddress(opened.replyAddress, opened.sender);
|
||||||
status(`${opened.replyAddress.short} saved for this sender (verified key)`);
|
status(t("contacts-sender-saved-note-verified", { address: opened.replyAddress.short }));
|
||||||
renderFolder();
|
renderFolder();
|
||||||
openMessage(row.id);
|
openMessage(row.id);
|
||||||
startReply(row);
|
startReply(row);
|
||||||
|
|
@ -663,7 +673,7 @@ async function openMessage(id) {
|
||||||
// The wire gives only a signed key; a reply needs the sender's address,
|
// The wire gives only a signed key; a reply needs the sender's address,
|
||||||
// which the user supplies once and we bind to that exact key.
|
// which the user supplies once and we bind to that exact key.
|
||||||
const input = el("input", { placeholder: "alice@example.org or smol://…", spellcheck: false });
|
const input = el("input", { placeholder: "alice@example.org or smol://…", spellcheck: false });
|
||||||
const submit = el("button", { textContent: "name sender & reply" });
|
const submit = el("button", { textContent: t("mail-name-sender-button") });
|
||||||
const form = el("form", { className: "name-sender" }, input, submit);
|
const form = el("form", { className: "name-sender" }, input, submit);
|
||||||
form.onsubmit = async (event) => {
|
form.onsubmit = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
@ -678,8 +688,7 @@ async function openMessage(id) {
|
||||||
submit.disabled = false;
|
submit.disabled = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
actions.before(placeholder("The wire carries no sender name, only this signed key. " +
|
actions.before(placeholder(t("mail-unknown-sender-hint")));
|
||||||
"To reply, give the sender's address — it is checked against this key:"));
|
|
||||||
actions.append(form);
|
actions.append(form);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -694,8 +703,8 @@ async function deleteMessage(folder, row) {
|
||||||
if (folder !== "sent" && row.keptOnServer) {
|
if (folder !== "sent" && row.keptOnServer) {
|
||||||
const me = store.identity();
|
const me = store.identity();
|
||||||
const addr = accountAddress();
|
const addr = accountAddress();
|
||||||
if (!me || !addr) throw new SmolError("not registered; cannot reach the server to delete this message");
|
if (!me || !addr) throw new SmolError(t("mail-delete-not-registered-error"));
|
||||||
const opened = await connect(addr, { requirePin: true });
|
const opened = await connect(addr);
|
||||||
try {
|
try {
|
||||||
await authenticate(opened.session, opened.handshakeHash, addr.user, me);
|
await authenticate(opened.session, opened.handshakeHash, addr.user, me);
|
||||||
await deleteOp(opened.session, [unhex(row.id)]);
|
await deleteOp(opened.session, [unhex(row.id)]);
|
||||||
|
|
@ -712,10 +721,10 @@ async function deleteMessage(folder, row) {
|
||||||
function trustRow(opened) {
|
function trustRow(opened) {
|
||||||
if (folder === "sent")
|
if (folder === "sent")
|
||||||
return el("div", { className: "trust" },
|
return el("div", { className: "trust" },
|
||||||
el("span", { className: "badge", textContent: "your own sealed copy" }));
|
el("span", { className: "badge", textContent: t("mail-trust-own-copy") }));
|
||||||
const chip = opened.verifiedSender ? ["badge ok", "verified sender key"]
|
const chip = opened.verifiedSender ? ["badge ok", t("mail-trust-verified-sender")]
|
||||||
: opened.knownSender ? ["badge", "key pinned on first use"]
|
: opened.knownSender ? ["badge", t("mail-trust-tofu-sender")]
|
||||||
: ["badge alert", "key bound to no address"];
|
: ["badge alert", t("mail-trust-unbound-sender")];
|
||||||
return el("div", { className: "trust" },
|
return el("div", { className: "trust" },
|
||||||
el("span", { className: chip[0], textContent: chip[1] }),
|
el("span", { className: chip[0], textContent: chip[1] }),
|
||||||
el("div", { className: "keyblock" },
|
el("div", { className: "keyblock" },
|
||||||
|
|
@ -724,7 +733,7 @@ function trustRow(opened) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const replySubject = (subject) =>
|
const replySubject = (subject) =>
|
||||||
!subject ? "" : subject.startsWith("Re:") ? subject : `Re: ${subject}`;
|
!subject ? "" : subject.startsWith("Re:") ? subject : t("reply-subject-prefix", { subject });
|
||||||
|
|
||||||
// One path for all three ways a reply can start, so the Re: rule lives once.
|
// One path for all three ways a reply can start, so the Re: rule lives once.
|
||||||
function startReply(row) {
|
function startReply(row) {
|
||||||
|
|
@ -746,14 +755,13 @@ async function saveContactChecked(address, keyBytes, verified) {
|
||||||
const existing = store.contact(address);
|
const existing = store.contact(address);
|
||||||
if (existing && !timingSafeEqual(existing.key, keyBytes)) {
|
if (existing && !timingSafeEqual(existing.key, keyBytes)) {
|
||||||
const ok = await confirmDialog({
|
const ok = await confirmDialog({
|
||||||
title: `${address} already has a different key`,
|
title: t("key-change-dialog-title"),
|
||||||
body: `existing fingerprint: ${fingerprint(existing.key)}\n` +
|
body: t("key-change-dialog-body", { address,
|
||||||
`new fingerprint: ${fingerprint(keyBytes)}\n\n` +
|
known_fingerprint: fingerprint(existing.key), offered_fingerprint: fingerprint(keyBytes) }),
|
||||||
`Overwriting trusts the new key for this address from now on.`,
|
confirmLabel: t("key-change-dialog-replace-button"),
|
||||||
confirmLabel: "use the new key",
|
|
||||||
danger: true,
|
danger: true,
|
||||||
});
|
});
|
||||||
if (!ok) throw new SmolError(`kept the existing key for ${address}`);
|
if (!ok) throw new SmolError(t("contacts-key-kept-note", { address }));
|
||||||
}
|
}
|
||||||
store.saveContact(address, keyBytes, verified);
|
store.saveContact(address, keyBytes, verified);
|
||||||
}
|
}
|
||||||
|
|
@ -767,7 +775,7 @@ async function nameSender(text, senderKey) {
|
||||||
const addr = parseAddress(text.trim());
|
const addr = parseAddress(text.trim());
|
||||||
let key = addr.identity, verified = true;
|
let key = addr.identity, verified = true;
|
||||||
if (!key) {
|
if (!key) {
|
||||||
const opened = await connect(addr, { requirePin: false });
|
const opened = await connect(addr);
|
||||||
try {
|
try {
|
||||||
({ identity: key } = await resolveOp(opened.session, addr.user));
|
({ identity: key } = await resolveOp(opened.session, addr.user));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -776,9 +784,10 @@ async function nameSender(text, senderKey) {
|
||||||
verified = false; // trust on first use, as with any RESOLVE
|
verified = false; // trust on first use, as with any RESOLVE
|
||||||
}
|
}
|
||||||
if (!timingSafeEqual(key, senderKey))
|
if (!timingSafeEqual(key, senderKey))
|
||||||
throw new SmolError("that address carries a different key than this message's sender");
|
throw new SmolError(t("contacts-sender-key-mismatch-error"));
|
||||||
await saveContactChecked(addr.short, senderKey, verified);
|
await saveContactChecked(addr.short, senderKey, verified);
|
||||||
status(`${addr.short} saved for this sender${verified ? " (verified key)" : ""}`);
|
status(t(verified ? "contacts-sender-saved-note-verified" : "contacts-sender-saved-note-unverified",
|
||||||
|
{ address: addr.short }));
|
||||||
renderFolder();
|
renderFolder();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -791,8 +800,7 @@ async function nameSender(text, senderKey) {
|
||||||
function bindReplyAddress(addr, senderKey) {
|
function bindReplyAddress(addr, senderKey) {
|
||||||
const existing = store.contact(addr.short);
|
const existing = store.contact(addr.short);
|
||||||
if (existing && !timingSafeEqual(existing.key, senderKey))
|
if (existing && !timingSafeEqual(existing.key, senderKey))
|
||||||
throw new SmolError(`${addr.short} is already known with a different key — ` +
|
throw new SmolError(t("contacts-reply-key-conflict-error", { address: addr.short }));
|
||||||
`verify out of band before replying`);
|
|
||||||
store.saveContact(addr.short, senderKey, true);
|
store.saveContact(addr.short, senderKey, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -838,7 +846,8 @@ function showUnpinnedPrompt(addr) {
|
||||||
try { live = parseAddress(compose.querySelector("form").to.value).short; } catch { live = null; }
|
try { live = parseAddress(compose.querySelector("form").to.value).short; } catch { live = null; }
|
||||||
if (live !== addr.short) return;
|
if (live !== addr.short) return;
|
||||||
unpinnedPrompt = { addressShort: addr.short, host: addr.host, port: addr.port, fetchedKey: null };
|
unpinnedPrompt = { addressShort: addr.short, host: addr.host, port: addr.port, fetchedKey: null };
|
||||||
$("compose-unpinned-host").textContent = addr.host;
|
$("compose-unpinned-title").textContent = t("compose-pin-title", { host: addr.host });
|
||||||
|
$("compose-unpinned-body").textContent = t("compose-pin-body", { address: addr.short });
|
||||||
$("compose-unpinned").hidden = false;
|
$("compose-unpinned").hidden = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -898,10 +907,9 @@ $("compose-unpinned-fetch").onclick = async () => {
|
||||||
unpinnedPrompt.fetchedKey = opened.serverStatic;
|
unpinnedPrompt.fetchedKey = opened.serverStatic;
|
||||||
$("compose-unpinned-key-area").hidden = false;
|
$("compose-unpinned-key-area").hidden = false;
|
||||||
$("compose-unpinned-key-area").replaceChildren(
|
$("compose-unpinned-key-area").replaceChildren(
|
||||||
field("fingerprint", fingerprint(opened.serverStatic)),
|
field(t("detail-fingerprint-label"), fingerprint(opened.serverStatic)),
|
||||||
field("key", b32encode(opened.serverStatic)),
|
field(t("detail-key-label"), b32encode(opened.serverStatic)),
|
||||||
el("p", { className: "hint", textContent:
|
el("p", { className: "hint", textContent: t("trust-fingerprint-hint") }));
|
||||||
"Compare this against what the operator published through a trusted channel." }));
|
|
||||||
$("compose-unpinned-fetch").hidden = true;
|
$("compose-unpinned-fetch").hidden = true;
|
||||||
$("compose-unpinned-pin").hidden = false;
|
$("compose-unpinned-pin").hidden = false;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -930,7 +938,7 @@ $("compose-unpinned-pin").onclick = async () => {
|
||||||
async function sendMail(toText, subject, body, replyTo, anonymous) {
|
async function sendMail(toText, subject, body, replyTo, anonymous) {
|
||||||
const me = store.identity();
|
const me = store.identity();
|
||||||
const master = store.master();
|
const master = store.master();
|
||||||
if (!me || !master) throw new SmolError("no identity yet");
|
if (!me || !master) throw new SmolError(t("identity-missing-error"));
|
||||||
const addr = parseAddress(toText);
|
const addr = parseAddress(toText);
|
||||||
const recipient = await resolveRecipient(addr);
|
const recipient = await resolveRecipient(addr);
|
||||||
const account = accountAddress();
|
const account = accountAddress();
|
||||||
|
|
@ -948,7 +956,7 @@ async function sendMail(toText, subject, body, replyTo, anonymous) {
|
||||||
// §5.8: our token for their mailbox, if they have given us one.
|
// §5.8: our token for their mailbox, if they have given us one.
|
||||||
const held = store.tokenFrom(addr.short);
|
const held = store.tokenFrom(addr.short);
|
||||||
const mac = held ? acceptMac(held, messageId(envelope)) : null;
|
const mac = held ? acceptMac(held, messageId(envelope)) : null;
|
||||||
const opened = await connect(addr, { requirePin: false });
|
const opened = await connect(addr);
|
||||||
try {
|
try {
|
||||||
await sendOp(opened.session, envelope, mac);
|
await sendOp(opened.session, envelope, mac);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -961,7 +969,7 @@ async function sendMail(toText, subject, body, replyTo, anonymous) {
|
||||||
envelope: seal(me, me.publicKey, bodyBytes),
|
envelope: seal(me, me.publicKey, bodyBytes),
|
||||||
sentAt: Math.floor(Date.now() / 1000),
|
sentAt: Math.floor(Date.now() / 1000),
|
||||||
});
|
});
|
||||||
status(`sent to ${addr.short}${mac ? " (accepted)" : ""}`);
|
status(t(mac ? "mail-sent-note-accepted" : "mail-sent-note", { address: addr.short }));
|
||||||
if (folder === "sent") renderFolder();
|
if (folder === "sent") renderFolder();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -990,7 +998,7 @@ async function resolveRecipient(addr) {
|
||||||
if (known) return known.key;
|
if (known) return known.key;
|
||||||
if (store.requirePinnedServer() && !store.serverPin(addr.host))
|
if (store.requirePinnedServer() && !store.serverPin(addr.host))
|
||||||
throw new UnpinnedServerError(addr.host);
|
throw new UnpinnedServerError(addr.host);
|
||||||
const opened = await connect(addr, { requirePin: false });
|
const opened = await connect(addr);
|
||||||
let current;
|
let current;
|
||||||
try {
|
try {
|
||||||
({ identity: current } = await resolveOp(opened.session, addr.user));
|
({ identity: current } = await resolveOp(opened.session, addr.user));
|
||||||
|
|
@ -998,7 +1006,7 @@ async function resolveRecipient(addr) {
|
||||||
opened.session.stream.close();
|
opened.session.stream.close();
|
||||||
}
|
}
|
||||||
store.saveContact(addr.short, current, false);
|
store.saveContact(addr.short, current, false);
|
||||||
status(`${addr.short} pinned (trust on first use${opened.pinned ? "" : ", UNVERIFIED server"})`);
|
status(t(opened.pinned ? "contacts-resolved-note" : "contacts-resolved-note-unverified", { address: addr.short }));
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1038,13 +1046,13 @@ $("fetch").onclick = async () => {
|
||||||
const me = store.identity();
|
const me = store.identity();
|
||||||
const master = store.master();
|
const master = store.master();
|
||||||
const addr = accountAddress();
|
const addr = accountAddress();
|
||||||
if (!me || !master) return status("no identity yet", true);
|
if (!me || !master) return status(t("identity-missing-error"), true);
|
||||||
if (!addr) return status("not registered yet — claim an address in settings", true);
|
if (!addr) return status(t("account-not-registered-fetch-warning"), true);
|
||||||
const button = $("fetch"), label = button.querySelector(".label");
|
const button = $("fetch"), label = button.querySelector(".label");
|
||||||
const resting = label.textContent;
|
const resting = label.textContent;
|
||||||
button.disabled = true; // one session at a time; the icon spins while it is
|
button.disabled = true; // one session at a time; the icon spins while it is
|
||||||
label.textContent = "fetching…";
|
label.textContent = t("status-busy-fetching");
|
||||||
status("fetching…");
|
status(t("status-busy-fetching"));
|
||||||
let stored = 0, verified = 0;
|
let stored = 0, verified = 0;
|
||||||
const rejected = [];
|
const rejected = [];
|
||||||
// §10: acknowledging (deleting) is the default; "leave mail on server" pages
|
// §10: acknowledging (deleting) is the default; "leave mail on server" pages
|
||||||
|
|
@ -1054,7 +1062,7 @@ $("fetch").onclick = async () => {
|
||||||
const leaveOnServer = store.leaveOnServer();
|
const leaveOnServer = store.leaveOnServer();
|
||||||
let [afterTime, afterId] = store.cursor();
|
let [afterTime, afterId] = store.cursor();
|
||||||
try {
|
try {
|
||||||
const opened = await connect(addr, { requirePin: true });
|
const opened = await connect(addr);
|
||||||
try {
|
try {
|
||||||
const { sync, tokens } = store.tokenSet(master);
|
const { sync, tokens } = store.tokenSet(master);
|
||||||
await authenticate(opened.session, opened.handshakeHash, addr.user, me, { sync, tokens });
|
await authenticate(opened.session, opened.handshakeHash, addr.user, me, { sync, tokens });
|
||||||
|
|
@ -1068,7 +1076,7 @@ $("fetch").onclick = async () => {
|
||||||
let unsealed;
|
let unsealed;
|
||||||
try {
|
try {
|
||||||
if (!timingSafeEqual(messageId(record.envelope), record.id))
|
if (!timingSafeEqual(messageId(record.envelope), record.id))
|
||||||
throw new SmolError("id does not match the envelope");
|
throw new SmolError(t("mail-envelope-id-mismatch-error"));
|
||||||
unsealed = unseal(store.identities(), record.envelope);
|
unsealed = unseal(store.identities(), record.envelope);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Left on the server rather than destroyed, so a client-side bug
|
// Left on the server rather than destroyed, so a client-side bug
|
||||||
|
|
@ -1110,10 +1118,11 @@ $("fetch").onclick = async () => {
|
||||||
// Everything acknowledged is deleted, so the next fetch starts fresh; a
|
// Everything acknowledged is deleted, so the next fetch starts fresh; a
|
||||||
// record left on the server (rejected above) simply resurfaces then.
|
// record left on the server (rejected above) simply resurfaces then.
|
||||||
if (!leaveOnServer) store.setCursor(0, new Uint8Array(ID_LEN));
|
if (!leaveOnServer) store.setCursor(0, new Uint8Array(ID_LEN));
|
||||||
status([`${stored} new, ${rejected.length} rejected`,
|
status([t("mail-fetch-summary-counts", { stored, rejected: rejected.length }),
|
||||||
verified ? `${verified} sender${verified === 1 ? "" : "s"} verified from a signed Reply-To` : null,
|
verified ? t(verified === 1 ? "mail-fetch-summary-verified-one" : "mail-fetch-summary-verified-other",
|
||||||
|
{ verified }) : null,
|
||||||
...rejected.map(line => ` ${line}`),
|
...rejected.map(line => ` ${line}`),
|
||||||
rejected.length ? "left on the server" : null].filter(Boolean).join("\n"),
|
rejected.length ? t("mail-fetch-summary-left-on-server") : null].filter(Boolean).join("\n"),
|
||||||
rejected.length > 0);
|
rejected.length > 0);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
status(err.message, true);
|
status(err.message, true);
|
||||||
|
|
@ -1141,16 +1150,16 @@ function renderContacts(list) {
|
||||||
el("div", { className: "mid" },
|
el("div", { className: "mid" },
|
||||||
el("div", { textContent: fingerprint(b32decode(key)) }),
|
el("div", { textContent: fingerprint(b32decode(key)) }),
|
||||||
el("span", { className: verified ? "badge ok" : "badge",
|
el("span", { className: verified ? "badge ok" : "badge",
|
||||||
textContent: verified ? "verified key" : "tofu" }),
|
textContent: t(verified ? "contact-state-verified" : "contact-state-tofu") }),
|
||||||
accepted?.active ? el("span", { className: "badge ok", textContent: "accepted" }) : null,
|
accepted?.active ? el("span", { className: "badge ok", textContent: t("contact-state-accepted") }) : null,
|
||||||
history.length ? el("span", { className: "badge",
|
history.length ? el("span", { className: "badge",
|
||||||
textContent: `${history.length} previous` }) : null));
|
textContent: t("contacts-history-badge", { count: history.length }) }) : null));
|
||||||
item.dataset.search =
|
item.dataset.search =
|
||||||
`${address} ${key} ${fingerprint(b32decode(key))} ${history.map(h => h.key).join(" ")}`.toLowerCase();
|
`${address} ${key} ${fingerprint(b32decode(key))} ${history.map(h => h.key).join(" ")}`.toLowerCase();
|
||||||
return item;
|
return item;
|
||||||
}));
|
}));
|
||||||
if (!contacts.length)
|
if (!contacts.length)
|
||||||
list.append(placeholder("no contacts yet — import a smol:// address above, or write to someone"));
|
list.append(placeholder(t("contacts-import-empty-hint")));
|
||||||
}
|
}
|
||||||
|
|
||||||
// The full key, the address to hand out, and any key this one replaced — the
|
// The full key, the address to hand out, and any key this one replaced — the
|
||||||
|
|
@ -1172,11 +1181,11 @@ function openContact(address) {
|
||||||
} finally {
|
} finally {
|
||||||
event.target.disabled = false;
|
event.target.disabled = false;
|
||||||
}
|
}
|
||||||
} }, icon("refresh"), "re-resolve");
|
} }, icon("refresh"), t("contacts-refresh-button"));
|
||||||
|
|
||||||
const isAccepted = Boolean(store.accepted(address)?.active);
|
const isAccepted = Boolean(store.accepted(address)?.active);
|
||||||
const tokenButton = el("button", { type: "button",
|
const tokenButton = el("button", { type: "button",
|
||||||
textContent: isAccepted ? "block" : "accept",
|
textContent: t(isAccepted ? "action-block" : "action-accept"),
|
||||||
onclick: async (event) => {
|
onclick: async (event) => {
|
||||||
event.target.disabled = true;
|
event.target.disabled = true;
|
||||||
try {
|
try {
|
||||||
|
|
@ -1188,32 +1197,29 @@ function openContact(address) {
|
||||||
|
|
||||||
view.replaceChildren(...[
|
view.replaceChildren(...[
|
||||||
el("button", { type: "button", className: "back",
|
el("button", { type: "button", className: "back",
|
||||||
onclick: () => { $("app").classList.remove("reading"); } }, icon("back"), "contacts"),
|
onclick: () => { $("app").classList.remove("reading"); } }, icon("back"), t("contacts-caption")),
|
||||||
el("h2", { textContent: address }),
|
el("h2", { textContent: address }),
|
||||||
el("div", { className: "trust" },
|
el("div", { className: "trust" },
|
||||||
el("span", { className: known.verified ? "badge ok" : "badge",
|
el("span", { className: known.verified ? "badge ok" : "badge",
|
||||||
textContent: known.verified ? "verified key" : "key pinned on first use" })),
|
textContent: t(known.verified ? "contact-state-verified" : "contact-state-tofu") })),
|
||||||
field("fingerprint", fingerprint(known.key)),
|
field(t("detail-fingerprint-label"), fingerprint(known.key)),
|
||||||
field("public key", b32encode(known.key)),
|
field(t("detail-key-label"), b32encode(known.key)),
|
||||||
field("address", addr.uri(known.key), false, copyButton(addr.uri(known.key), "address")),
|
field(t("detail-address-label"), addr.uri(known.key), false, copyButton(addr.uri(known.key), "address")),
|
||||||
field("server", addr.host, false,
|
field(t("detail-server-label"), addr.host, false,
|
||||||
el("span", { className: store.serverPin(addr.host) ? "badge ok" : "badge",
|
el("span", { className: store.serverPin(addr.host) ? "badge ok" : "badge",
|
||||||
textContent: store.serverPin(addr.host) ? "pinned" : "not pinned" })),
|
textContent: t(store.serverPin(addr.host) ? "contacts-server-pinned-badge" : "contacts-server-not-pinned-badge") })),
|
||||||
el("div", { className: "seed-card" },
|
el("div", { className: "seed-card" },
|
||||||
el("h4", { textContent: "accept token" }),
|
el("h4", { textContent: t("contacts-accept-token-title") }),
|
||||||
el("p", { className: "hint", textContent: isAccepted
|
el("p", { className: "hint", textContent: t(isAccepted ? "contacts-accepted-hint" : "contacts-unaccepted-hint") }),
|
||||||
? "This contact's mail lands in your main tier."
|
|
||||||
: "This contact's mail lands in requests until accepted." }),
|
|
||||||
el("div", { className: "actions" }, tokenButton)),
|
el("div", { className: "actions" }, tokenButton)),
|
||||||
known.history.length ? el("section", { className: "history" },
|
known.history.length ? el("section", { className: "history" },
|
||||||
el("h3", { textContent: `previous keys (${known.history.length})` }),
|
el("h3", { textContent: t("contacts-history-title", { count: known.history.length }) }),
|
||||||
el("p", { className: "hint", textContent:
|
el("p", { className: "hint", textContent: t("contacts-history-hint") }),
|
||||||
"Replaced by a signed rotation chain. Mail signed with these was sent " +
|
|
||||||
"before the change; rotation is not revocation, so a stolen key can still rotate onward." }),
|
|
||||||
...known.history.map(({ key, until }) => el("div", { className: "keyblock" },
|
...known.history.map(({ key, until }) => el("div", { className: "keyblock" },
|
||||||
el("div", { textContent: fingerprint(b32decode(key)) }),
|
el("div", { textContent: fingerprint(b32decode(key)) }),
|
||||||
el("div", { className: "full", textContent: key }),
|
el("div", { className: "full", textContent: key }),
|
||||||
el("div", { className: "replaced", textContent: `replaced ${fmtTime(Math.floor(until / 1000))}` }))))
|
el("div", { className: "replaced",
|
||||||
|
textContent: t("contacts-history-replaced", { time: fmtTime(Math.floor(until / 1000)) }) }))))
|
||||||
: null,
|
: null,
|
||||||
el("div", { className: "actions" }, refresh),
|
el("div", { className: "actions" }, refresh),
|
||||||
].filter(node => node != null));
|
].filter(node => node != null));
|
||||||
|
|
@ -1224,9 +1230,9 @@ function openContact(address) {
|
||||||
async function refreshContact(address) {
|
async function refreshContact(address) {
|
||||||
try {
|
try {
|
||||||
const addr = parseAddress(address);
|
const addr = parseAddress(address);
|
||||||
if (addr.identity) throw new SmolError("that address already carries a key; use import instead");
|
if (addr.identity) throw new SmolError(t("contacts-refresh-embedded-key-error"));
|
||||||
const known = store.contact(addr.short);
|
const known = store.contact(addr.short);
|
||||||
const opened = await connect(addr, { requirePin: false });
|
const opened = await connect(addr);
|
||||||
let current, chain;
|
let current, chain;
|
||||||
try {
|
try {
|
||||||
({ identity: current, chain } = await resolveOp(opened.session, addr.user));
|
({ identity: current, chain } = await resolveOp(opened.session, addr.user));
|
||||||
|
|
@ -1235,15 +1241,14 @@ async function refreshContact(address) {
|
||||||
}
|
}
|
||||||
if (!known) {
|
if (!known) {
|
||||||
store.saveContact(addr.short, current, false);
|
store.saveContact(addr.short, current, false);
|
||||||
status(`${addr.short} pinned (trust on first use${opened.pinned ? "" : ", UNVERIFIED server"})`);
|
status(t(opened.pinned ? "contacts-resolved-note" : "contacts-resolved-note-unverified", { address: addr.short }));
|
||||||
} else if (timingSafeEqual(known.key, current)) {
|
} else if (timingSafeEqual(known.key, current)) {
|
||||||
status(`${addr.short}: key unchanged`);
|
status(t("contacts-refresh-unchanged-note", { address: addr.short }));
|
||||||
} else if (walkChain(addr.user, known.key, current, chain)) {
|
} else if (walkChain(addr.user, known.key, current, chain)) {
|
||||||
store.saveContact(addr.short, current, known.verified);
|
store.saveContact(addr.short, current, known.verified);
|
||||||
status(`${addr.short} rotated its key; a signed chain confirms it.\nnow ${b32encode(current)}`, true);
|
status(t("contacts-refresh-rotated-note", { address: addr.short, key: b32encode(current) }), true);
|
||||||
} else {
|
} else {
|
||||||
status(`${addr.short} presents a different key with no valid rotation chain.\n` +
|
status(t("contacts-refresh-no-chain-warning", { address: addr.short }), true);
|
||||||
`Verify out of band, then import the new smol:// address.`, true);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
status(err.message, true);
|
status(err.message, true);
|
||||||
|
|
@ -1260,12 +1265,12 @@ async function pushTokens() {
|
||||||
const me = store.identity();
|
const me = store.identity();
|
||||||
const master = store.master();
|
const master = store.master();
|
||||||
const addr = accountAddress();
|
const addr = accountAddress();
|
||||||
if (!me || !master) throw new SmolError("no identity yet");
|
if (!me || !master) throw new SmolError(t("identity-missing-error"));
|
||||||
if (!addr) {
|
if (!addr) {
|
||||||
status("not registered; the set will be pushed with your first fetch", true);
|
status(t("account-not-registered-tokens-note"), true);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
const opened = await connect(addr, { requirePin: true });
|
const opened = await connect(addr);
|
||||||
try {
|
try {
|
||||||
const { sync, tokens } = store.tokenSet(master);
|
const { sync, tokens } = store.tokenSet(master);
|
||||||
return await authenticate(opened.session, opened.handshakeHash, addr.user, me, { sync, tokens });
|
return await authenticate(opened.session, opened.handshakeHash, addr.user, me, { sync, tokens });
|
||||||
|
|
@ -1279,11 +1284,11 @@ async function pushTokens() {
|
||||||
async function acceptContact(address) {
|
async function acceptContact(address) {
|
||||||
try {
|
try {
|
||||||
const key = store.contact(address)?.key;
|
const key = store.contact(address)?.key;
|
||||||
if (!key) throw new SmolError(`no key for ${address} yet`);
|
if (!key) throw new SmolError(t("contacts-no-key-error", { address }));
|
||||||
store.accept(address, key);
|
store.accept(address, key);
|
||||||
store.setSyncOk(true);
|
store.setSyncOk(true);
|
||||||
const held = await pushTokens();
|
const held = await pushTokens();
|
||||||
status(`${address} accepted; server now holds ${held} accept token(s)`);
|
status(t(held === 1 ? "contacts-accepted-note-one" : "contacts-accepted-note-other", { address, held }));
|
||||||
renderFolder();
|
renderFolder();
|
||||||
if (folder === "contacts" && selectedId === address) openContact(address);
|
if (folder === "contacts" && selectedId === address) openContact(address);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -1297,7 +1302,7 @@ async function blockContact(address) {
|
||||||
try {
|
try {
|
||||||
store.block(address);
|
store.block(address);
|
||||||
const held = await pushTokens();
|
const held = await pushTokens();
|
||||||
status(`${address} blocked; server now holds ${held} accept token(s)`);
|
status(t(held === 1 ? "contacts-blocked-note-one" : "contacts-blocked-note-other", { address, held }));
|
||||||
renderFolder();
|
renderFolder();
|
||||||
if (folder === "contacts" && selectedId === address) openContact(address);
|
if (folder === "contacts" && selectedId === address) openContact(address);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -1377,13 +1382,14 @@ function renderServerPanel() {
|
||||||
const host = account?.host ?? null;
|
const host = account?.host ?? null;
|
||||||
const key = host ? store.serverPin(host) : null;
|
const key = host ? store.serverPin(host) : null;
|
||||||
$("server-connection").replaceChildren(
|
$("server-connection").replaceChildren(
|
||||||
field("Host", host ?? "not registered yet", !host, host ? copyButton(host, "host") : null),
|
field(t("settings-field-host"), host ?? t("settings-not-registered-value"), !host,
|
||||||
field("Public Key", key ? b32encode(key) : "not pinned yet", !key,
|
host ? copyButton(host, "host") : null),
|
||||||
|
field(t("settings-field-public-key"), key ? b32encode(key) : t("settings-public-key-not-pinned"), !key,
|
||||||
key ? copyButton(b32encode(key), "public key") : null),
|
key ? copyButton(b32encode(key), "public key") : null),
|
||||||
el("div", { className: "spin-row" },
|
el("div", { className: "spin-row" },
|
||||||
el("div", { className: "spin-text" },
|
el("div", { className: "spin-text" },
|
||||||
el("span", { className: "check-title", textContent: "Timeout" }),
|
el("span", { className: "check-title", textContent: t("server-timeout-title") }),
|
||||||
el("span", { className: "hint", textContent: "Seconds before a network call gives up" })),
|
el("span", { className: "hint", textContent: t("server-timeout-subtitle") })),
|
||||||
el("input", { type: "number", min: 1, max: 600, step: 1, value: store.timeoutSeconds(),
|
el("input", { type: "number", min: 1, max: 600, step: 1, value: store.timeoutSeconds(),
|
||||||
onchange: (event) => {
|
onchange: (event) => {
|
||||||
store.setTimeoutSeconds(event.target.value);
|
store.setTimeoutSeconds(event.target.value);
|
||||||
|
|
@ -1400,14 +1406,15 @@ function renderIdentityPanel() {
|
||||||
secret.replaceChildren();
|
secret.replaceChildren();
|
||||||
if (!me) return;
|
if (!me) return;
|
||||||
|
|
||||||
const address = account ? `${account.user}@${account.host}` : "not registered yet";
|
const address = account ? `${account.user}@${account.host}` : t("settings-not-registered-value");
|
||||||
const key = b32encode(me.publicKey);
|
const key = b32encode(me.publicKey);
|
||||||
const uri = account ? accountAddress().uri(me.publicKey) : "claim an address to get one";
|
const uri = account ? accountAddress().uri(me.publicKey) : t("settings-uri-claim-hint");
|
||||||
fields.append(
|
fields.append(
|
||||||
field("Address", address, !account, account ? copyButton(address, "address") : null),
|
field(t("settings-field-address"), address, !account, account ? copyButton(address, "address") : null),
|
||||||
field("Key", key, false, copyButton(key, "key")),
|
field(t("settings-field-key"), key, false, copyButton(key, "key")),
|
||||||
field("Fingerprint", fingerprint(me.publicKey), false, copyButton(fingerprint(me.publicKey), "fingerprint")),
|
field(t("settings-field-fingerprint"), fingerprint(me.publicKey), false,
|
||||||
field("URI", uri, !account, account ? copyButton(uri, "uri") : null));
|
copyButton(fingerprint(me.publicKey), "fingerprint")),
|
||||||
|
field(t("settings-field-uri"), uri, !account, account ? copyButton(uri, "uri") : null));
|
||||||
|
|
||||||
const masterBytes = store.master();
|
const masterBytes = store.master();
|
||||||
if (!masterBytes) return;
|
if (!masterBytes) return;
|
||||||
|
|
@ -1416,22 +1423,22 @@ function renderIdentityPanel() {
|
||||||
const masked = "\u2022".repeat(64);
|
const masked = "\u2022".repeat(64);
|
||||||
const seedText = hex(masterBytes);
|
const seedText = hex(masterBytes);
|
||||||
const seed = el("div", { className: "seed", textContent: masked });
|
const seed = el("div", { className: "seed", textContent: masked });
|
||||||
const reveal = el("button", { type: "button", textContent: "reveal", title: "reveal the recovery secret",
|
const reveal = el("button", { type: "button", textContent: t("settings-secret-reveal-button"),
|
||||||
|
title: t("settings-secret-reveal-tooltip"),
|
||||||
onclick: (event) => {
|
onclick: (event) => {
|
||||||
const showing = seed.textContent !== masked;
|
const showing = seed.textContent !== masked;
|
||||||
seed.textContent = showing ? masked : seedText;
|
seed.textContent = showing ? masked : seedText;
|
||||||
event.currentTarget.textContent = showing ? "reveal" : "hide";
|
event.currentTarget.textContent = t(showing ? "settings-secret-reveal-button" : "settings-secret-hide-button");
|
||||||
event.currentTarget.title = showing ? "reveal the recovery secret" : "hide the recovery secret";
|
event.currentTarget.title = t(showing ? "settings-secret-reveal-tooltip" : "settings-secret-hide-tooltip");
|
||||||
} });
|
} });
|
||||||
|
|
||||||
secret.append(el("div", { className: "seed-card" },
|
secret.append(el("div", { className: "seed-card" },
|
||||||
el("div", { className: "row" }, seed, reveal, copyButton(seedText, "recovery secret")),
|
el("div", { className: "row" }, seed, reveal, copyButton(seedText, "recovery secret")),
|
||||||
el("p", { textContent: "Anyone who holds this can read your mail and write as you, and losing " +
|
el("p", { textContent: t("settings-secret-warning") })));
|
||||||
"it loses the address with it — there is no reset. Keep it in a password manager." })));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAdvancedPanel() {
|
function renderAdvancedPanel() {
|
||||||
$("rotations-count").replaceChildren(field("Completed", String(store.rotations())));
|
$("rotations-count").replaceChildren(field(t("advanced-rotations-completed"), String(store.rotations())));
|
||||||
$("rotate-error").textContent = "";
|
$("rotate-error").textContent = "";
|
||||||
$("logout-error").textContent = "";
|
$("logout-error").textContent = "";
|
||||||
}
|
}
|
||||||
|
|
@ -1445,9 +1452,9 @@ function renderAdvancedPanel() {
|
||||||
function cleanHost(hostText) {
|
function cleanHost(hostText) {
|
||||||
const host = hostText.trim().toLowerCase();
|
const host = hostText.trim().toLowerCase();
|
||||||
if (host.includes("@"))
|
if (host.includes("@"))
|
||||||
throw new SmolError(`"${host}" looks like an address, not a server — the part after the @ goes here`);
|
throw new SmolError(t("signup-host-looks-like-address-error", { host }));
|
||||||
if (host.includes(":"))
|
if (host.includes(":"))
|
||||||
throw new SmolError(`"${host}": a pin is by host only, no port — just the part before the colon`);
|
throw new SmolError(t("signup-host-has-port-error", { host }));
|
||||||
return host;
|
return host;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1456,10 +1463,10 @@ $("import-form").onsubmit = async (event) => {
|
||||||
const form = event.target;
|
const form = event.target;
|
||||||
try {
|
try {
|
||||||
const addr = parseAddress(form.uri.value.trim());
|
const addr = parseAddress(form.uri.value.trim());
|
||||||
if (!addr.identity) throw new SmolError("import needs a smol:// address carrying a key");
|
if (!addr.identity) throw new SmolError(t("contacts-import-needs-key"));
|
||||||
await saveContactChecked(addr.short, addr.identity, true);
|
await saveContactChecked(addr.short, addr.identity, true);
|
||||||
status(`imported ${addr.short} ${b32encode(addr.identity)} (verified)\n` +
|
status(t("contacts-import-detail-note",
|
||||||
`fingerprint: ${fingerprint(addr.identity)}`);
|
{ address: addr.short, key: b32encode(addr.identity), fingerprint: fingerprint(addr.identity) }));
|
||||||
renderFolder();
|
renderFolder();
|
||||||
form.reset();
|
form.reset();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -1473,7 +1480,7 @@ $("import-form").onsubmit = async (event) => {
|
||||||
// guess of index 0. Also binds "account" — onboarding's only path to it now.
|
// guess of index 0. Also binds "account" — onboarding's only path to it now.
|
||||||
async function recallAndDiscoverRotation(masterBytes, text) {
|
async function recallAndDiscoverRotation(masterBytes, text) {
|
||||||
const addr = parseAddress(text.trim());
|
const addr = parseAddress(text.trim());
|
||||||
const opened = await connect(addr, { requirePin: true });
|
const opened = await connect(addr);
|
||||||
let current;
|
let current;
|
||||||
try {
|
try {
|
||||||
({ identity: current } = await resolveOp(opened.session, addr.user));
|
({ identity: current } = await resolveOp(opened.session, addr.user));
|
||||||
|
|
@ -1488,8 +1495,7 @@ async function recallAndDiscoverRotation(masterBytes, text) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (found === null)
|
if (found === null)
|
||||||
throw new SmolError(`the key bound to ${addr.short} is not derived from this master ` +
|
throw new SmolError(t("recall-rotation-not-found-error", { address: addr.short, max: MAX_CHAIN }));
|
||||||
`within ${MAX_CHAIN} rotations`);
|
|
||||||
store.setRotationIndex(found);
|
store.setRotationIndex(found);
|
||||||
store.setAccount(addr);
|
store.setAccount(addr);
|
||||||
return addr;
|
return addr;
|
||||||
|
|
@ -1508,7 +1514,7 @@ $("export-data").onclick = async () => {
|
||||||
const text = JSON.stringify(data);
|
const text = JSON.stringify(data);
|
||||||
if (window.showSaveFilePicker) {
|
if (window.showSaveFilePicker) {
|
||||||
const handle = await window.showSaveFilePicker({ suggestedName: filename,
|
const handle = await window.showSaveFilePicker({ suggestedName: filename,
|
||||||
types: [{ description: "gsmol backup", accept: { "application/json": [".json"] } }] });
|
types: [{ description: t("backup-export-dialog-title"), accept: { "application/json": [".json"] } }] });
|
||||||
const writable = await handle.createWritable();
|
const writable = await handle.createWritable();
|
||||||
await writable.write(text);
|
await writable.write(text);
|
||||||
await writable.close();
|
await writable.close();
|
||||||
|
|
@ -1537,7 +1543,7 @@ $("import-data-trigger").onclick = async () => {
|
||||||
if (!window.showOpenFilePicker) return $("import-data").click();
|
if (!window.showOpenFilePicker) return $("import-data").click();
|
||||||
try {
|
try {
|
||||||
const [handle] = await window.showOpenFilePicker({
|
const [handle] = await window.showOpenFilePicker({
|
||||||
types: [{ description: "gsmol backup", accept: { "application/json": [".json"] } }] });
|
types: [{ description: t("backup-import-dialog-title"), accept: { "application/json": [".json"] } }] });
|
||||||
await importBackupText(await (await handle.getFile()).text());
|
await importBackupText(await (await handle.getFile()).text());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.name !== "AbortError") $("backup-error").textContent = err.message;
|
if (err.name !== "AbortError") $("backup-error").textContent = err.message;
|
||||||
|
|
@ -1570,21 +1576,22 @@ async function rotateIdentity() {
|
||||||
const addr = accountAddress();
|
const addr = accountAddress();
|
||||||
$("rotate-error").textContent = "";
|
$("rotate-error").textContent = "";
|
||||||
if (!me || !master || !addr) {
|
if (!me || !master || !addr) {
|
||||||
$("rotate-error").textContent = "rotate needs a registered account";
|
$("rotate-error").textContent = t("advanced-rotate-needs-account-error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const confirmed = await confirmDialog({
|
const confirmed = await confirmDialog({
|
||||||
title: "Rotate the identity?", body: panelText("rotations-description"), confirmLabel: "Rotate" });
|
title: t("rotate-dialog-title"), body: panelText("rotations-description"),
|
||||||
|
confirmLabel: t("rotate-dialog-confirm-button") });
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
const button = $("rotate-identity");
|
const button = $("rotate-identity");
|
||||||
const resting = button.textContent;
|
const resting = button.textContent;
|
||||||
button.disabled = true; // a round trip to the server takes a moment; say so rather than sit inert
|
button.disabled = true; // a round trip to the server takes a moment; say so rather than sit inert
|
||||||
button.textContent = "rotating…";
|
button.textContent = t("status-busy-rotating");
|
||||||
try {
|
try {
|
||||||
const freshSeed = identitySeed(master, store.rotations() + 1);
|
const freshSeed = identitySeed(master, store.rotations() + 1);
|
||||||
const fresh = identityFromSeed(freshSeed);
|
const fresh = identityFromSeed(freshSeed);
|
||||||
const cert = makeCert(addr.user, me, freshSeed);
|
const cert = makeCert(addr.user, me, freshSeed);
|
||||||
const opened = await connect(addr, { requirePin: true });
|
const opened = await connect(addr);
|
||||||
try {
|
try {
|
||||||
await registerOp(opened.session, opened.serverStatic, { username: addr.user, identity: fresh, cert });
|
await registerOp(opened.session, opened.serverStatic, { username: addr.user, identity: fresh, cert });
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -1608,8 +1615,8 @@ $("rotate-identity").onclick = rotateIdentity;
|
||||||
// Unlike rotate, nothing is kept: this is the master's only local copy, gone.
|
// Unlike rotate, nothing is kept: this is the master's only local copy, gone.
|
||||||
async function logout() {
|
async function logout() {
|
||||||
$("logout-error").textContent = "";
|
$("logout-error").textContent = "";
|
||||||
const confirmed = await confirmDialog({ title: "Log out and delete all data?",
|
const confirmed = await confirmDialog({ title: t("reset-dialog-title"),
|
||||||
body: panelText("logout-description"), confirmLabel: "Log Out", danger: true });
|
body: panelText("logout-description"), confirmLabel: t("reset-dialog-confirm-button"), danger: true });
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
try {
|
try {
|
||||||
await store.clearAll();
|
await store.clearAll();
|
||||||
|
|
|
||||||
31
web/js/i18n.js
Normal file
31
web/js/i18n.js
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
// Strings, loaded once from the flat dictionary scripts/build-locale.mjs
|
||||||
|
// generates from locales/en-US/main.ftl. No Fluent parser ships here — the
|
||||||
|
// build step already resolved the source file's syntax down to plain
|
||||||
|
// `{ $var }` placeholders, which is all this needs to substitute.
|
||||||
|
|
||||||
|
let messages = null;
|
||||||
|
|
||||||
|
export async function loadLocale() {
|
||||||
|
const response = await fetch("locales/en-US.json");
|
||||||
|
messages = await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Falls back to the id itself on a miss, so a typo'd or forgotten call
|
||||||
|
// shows up as visibly wrong text in the UI rather than disappearing.
|
||||||
|
export function t(id, args = {}) {
|
||||||
|
const value = messages?.[id];
|
||||||
|
if (value === undefined) return id;
|
||||||
|
return value.replace(/\{\s*\$(\w+)\s*\}/g, (_, name) => args[name] ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// index.html's static markup keeps its literal English text and gains
|
||||||
|
// data-l10n-* attributes alongside it, so the page still reads correctly
|
||||||
|
// even if this runs late — this pass just overwrites it from the same
|
||||||
|
// English source, proving the wiring for whenever a second locale exists.
|
||||||
|
export function applyStaticLocale(root = document) {
|
||||||
|
for (const el of root.querySelectorAll("[data-l10n-id]")) el.textContent = t(el.dataset.l10nId);
|
||||||
|
for (const el of root.querySelectorAll("[data-l10n-placeholder]")) el.placeholder = t(el.dataset.l10nPlaceholder);
|
||||||
|
for (const el of root.querySelectorAll("[data-l10n-title]")) el.title = t(el.dataset.l10nTitle);
|
||||||
|
for (const el of root.querySelectorAll("[data-l10n-aria-label]"))
|
||||||
|
el.setAttribute("aria-label", t(el.dataset.l10nAriaLabel));
|
||||||
|
}
|
||||||
287
web/locales/en-US.json
Normal file
287
web/locales/en-US.json
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
{
|
||||||
|
"seed-bad-hex": "That's not valid hex. Check for typos or extra characters.",
|
||||||
|
"seed-wrong-length": "A seed is 32 bytes. Check you copied the whole thing.",
|
||||||
|
"file-error": "{ $path }: { $reason }",
|
||||||
|
"key-file-wrong-length": "{ $path }: not a 32-byte secret",
|
||||||
|
"compose-header-colon-missing": "Headers need a colon, like `Key: value`.",
|
||||||
|
"compose-header-reserved-key": "{ $key } is set automatically and can't be changed.",
|
||||||
|
"compose-header-invalid-key": "Keys can be 1 to 64 letters, digits, and dashes.",
|
||||||
|
"compose-header-value-multiline": "Values need to stay on one line.",
|
||||||
|
"compose-header-duplicate-key": "{ $key } is set twice. Remove one of them.",
|
||||||
|
"compose-headers-too-large": "Your headers take up too much space. Remove some and try again.",
|
||||||
|
"compose-headers-too-many": "You have too many headers. Remove some and try again.",
|
||||||
|
"contacts-import-needs-key": "Import needs a smol:// address that includes a key.",
|
||||||
|
"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-result-note-rejected": "{ $total } fetched, { $stored } stored, { $rejected } rejected",
|
||||||
|
"fetch-result-note-cancelled": "{ $total } fetched, { $stored } stored (cancelled)",
|
||||||
|
"fetch-result-note-rejected-cancelled": "{ $total } fetched, { $stored } stored, { $rejected } rejected (cancelled)",
|
||||||
|
"fetch-result-note-plain": "{ $total } fetched, { $stored } stored",
|
||||||
|
"mail-envelope-id-mismatch-error": "id does not match the envelope",
|
||||||
|
"mail-fetch-summary-counts": "{ $stored } new, { $rejected } rejected",
|
||||||
|
"mail-fetch-summary-verified-one": "{ $verified } sender verified from a signed Reply-To",
|
||||||
|
"mail-fetch-summary-verified-other": "{ $verified } senders verified from a signed Reply-To",
|
||||||
|
"mail-fetch-summary-left-on-server": "left on the server",
|
||||||
|
"send-result-note-token": "Sent to { $address } ({ $bytes } bytes), accept token attached",
|
||||||
|
"send-result-note-unverified": "Sent to { $address } ({ $bytes } bytes); new contact learned from an unpinned server (unverified)",
|
||||||
|
"send-result-note-unverified-token": "Sent to { $address } ({ $bytes } bytes), accept token attached; new contact learned from an unpinned server (unverified)",
|
||||||
|
"send-result-note-tofu": "Sent to { $address } ({ $bytes } bytes); new contact learned (trust on first use)",
|
||||||
|
"send-result-note-tofu-token": "Sent to { $address } ({ $bytes } bytes), accept token attached; new contact learned (trust on first use)",
|
||||||
|
"send-result-note-rotated": "Sent to { $address } ({ $bytes } bytes); contact rotated to a new key (chain verified)",
|
||||||
|
"send-result-note-rotated-token": "Sent to { $address } ({ $bytes } bytes), accept token attached; contact rotated to a new key (chain verified)",
|
||||||
|
"send-result-note-plain": "Sent to { $address } ({ $bytes } bytes)",
|
||||||
|
"mail-sent-note": "sent to { $address }",
|
||||||
|
"mail-sent-note-accepted": "sent to { $address } (accepted)",
|
||||||
|
"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",
|
||||||
|
"contacts-no-key-error": "no key for { $address } yet",
|
||||||
|
"contacts-accepted-note-one": "{ $address } accepted; server now holds { $held } accept token",
|
||||||
|
"contacts-accepted-note-other": "{ $address } accepted; server now holds { $held } accept tokens",
|
||||||
|
"contacts-blocked-note-one": "{ $address } blocked; server now holds { $held } accept token",
|
||||||
|
"contacts-blocked-note-other": "{ $address } blocked; server now holds { $held } accept tokens",
|
||||||
|
"status-imported-note": "Imported { $address } (verified)",
|
||||||
|
"status-rotated-note": "Rotated to index { $index }; new key { $key_prefix }…",
|
||||||
|
"backup-written-note": "Backup written to { $path }",
|
||||||
|
"draft-saved-note": "Draft saved",
|
||||||
|
"logged-out-note": "Signed out. Create a new identity or restore a seed.",
|
||||||
|
"identity-created-note": "Identity created. Write the seed down. It's the only secret.",
|
||||||
|
"seed-restored-note": "Seed restored. Bind a username or recall an address.",
|
||||||
|
"signup-enter-host-error": "Enter the server host first.",
|
||||||
|
"signup-enter-user-host-error": "Enter the user and host.",
|
||||||
|
"signup-host-looks-like-address-error": "\"{ $host }\" looks like an address, not a server — the part after the @ goes here",
|
||||||
|
"signup-host-has-port-error": "\"{ $host }\": a pin is by host only, no port — just the part before the colon",
|
||||||
|
"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 isn't in the right format.",
|
||||||
|
"contacts-reply-address-invalid-error": "The sender's reply address isn't in the right format.",
|
||||||
|
"contacts-sender-key-invalid-error": "The sender's key isn't in the right format.",
|
||||||
|
"contacts-accept-no-address-error": "This sender left no address to accept.",
|
||||||
|
"contacts-block-no-address-error": "This sender left no address to block.",
|
||||||
|
"contacts-sender-key-mismatch-error": "that address carries a different key than this message's sender",
|
||||||
|
"contacts-reply-key-conflict-error": "{ $address } is already known with a different key — verify out of band before replying",
|
||||||
|
"contacts-key-kept-note": "kept the existing key for { $address }",
|
||||||
|
"contacts-sender-saved-note-verified": "{ $address } saved for this sender (verified key)",
|
||||||
|
"contacts-sender-saved-note-unverified": "{ $address } saved for this sender",
|
||||||
|
"contacts-import-detail-note": "imported { $address } { $key } (verified)\nfingerprint: { $fingerprint }",
|
||||||
|
"action-cancel": "Cancel",
|
||||||
|
"action-back": "Back",
|
||||||
|
"action-block": "Block",
|
||||||
|
"action-unblock": "Unblock",
|
||||||
|
"action-accept": "Accept",
|
||||||
|
"action-reply": "Reply",
|
||||||
|
"action-delete": "Delete",
|
||||||
|
"trust-fingerprint-hint": "If the server operator published a key or fingerprint, make sure it matches before trusting it.",
|
||||||
|
"identity-missing-error": "no identity yet",
|
||||||
|
"account-not-registered-fetch-warning": "not registered yet — claim an address in settings",
|
||||||
|
"account-not-registered-tokens-note": "not registered; the set will be pushed with your first fetch",
|
||||||
|
"mail-fetch-button": "Fetch",
|
||||||
|
"mail-write-button": "Write",
|
||||||
|
"folder-inbox": "Inbox",
|
||||||
|
"folder-requests": "Requests",
|
||||||
|
"folder-sent": "Sent",
|
||||||
|
"folder-drafts": "Drafts",
|
||||||
|
"settings-nav-label": "Settings",
|
||||||
|
"settings-close-aria-label": "close settings",
|
||||||
|
"settings-close-tooltip": "close",
|
||||||
|
"identity-you-caption": "you",
|
||||||
|
"contacts-caption": "contacts",
|
||||||
|
"contacts-import-placeholder": "Import a smol:// address",
|
||||||
|
"contacts-import-button": "Import",
|
||||||
|
"theme-auto-label": "theme: following the system",
|
||||||
|
"theme-light-label": "theme: light",
|
||||||
|
"theme-dark-label": "theme: dark",
|
||||||
|
"account-not-registered-value": "not registered",
|
||||||
|
"mail-search-placeholder": "search { $folder }",
|
||||||
|
"mail-meta-to": "to",
|
||||||
|
"mail-meta-from": "from",
|
||||||
|
"mail-meta-line-no": "{ $label } { $from } · { $time } UTC · id { $id }…",
|
||||||
|
"mail-meta-line-yes": "{ $label } { $from } · { $time } UTC · id { $id }… · a server copy still exists",
|
||||||
|
"mail-no-subject": "(no subject)",
|
||||||
|
"mail-no-reply-address": "no reply address: can't accept or block",
|
||||||
|
"contacts-empty-hint": "no contacts",
|
||||||
|
"mail-back-to-list-button": "messages",
|
||||||
|
"mail-meta-sent": "to: { $recipient }\nid: { $id }\nsent: { $time }",
|
||||||
|
"mail-meta-received": "from: { $from }\nid: { $id }\ndate: { $time }",
|
||||||
|
"mail-trust-own-copy": "your own sealed copy",
|
||||||
|
"mail-trust-verified-sender": "verified sender key",
|
||||||
|
"mail-trust-tofu-sender": "key pinned on first use",
|
||||||
|
"mail-trust-unbound-sender": "key bound to no address",
|
||||||
|
"mail-sender-unbound-tooltip": "sender key not bound to an address",
|
||||||
|
"mail-signed-reply-hint": "this message carries the sender's signed address: { $address }",
|
||||||
|
"mail-save-sender-reply-button": "save sender & reply",
|
||||||
|
"mail-name-sender-button": "name sender & reply",
|
||||||
|
"mail-unknown-sender-hint": "The wire carries no sender name, only this signed key. To reply, give the sender's address — it is checked against this key:",
|
||||||
|
"mail-delete-not-registered-error": "not registered; cannot reach the server to delete this message",
|
||||||
|
"mail-request-unaccepted-hint": "unsolicited mail from { $address }, not yet accepted into your main tier",
|
||||||
|
"mail-accept-button": "accept — future mail lands in your main tier",
|
||||||
|
"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 haven't 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": "Closing a compose from the sidebar keeps it 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.",
|
||||||
|
"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",
|
||||||
|
"mail-row-unreadable-error": "<unreadable: { $error }>",
|
||||||
|
"mail-contacts-reader-hint": "contacts and their keys are listed alongside",
|
||||||
|
"mail-list-count-one": "{ $count } message",
|
||||||
|
"mail-list-count-other": "{ $count } messages",
|
||||||
|
"mail-list-shown-of-total": "{ $shown } of { $total }",
|
||||||
|
"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",
|
||||||
|
"compose-body-placeholder": "message",
|
||||||
|
"compose-anonymous-label": "anonymous — omit my address",
|
||||||
|
"contact-state-accepted": "accepted",
|
||||||
|
"contact-state-blocked": "blocked",
|
||||||
|
"contact-state-verified": "verified",
|
||||||
|
"contact-state-tofu": "trust on first use",
|
||||||
|
"contact-meta-line-yes": "{ $state } · server pinned",
|
||||||
|
"contact-meta-line-no": "{ $state } · server not pinned",
|
||||||
|
"detail-key-label": "key",
|
||||||
|
"detail-fingerprint-label": "fingerprint",
|
||||||
|
"detail-address-label": "address",
|
||||||
|
"detail-server-label": "server",
|
||||||
|
"contact-server-key-offered": "this server presents",
|
||||||
|
"contacts-server-pinned-badge": "pinned",
|
||||||
|
"contacts-server-not-pinned-badge": "not pinned",
|
||||||
|
"contacts-history-badge": "{ $count } previous",
|
||||||
|
"contacts-import-empty-hint": "no contacts yet — import a smol:// address above, or write to someone",
|
||||||
|
"contacts-refresh-button": "re-resolve",
|
||||||
|
"contacts-accept-token-title": "accept token",
|
||||||
|
"contacts-accepted-hint": "This contact's mail lands in your main tier.",
|
||||||
|
"contacts-unaccepted-hint": "This contact's mail lands in requests until accepted.",
|
||||||
|
"contacts-history-title": "previous keys ({ $count })",
|
||||||
|
"contacts-history-hint": "Replaced by a signed rotation chain. Mail signed with these was sent before the change; rotation is not revocation, so a stolen key can still rotate onward.",
|
||||||
|
"contacts-history-replaced": "replaced { $time }",
|
||||||
|
"settings-tab-identity": "Identity",
|
||||||
|
"settings-tab-server": "Server",
|
||||||
|
"settings-tab-backup": "Backup",
|
||||||
|
"settings-tab-advanced": "Advanced",
|
||||||
|
"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-button": "reveal",
|
||||||
|
"settings-secret-hide-button": "hide",
|
||||||
|
"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-secret-warning": "Anyone who holds this can read your mail and write as you, and losing it loses the address with it — there is no reset. Keep it in a password manager.",
|
||||||
|
"copy-button-idle": "copy",
|
||||||
|
"copy-button-success": "copied",
|
||||||
|
"copy-button-failed": "failed",
|
||||||
|
"settings-not-registered-value": "not registered yet",
|
||||||
|
"settings-uri-claim-hint": "claim an address to get one",
|
||||||
|
"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 you can fetch it again from another app or device. When this is on, deleting a message removes it here and on the server.",
|
||||||
|
"backup-page-description": "One file with your mail, contacts, and server info, for moving to another app or keeping what the server no longer has. It's sealed to this identity and never includes the secret itself.",
|
||||||
|
"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",
|
||||||
|
"advanced-trust-section-title": "trust",
|
||||||
|
"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-rotate-needs-account-error": "rotate needs a registered account",
|
||||||
|
"advanced-first-contact-title": "First contact",
|
||||||
|
"advanced-first-contact-description": "A recipient who isn't 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 that send and offer to pin the server's key first, so you can check it against what the operator published. With this off, the key's taken on trust, and the send is only reported as unverified afterwards.",
|
||||||
|
"advanced-logout-title": "Sign out",
|
||||||
|
"advanced-logout-description": "Removes this identity from this app entirely: the secret, pins, contacts, and every cached message. Without a backup of the secret, you can't get back in. Mail already fetched here may have no other copy anywhere, unless \"Leave mail on server\" was on. Whatever's still on the server comes back on the first fetch after you sign back in.",
|
||||||
|
"advanced-logout-button": "Sign out and delete all data",
|
||||||
|
"onboard-welcome-title": "welcome",
|
||||||
|
"onboard-welcome-description": "Encrypted mail with no account and no password. Your identity is one secret, kept in this browser and nowhere else.",
|
||||||
|
"onboard-tagline": "smolmail: one seed, sealed mail, five operations",
|
||||||
|
"onboard-create-button": "Create a new identity",
|
||||||
|
"onboard-restore-button": "Restore an existing identity from a seed",
|
||||||
|
"onboard-created-title": "Write this seed down",
|
||||||
|
"onboard-created-description": "It's the only secret, and it's shown once.",
|
||||||
|
"onboard-saved-button": "I saved it",
|
||||||
|
"onboard-restore-title": "Restore from a seed",
|
||||||
|
"onboard-restore-description": "Paste the secret you saved. Your keys come back from it — it is all this client needs.",
|
||||||
|
"onboard-seed-entry-title": "Seed: 32 bytes, base32 or hex",
|
||||||
|
"onboard-restore-go-button": "Restore",
|
||||||
|
"signup-server-title": "Set up the server",
|
||||||
|
"signup-host-label": "host",
|
||||||
|
"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 the server requires one)",
|
||||||
|
"signup-register-button": "Register",
|
||||||
|
"signup-recall-button": "Restore access",
|
||||||
|
"compose-pin-title": "{ $host } isn't pinned",
|
||||||
|
"compose-pin-body": "{ $address } isn't a contact yet, so carrier would have to ask this server for its key. A server with no pinned key can answer with one of its own, then read the message.",
|
||||||
|
"compose-pin-button-fetch": "Fetch their server's key",
|
||||||
|
"compose-pin-button-send": "Pin and send",
|
||||||
|
"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": "This removes the only copy, which is sealed and stored on this device.",
|
||||||
|
"delete-dialog-everywhere-button": "Delete everywhere",
|
||||||
|
"rotate-dialog-title": "Rotate the identity?",
|
||||||
|
"rotate-dialog-confirm-button": "Rotate",
|
||||||
|
"reset-dialog-title": "Sign out and delete all data?",
|
||||||
|
"reset-dialog-confirm-button": "Sign Out",
|
||||||
|
"reply-dialog-title": "Carry the sender's fields into the reply?",
|
||||||
|
"reply-dialog-body": "This message has its own headers:\n\n{ $list }\n\nReply 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.\n\nknown { $known_fingerprint }\nnew { $offered_fingerprint }\n\nThe address you were given carries the new key and would replace the one on file. If this contact didn't 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-busy-fetching": "fetching…",
|
||||||
|
"status-busy-refreshing": "refreshing…",
|
||||||
|
"status-busy-registering": "registering…",
|
||||||
|
"status-busy-restoring": "restoring…",
|
||||||
|
"status-busy-rotating": "rotating…",
|
||||||
|
"status-fetching-progress": "fetching… { $count } in inbox",
|
||||||
|
"first-contact-blocked-error": "{ $address } isn't a contact yet, and { $host } isn't 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",
|
||||||
|
"connect-unpinned-warning": "{ $host } is not pinned; its key is { $key }.\nLookups from this session are UNVERIFIED.",
|
||||||
|
"recall-rotation-not-found-error": "the key bound to { $address } is not derived from this master within { $max } rotations",
|
||||||
|
"contacts-refresh-embedded-key-error": "that address already carries a key; use import instead",
|
||||||
|
"contacts-resolved-note": "{ $address } pinned (trust on first use)",
|
||||||
|
"contacts-resolved-note-unverified": "{ $address } pinned (trust on first use, UNVERIFIED server)",
|
||||||
|
"contacts-refresh-unchanged-note": "{ $address }: key unchanged",
|
||||||
|
"contacts-refresh-rotated-note": "{ $address } rotated its key; a signed chain confirms it.\nnow { $key }",
|
||||||
|
"contacts-refresh-no-chain-warning": "{ $address } presents a different key with no valid rotation chain.\nVerify out of band, then import the new smol:// address."
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue