At sign-in the files, contacts and calendar stores each asked every shared
account a question, one account after another: a request apiece before the
reader had opened any of those views.
Files now only works out at sign-in whether it is available. Which shared
accounts hold files is asked when the Files view or the file picker opens,
which the Files view already did on every visit. Shared address books and
calendars are asked for in one request, and the calendar store loads its
calendars, identities and shared calendars side by side.
The main chunk carried everything the mail view might open: the file
preview and its Markdown renderer, the composer and its editor, the contact
editor, the filter and share dialogs, and the calendar, contacts and files
sidebars. Each is now loaded when first shown. The composer is also
fetched when the browser is idle after startup, so the first Compose does
not wait on the network.
Import the notification helpers statically where they already were: the
dynamic imports beside those static ones split nothing.
Takes the flat module count from 66 to 42, continuing what admin/ and
calendar/ started.
lib/mailbox/ archiveDate, emptyFolder, folderMove, labelTree,
mailboxName, mailboxRoute
lib/sieve/ sieve, sieveApply, sieveFolders
lib/input/ keyboard, swipe, touch, listSelection, dropUpload
lib/notify/ notify, webpush, webpushEnable
lib/sw/ swCache, swFacts, staleBuild
lib/text/ html, markdown, text, emlName
FOUR THINGS THE FILENAMES GET WRONG, each checked by reading the file
rather than trusting what it is called:
- appFolder is not a mailbox. It is the `ihasmail` folder in JMAP
*Files*, where the client keeps signature images and synced settings.
It stays flat.
- format holds no formatting of text. It re-exports the date and clock
formatters, so it belongs with dates/datetime, not with text/.
- preview is the file viewer deciding what it can show without
downloading, and source is where to point someone asking for this
instance's AGPL source. Neither is about text.
- notify is not Web Push. It is the tab title, the favicon badge and
the new-mail sound -- in-app notification, which is why it sits with
webpush rather than under sw/ with the service worker's own concerns.
threadScroll stays flat too: it decides where a conversation opens, which
is view state rather than a gesture, and input/ is honest only if
everything in it interprets something the reader did.
No behavior change. Almost every reference was on the @/ alias; eight
relative imports in files that did not move, or that moved away from a
sibling, needed rewriting by hand.
i18n-literals now flags a template literal in a UI attribute or prop when
there are words between its values: `Remove ${email}`, `${name} — shared by
${owner}`. It cannot be a catalogue key as written, so neither the key
exemption nor the sentence-shape test applies. A template that is only
punctuation around values, like `${name} (${size})`, is left alone.
The twelve it found are now keys with placeholders: the quota bar title,
the address menu's label, a folder's subfolder unread count, a recipient
chip's remove button, the contact editor's title, shared and available
calendars and address books, the date and time fields' labels, the
attachment title's fallback name, and the free/busy bar. The bar showed
the raw JMAP busyStatus ("confirmed") and now says Busy, Tentative or
Unavailable.
11 new keys in all nine catalogues. Remove {address}, Date, Time, Busy and
Tentative already existed.
i18n-literals checked title, aria-label, placeholder and alt on elements,
but not the same English passed to a component, so a MenuItem label or a
Popover ariaLabel written as a literal went through. It also excused a
literal that happened to be a catalogue key. That exemption is meant for
English held in a constant and translated where it renders, and a literal
written straight into a JSX attribute has no such render site. No
component passes its props through t(). And neither half of i18n:check
was run with --check, so a finding printed and the script still exited 0.
Component props are checked now, a key no longer excuses a literal in an
attribute, and both scripts run with --check. That found 28 strings
rendering English in every language: 19 already had keys and are wrapped,
and 9 are new keys in all nine catalogues. The contact editor's Save and
Saving… buttons are wrapped as well, on the same line.
A maximised composer goes position: fixed but stays a child of the dock,
and had no z-index of its own. The dock is a stacking context, so the
positioned parts of any composer later in the DOM (its recipients row, its
editor) painted straight over the full-screen one.
Give the maximised composer its own layer, and hide the other composers
while one is full screen: they cannot be reached anyway, and the 24px
inset would otherwise show their footers along the bottom edge. They stay
mounted, so nothing being written in them is lost.
Fixes#330
A sweep of lib/ and store/ for user-facing English, after the views were done.
The pattern here is the one #248 found: a module returns an English sentence
and the view renders it without asking for a translation.
Scheduled send was entirely untranslated. The four presets -- Later today,
Tomorrow morning, Tomorrow afternoon, Monday morning -- were rendered raw, and
scheduleError() returned three English sentences straight to the picker.
describeSpan() built "30 days" by appending an "s" unless the count was one,
which is English grammar written into the code: it produces the right German
only by the two languages happening to agree, and Russian needs three forms.
It is plural() now. scheduleError translates in place rather than returning a
key, because it composes a sentence around that span.
Read receipts: refusalText() returns five explanations, all rendered raw in
MessageView, and the offered case was a template literal -- "Requested, to
x@y. Never sent automatically." -- with the address concatenated in. It takes
a placeholder now, so the sentence can be reordered.
Compose: the sending toast and its Undo, the Open draft action, the two
attachment failures, and three thrown errors that surface to the reader as
toasts.
Two swipe labels, Add star and Remove star, are rendered through
t(desc.label) and have never been in any catalogue -- the coverage scan that
found the other 176 only looked at t("literal") sites, so labels reaching t()
through a variable were invisible to it. Every such table is now enumerated
and all 61 values checked: these two were the only ones missing.
17 strings and 3 plural forms are new and land with each language.
Verified: typecheck clean, 1000 tests pass.
Four warnings, in Privacy & safety, and all of them start switched off.
That is not timidity. A client that begins by interrupting is one people
learn to click through, and a warning clicked through without reading
costs the same attention and buys nothing. The outside-sender warning
could not be on by default in any case: it measures against the domains
that count as yours, and with nothing configured every message in the
mailbox is from outside.
Your own identity domains are always internal and are not configuration.
An account signed in as [email protected] warning that example.com is
external would be absurd, and making it be typed in first is a foot-gun
that leaves the feature useless the moment it is enabled. Configured
domains are additional, and cover their subdomains -- matched on a dot
boundary, so example.com covers mail.example.com and not notexample.com,
which is exactly the domain somebody registers on purpose.
The four:
A banner names the sender's domain on a message from outside.
Sending outside names the outside recipients and asks, rather than
refusing. "This is going outside" is a rule and not something the sender
can check; a list of addresses is. It reads the full identity list rather
than the visible one, since hiding an identity from the From menu does not
make its domain somebody else's.
Sending to a large group asks once the count crosses a threshold, which is
what catches a reply-all onto a long thread. It counts people rather than
headers, so one address in To and nine in Cc is a message to ten.
Opening a link asks when the destination is not trusted, and always when
the link's text names one domain while its destination is another -- even
where that destination is trusted, because being trusted is not the same
as being the place the text claimed. On that mismatch the offer to trust
the domain is withheld: what would be trusted is the destination, and the
destination is not the thing in question. Anything that is not http or
https is left alone, since warning about a mailto: is noise and noise is
how a warning stops being read. Both bodies are covered, because a link in
a plain-text mail is linkified by us and points wherever it likes just as
readily as one the sender marked up.
The click is cancelled and the navigation re-issued after the answer,
since there is no way to hold a real navigation open across a dialog. The
reopen runs in the continuation of the dialog's own click, which is still
the gesture a popup blocker wants to see.
`i18n:coverage` reported 100% while a hundred-odd strings rendered
English in every language. It was not wrong about what it measured: it
reads JSX text, and none of these were JSX text. They were toast
arguments, `confirmDialog({ title, confirmLabel })` props, `title=` and
`aria-label=` attributes, and template literals — every one built from an
expression the codemod cannot read.
176 source strings and 15 plural sets now go through t() and plural(),
translated into all nine languages. Where English put a word in a slot,
the sentence is spelled out per branch instead: `Filter ${verb}` became
"Filter saved" and "Filter created", because which word agrees with what,
and where it sits, is not a property English gets to decide for everyone.
Counts that were `${n} message${n === 1 ? "" : "s"}` are plural() calls,
so Russian and Ukrainian get three forms and Japanese and Chinese get the
one they actually have.
Two of the catalogue's own conventions were worth learning the hard way.
Plural entries are keyed on the English *other* form, not `one` — `one`
is a form English happens to have and Japanese does not. And a constant
table holding English that is translated at the render site is fine: the
literal is a key, not a leak.
Which is what the new check encodes. `scripts/i18n-literals.mjs` accepts
a string that is wrapped where it is written or is a catalogue key
somewhere, and refuses one that is neither — a string no catalogue can
translate, however many languages ship. It found twenty more than my own
sweep had, including the stale-folder toast seen in production. It runs
as part of `npm run i18n:check`.
Also fixed: the catalogue is now awaited before the first paint. The
tree is rebuilt when a catalogue lands, so components recover on their
own, but a string computed in an effect does not — a toast fired in that
gap is emitted in English and stays English. The wait costs nothing
visible, since the session bootstrap already shows a spinner and English
resolves immediately.
And the Japanese agenda title loses a space Japanese does not use:
"{date} からの予定" was written with the English habit of spacing around
a placeholder.
Three places built user-visible English out of expressions rather than
writing it as JSX text, so the extraction codemod never found them and
they stayed English in all nine languages — including the five that have
been in production for weeks.
The calendar view switcher was the worst of them: it spelled its labels
as `v[0].toUpperCase() + v.slice(1)`, which is correct English and
untranslatable anywhere else. Day, Week, Month and Agenda were already in
every catalogue, sitting unused, because the buttons never asked for
them. They now come from a Record<View, () => string>, so TypeScript
makes the map exhaustive and adding a view forces adding its label. The
labels are functions rather than values: a module-level object would
capture whichever language happened to load first and keep it.
The other two needed new source strings, added to all nine catalogues:
the composer's title for an untitled draft ("New message"), its status
line ("Sending…", "Saving…", "Error", "Saved {when}", "Unsaved"), and the
agenda view's own title ("Agenda from {date}").
This is not the whole of it. A sweep for the same shape — template
literals, toast arguments, and dialog props rather than JSX text — turns
up roughly a hundred more strings, mostly toasts and confirmation
dialogs. Those are a second extraction pass rather than a fix, and are
left for one.
The 143 the codemod refused turned out to be two different things, and only
one of them needed a person.
A third were phrases sitting next to an icon -- `<Plus /> New rule`. The
refusal rule was "has siblings", which is broader than the danger: what breaks
a translation is a sibling that renders *text*, splitting a sentence into
fragments no one can reorder. An element beside a phrase does not. Narrowing
the rule to text-producing siblings let the codemod take 73 more.
The rest were real sentences with values in the middle, rebuilt by hand as
named placeholders -- "Your active script “{name}” was written by hand",
"Waiting on the server — goes out {when}." Named rather than positional
because a translator moves the parts around; counted things go through
plural() so Russian and Ukrainian get their three forms rather than English's
two.
Sentences with an element inside them needed something new. `Open <code>mailto:
</code> links in ihasmail` has two obvious treatments and both are wrong:
splitting it into two t() calls hands over fragments that cannot be reordered,
and dropping the <code> keeps the sentence whole but loses the monospace that
said "this is a literal". tNode() keeps the sentence whole and makes the
element a named hole in it, so a translator sees one sentence and can put the
hole where their language wants it. The German test asserts exactly that: the
same call renders the code first when the catalogue says so.
The coverage number was also lying, and it is worth saying how. It counted
text inside <code> and inside translate="no" as untranslated work, and
placeholders like "123456" and "+1 555 0100" -- a one-time code and a phone
format. None of those will ever be translated, so the report sat at 21 with 6
real items left. A number with an unreachable floor is something to argue with
rather than act on, so the tool now applies the same rules the codemod does.
596 wrapped, nothing remaining. Verified in the browser across 15 views, which
is where the last bulk pass hid a bug the tests could not see: no entities, no
unfilled placeholders, no raw t( in rendered text, and the toggle switches that
looked like emptied labels are text-free by design.
Wrapping ~1,000 strings by hand is a thousand chances to mistype the copy
itself, and a parser does not get bored. scripts/i18n-extract.mjs does the
mechanical part -- JSX text and the attributes a person actually reads -- and
refuses the rest rather than guessing. 78% now: 515 wrapped, 143 left.
What it refuses matters as much as what it does. Text split around an
interpolation arrives as separate fragments, and wrapping each on its own
produces "Move " and " messages", which no translator can do anything with;
those are listed for a person to rebuild as sentences. So is anything
containing a double quote, which would end the literal.
Three things it had to be taught, each found by running it:
- <code>, <kbd> and <pre> are not prose. The first run wrapped `label:name`
inside <code> -- a search operator, where translating it breaks the thing it
documents. Subtrees marked translate="no" are skipped for the same reason.
- `t` is a natural name for a callback parameter and several files already use
it, so an import called `t` is shadowed inside those callbacks -- silently,
wherever the local happens to be callable. The name is checked per file now
and aliased to `translate` where it is taken.
- JSX decodes HTML entities and a JS string literal does not, so
`Language & region` moved into t("...") and rendered the entity on screen.
That last one is the one worth remembering. Typecheck passed, 443 tests
passed, and the page said "Language & region" in plain sight. It took
looking at a screenshot, and then a sweep of ten views to find the second
occurrence in a sentence I had written by hand earlier the same day. Nothing
in the toolchain was ever going to catch it: it is valid TypeScript rendering
valid text that happens to be wrong.
The codemod decodes entities now, and checks for a quote after decoding rather
than before.
Groundwork for un-shelving translations. Chrome's translator rewrites the
rendered DOM directly, wrapping text nodes in <font> elements React has never
heard of, and the next update can then call removeChild against a parent whose
children have moved (facebook/react#11538). This is the structural defence
against that, plus the setting the served language will read from.
The language setting is `uiLanguage`, and it is deliberately not the `locale`
field that already exists. That one is a formatting choice -- what calendar,
clock and numerals to use -- and folding the two together would silently
rewrite everybody's date format the first time they picked a language. German
dates with an English interface is a real preference, and so is the reverse.
It defaults to English when absent, which covers both a new account and every
settings file written before this, and Accept-Language is not consulted: a
served locale should be something the reader chose rather than something
guessed and then written down as though they had. Only languages with strings
shipped are offered, which today means English alone -- a picker entry without
a catalogue behind it would leave the page claiming a language it is not in,
which stops a reader translating a page they cannot read.
`<html lang>` is set where applyTheme is set: at store module load, from the
localStorage cache, before createRoot() has rendered anything. Not in an
effect -- a lang that is briefly wrong is enough to raise the translate prompt
on a page that needed none. There is no server-rendered alternative to reach
for here: ihasmail serves a static shell and holds no account state, and the
settings file lives in the reader's own JMAP Files, so reading it before the
page existed would mean authenticating to Stalwart on every page load. The
static lang="en" in index.html covers the first bytes; the store only ever
corrects a reader who chose otherwise. Both halves are tested.
translate="no" and class="notranslate" go on the narrow boundaries only:
rendered email bodies, raw message source, attachment text, the generated and
hand-edited Sieve, the brand and the login name. Not on <body> -- someone
whose language ihasmail does not speak yet should still be able to translate
the parts that are ours. Email bodies turn out to live in a shadow root, so
React never reconciles them and they were never a crash risk; the marker there
is about not rewriting what a sender actually wrote.
Twenty-four fragile interpolation points were found with the TypeScript
parser rather than grep, and fifteen refactored. Pluralisation and
"count + label" pairs are collapsed into a single expression so the text is a
lone child React updates with textContent, rather than a text node with
conditional siblings to insert around. One of them -- InviteCard's
{method === "REPLY" && organizer ? "" : ""} -- rendered an empty string either
way and is simply gone.
The boundary is scoped to the main content, so the header, folder tree and any
open composer sit outside it and survive independently. It recovers by
remounting the subtree, which costs nothing because everything inside
re-derives from the stores, and it logs at info rather than error: a reader
translating a page is expected and recovered from, and filing it as an error
would put an entry in every console-reading reporter for behaviour that
worked. It re-raises anything that is not a DOM mutation error, so a real bug
still surfaces as one, and it gives up after three attempts rather than
looping invisibly.
Worth recording: the crash could not be reproduced on React 19.2.8. Wrapping
207-249 React-managed text nodes in <font>, exactly as the translator does,
then driving in-place conditional toggles and navigations, left the app intact
with the boundary never firing. The original issue is from React 16 and the
reconciler has changed a great deal since. So this lands as defence whose
premise is weaker than assumed rather than as a fix for something observed
here, and the boundary is insurance rather than a load-bearing part. The
notranslate markers and the collapsed interpolations stand on their own merits
either way.
The picker opened on "No contacts in this address book" -- about an
address book with contacts in it. Nothing was wrong with the button, and
that is why it read as one: it opened, correctly, onto nothing.
Contacts are fetched on demand. `loadAll` runs when the Contacts view
mounts, and `suggest` kicks it off itself, which is why autocomplete has
always worked from anywhere. The picker did neither, so opening a
composer without having visited Contacts first -- which is most of the
time, and every time in a fresh tab -- showed an empty list over a full
account. Anyone who had been to Contacts that session saw it work, which
is the sort of difference that reads as browser-specific when it is not.
It asks for them now, and says it is loading rather than that there are
none.
While here: the picker decided which shared books to offer on
`isSubscribed` alone. Stalwart refuses that flag on a book shared
read-only, so those are recorded in settings instead -- for an address
book it is the *only* record -- and filtering on the server's flag left
every shared book out of the picker while the sidebar showed it. Both now
ask the same question.
Verified against the mock from a genuinely cold store -- cards emptied,
`loaded` false, opening the picker as the first thing that wants them:
eight rows, from the reader's own book and a shared one, where before
there were none.
Addressing a message worked only if you already knew the name you were
half-way through typing. Autocomplete answers "finish this for me"; there
was no answer to "who is there?", which is the question someone has when
they open a compose window and want the person from the team list whose
surname they cannot summon.
The To row now opens the address books -- from a button beside Cc and
Bcc, where someone thinking about recipients is already looking, and from
the To label itself for anyone who tries that first. Search across every
book or narrow to one, tick as many people as the message needs, and send
them to To, Cc or Bcc. Picking for a field that is hidden opens it, since
a Bcc dropped somewhere invisible is worse than no Bcc.
Every address is its own row rather than every person. Somebody with a
work address and a personal one is a choice the writer has to make, and a
picker that listed the card and quietly took the first address would be
making it for them.
Shared books are in it on the same footing as the reader's own -- that
being the point of having added them -- with the account named on each
row, so it is never a mystery whose list a name came from. Books that
have not been added contribute nothing, the same rule the To field
already follows.
Verified against the mock: the picker lists the reader's contacts and the
shared book's, each row naming its source; ticking one of each and
choosing Cc opens the Cc row with both in it.
Attaching meant uploading, even when the file was sitting in the account
already -- picking it off disk again to send the server a copy of what it
was holding.
The composer can now attach from Files. A blob the account can already
see needs no upload at all: an attachment carrying a `blobId` is what a
forward produces, so the send path has always known what to do with one.
Attaching a large file the server is already storing now costs nothing
and takes no time.
A file in an account somebody *shared* is different, because blobs belong
to the account they were uploaded to and a draft in yours cannot
reference one in theirs. Those are fetched and uploaded to your account,
and the picker says so before you attach rather than leaving someone
wondering why one file was instant and another was not.
The picker borrows the Files store, so it browses what Files browses,
shared accounts included, and puts the file manager back where it was on
the way out -- a detour through somebody's shared folder to find an
attachment should not leave Files somewhere else afterwards.
Verified against the mock, and worth recording how, because the first
attempt measured nothing: `client.upload` uses XMLHttpRequest, since it
reports progress, so a counter wrapped around `fetch` sees no uploads
whether or not any happen and agrees with you either way. Counted at
XHR instead: attaching one's own file issues no upload, and attaching a
shared one issues exactly one, to the reader's own account.
An account using a unique address per service, on a server with an alias
domain, ends up with every local part twice over and a From picker
nobody can use -- while only ever sending from a handful (#73).
Identities can now be hidden from that picker, from Identities &
signatures. Hiding is presentation only: the identity still exists,
still receives, and stays listed and editable, the way an unsubscribed
folder is still a folder. That framing is mbunkus's own, and it is the
right one -- this is a UI preference, not a change to the account.
Three things it refuses to do, because a sender picker with nothing
usable in it is worse than a cluttered one:
- it will not hide the identity a draft is already using, which would
leave the select with no matching option and move the From line
under the writer
- it will not hide the default, which is what a new draft starts on;
the button is disabled there and says why
- if every identity is somehow hidden -- reachable only through
settings sync, since the UI will not do it -- they are all offered
again
The setting syncs, so the picker looks the same on every device, which
follows from DEVICE_KEYS being a list of exceptions rather than a list
of what travels.
Verified against the mock with four identities and one hidden: the
picker offers the other three, the hidden address is gone from
composing, the default's hide button is disabled, and the row says the
identity still receives.
Scheduled send, which the README listed as needing server support that
Stalwart has had all along. The delay cannot be asked for directly --
RFC 8621 makes `sendAt` read-only and server-derived -- so it goes on the
envelope as an RFC 4865 `HOLDUNTIL` parameter, and the server reports back
the time it settled on.
Stalwart advertises this in the *account* capability, not the session-level
one (which is empty): `maxDelayedSend` of thirty days and `FUTURERELEASE`
among its `submissionExtensions`. The composer offers scheduling only when
both are there, and never offers a time the server would refuse.
A held message goes to a Scheduled folder rather than Sent, because
`onSuccessUpdateEmail` would otherwise file it as sent the moment the
submission is created, and it has not been sent. Nothing moves it out when
the hold expires, so the folder is reconciled on the way in: released
messages to Sent, cancelled ones back to Drafts. Cancelling uses a separate
`Email/set` rather than `onSuccessUpdateEmail`, whose key Stalwart reads as
an Email id and not, as the RFC says, a submission id.
The mock grows the whole lifecycle, and learns to resolve creation
references while it is there -- it had been quietly declining to create any
submission at all, since sending names its message as `#m`. Because
Stalwart's own `futureRelease` setting defaults to off and then drops the
hold in silence, `npm run dev:mock:no-future-release` reproduces that.
Verified end to end against the mock; not yet against the live server.
The body editor was told to focus itself with
autoFocus={d.to.length > 0 && Boolean(d.subject)}
and RichEditor ran that as an effect keyed on the prop. Typing the first
letter of a subject flipped Boolean(d.subject) false -> true, the effect fired,
and the caret jumped from the subject line into the message body.
autoFocus now means what it means on a DOM element: focus on mount. RichEditor
captures the prop in a ref and focuses once, and the composer decides where the
caret starts when it opens - recipients for a blank message, body for a reply
that already has recipients and a subject - instead of deriving it from state
that changes as the user types.
initialFocusTarget is extracted and exported so the rule is stated in one place
and tested. The regression test renders RichEditor and asserts it does not take
focus from a field being typed into; it fails against the previous effect.