Commit Graph
11 Commits
Author SHA1 Message Date
jcoffey-dev 20b6475f18 Second pass: four dialogs that were never wrapped
A sweep for UI text still rendering in English, after the nine catalogues were
brought up to date. Four dialogs were building their own English:

  - The delete confirmation in MailView, entirely: both titles, both messages
    and the confirm label. Its counts read "message(s)", which is a
    parenthesis standing in for agreement -- every language that inflects got
    the wrong form. They are plural() calls now.
  - Rename, in the Files tree.
  - New address book, and its Name placeholder.
  - New category, and its Name placeholder. The button opening that dialog was
    already translated, which is how it went unnoticed: the label read right
    and the dialog it opened did not.

Rename, New address book, New category, Name and Delete are already in all
nine catalogues. Delete?, Delete forever? and the two plural forms are new and
land with each language.

Verified: typecheck clean, 1000 tests pass.
2026-09-03 11:58:17 -07:00
jcoffey-dev 00b580bad8 Select more than one file at a time
Moving or deleting five files meant doing it five times, each with its own
confirm. Rows now select the way they do in a file manager: a plain click
replaces the selection, ctrl or cmd adds and removes one, shift takes the
run from the last row clicked, clicking past the last row or pressing
Escape clears it. Two or more selected raises a bar with Move to... and
Delete, and the row menu offers the same for the whole selection.

The move is one `FileNode/set` rather than a loop, and not only for the
round trip: a loop would apply half the moves and then throw, leaving a
selection split across two folders with nothing saying which half went.
One call is one answer, and `notUpdated` names whatever the server
refused.

Right-clicking inside the selection acts on all of it; right-clicking
outside means you meant that row, so the selection follows the pointer
rather than the menu quietly applying to something off-screen. A drag
carries the whole selection the same way, which is why the payload is now
a list -- and why a drop is refused unless every file in it can land,
since a drag that moves four of five and skips the fifth is worse than
one that will not start.

A selection belongs to the folder it was made in, so changing folder or
account drops it: rows left selected off-screen make the delete two
folders later a surprise.
2026-09-01 21:11:36 -07:00
jcoffey-dev a4c0e04ab9 Edit a text file where you are already reading it
v2 of the viewer: Edit, on text and Markdown, in the dialog and on the
row menu. Save is explicit -- every save mints a new blob, so autosave
would burn quota and multiply the conflicts it cannot see.

Two people editing one file is the case worth getting right. `saveText`
re-reads the node and compares the blob the editor started from: if
somebody else saved in the meantime it refuses, says so, and leaves the
work in the box to copy out. `ifInState` is the obvious tool and the
wrong one -- it is the state of every FileNode in the account, so an
unrelated upload in another folder would fail the save, and a warning
that cries wolf is a warning people click through.

Editing is not offered where saving would lose something: a file
truncated for display would have its tail written away, and one that did
not decode as UTF-8 would have mojibake written over whatever encoding it
really is. Both open read-only and say which. Nor is it offered without
mayModifyContent -- a read-only share just has no Edit.

Closing or cancelling with unsaved changes asks first, Ctrl+S saves, and
mail attachments are unaffected: they pass no onSave, because a message
part is not a thing that can be written back.
2026-09-01 20:51:51 -07:00
jcoffey-dev 984f0474e3 Look at a file without downloading it first
Files could only hand you the bytes: double-clicking a picture put it on
disk and left you to find it. The viewer for this already existed --
images, PDFs and text, in the attachment preview in MessageView -- it was
just wired to one screen. It is now a component both screens use.

Two things it needed before it was any use on Files. The type detection
falls back to the file name: an upload carries whatever the browser
guessed, which for anything unusual is application/octet-stream, so the
old exact-type check saw nothing to show in a .md that had just been
uploaded. And text is read with fetch, which ignores Content-Disposition,
so Markdown previews even though the server will not serve it inline.

Whether we can show a file and whether the server will serve it inline
are separate questions, and lib/preview.ts answers them separately:
`openableInTab` mirrors the isInlineSafe allowlist in the blob route,
because navigating to a blob the server will not inline just starts a
download. SVG is left out of both -- it carries script, and how to show
one safely is its own question, not a detail of a file lister.

Printing goes with it. A picture or a text file prints from the dialog
with everything else dropped; a PDF prints itself from its own iframe,
since the page around it cannot paginate someone else's document. Hiding
`.app` alone was not enough there -- `#root` kept its height and printed a
blank first page, the same trap as the message card.
2026-09-01 20:18:51 -07:00
jcoffey-dev a6863e98cc Second extraction pass: the strings the codemod could not 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.
2026-08-31 14:14:30 -07:00
jcoffey-dev 3f4b33cb51 Finish extraction: 100%, and a coverage number worth believing
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.
2026-08-31 10:41:24 -07:00
jcoffey-dev 8ea611f7f7 Extract 515 strings by codemod, and the two bugs only a screenshot caught
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 &amp; 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 &amp; 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.
2026-08-31 09:58:33 -07:00
jcoffey-dev ad94efb65b Reach shared folders from Files, not the profile menu
A folder somebody shared was reachable only by switching the whole app
to their account from the profile menu -- which nobody would think to
look in for files, and which pointed mail, calendar and contacts at them
as well. The server refused all three, so nothing leaked; it was simply
the app claiming to be somewhere it could not go.

Files now lists shared accounts itself, under "Shared with me", and opens
them in place. Only Files moves: `accountId` in its store is the account
being browsed, `ownAccountId` is the reader's, and nothing else in the
app notices.

Which accounts hold shared files cannot be worked out from capabilities.
Stalwart advertises the whole set on a shared account -- mail, calendars,
contacts, sieve, the lot, identical to a personal one, whatever was
actually shared (checked live on 0.16.19, 2026-08-27). That is why
routing alone could never have fixed this, and why the list offers every
account that is not the reader's own and lets its folders answer for
themselves. The mock's shared account now advertises the same full set,
because a mock that quietly advertised only what it shared would agree
with a fix that cannot work.

Shares also went unseen until the next sign-in. They arrive in the JMAP
session, which is fetched once and refreshed only when a session-state
change is pushed to that tab -- so a share granted while the tab was open
stayed invisible, and one removed stayed on offer. That is the two
browsers disagreeing about whether an account still existed. Opening
Files now re-reads the session, throttled, and the section header carries
a refresh for when someone is waiting on a share they have just been
promised.

The sidebar's button on Files was Compose, which wrote mail from the file
manager. It uploads.

Verified against the mock, which grew a second account to make any of
this testable: "Shared with me" lists it, opening it shows its folders
and not the reader's, the header says whose they are, "Back to my files"
returns, and the profile menu is not involved at any point.
2026-08-27 10:13:58 -07:00
jcoffey-dev f70eb184c2 A folder tree, and dragging things into it
Files had a breadcrumb and a Move to… dialog. Moving anything meant
opening a dialog and walking down the folder you wanted, which is a lot
of ceremony for something every file manager does by dragging, and there
was nowhere to see the shape of the account at all.

There is now a folder tree in the sidebar, beside the mailbox tree it
borrows its look from. Rows in the list and folders in the tree can be
dragged onto any folder in either, and folders dropped from outside are
uploaded with their structure intact.

The tree arrives in a single query. `filter: { nodeType: "directory" }`
returns every folder in the account -- checked against 0.16.19 on
2026-08-27 -- so nothing waits on an expand, and a drag knows every
folder it could land on including ones nobody has opened. It is
deliberately its own request: a filter Stalwart refuses fails with a
request-level 400 that takes every method call in the request with it,
which `{ parentId: null }` does, so a per-level query batched alongside
the listing would blank the whole view rather than just the sidebar.

Two things the writing of this turned up.

The mock ignored the `nodeType` filter the live server applies, so the
tree asked for directories, was handed files as well, and drew them as
folders you could open into nothing. The mock now filters the way 0.16.19
does. The store also filters again on the way in, because a tree that
believes whatever a server sends is a tree that draws files as folders on
the next server that gets this wrong.

And the drag state was per-pane, which cannot work: a drag that starts in
the list has to be recognised by the tree, and the pane that did not
start it never lit up or accepted the drop. Dropping still worked, since
the drop handler re-checks from the drag itself -- which is why this
would have shipped looking fine and been unusable. It lives in the store
now, with the reason written down.

Dropping a folder in goes through `webkitGetAsEntry`, which is
non-standard in name and universal in practice. Its `readEntries` returns
*up to* some entries per call and signals the end with an empty array, so
a single read loses everything past the first batch. Both bounds in there
-- depth, and entries per directory -- exist because a directory tree
from outside the app is not something to take on trust; the test that
covers the second one found the version without it looping for ever.

Verified against the mock: a row dragged onto a folder in the tree lights
the target, is accepted, and moves it on the server; a top-level folder
dragged to All files is refused as the no-op it is; the tree's own menu
creates, renames, shares and deletes; and the tree lists folders only.
2026-08-27 09:19:54 -07:00
jcoffey-dev 6566f4c2d3 Share files and folders with other people
Calendars and address books have been shareable since JMAP Sharing went
in; Files never was, though Stalwart treats file storage as a first-class
thing to share and ihasmail has carried the types for it all along.
`FilesRights` and `FileNode.shareWith` were already declared -- what was
missing was asking for the property, offering the dialog, and saying so
in the list.

Checked against the live 0.16.19 first, read-only, because building a
picker against a mock that agrees with you proves nothing:

  - `FileNode/get` returns `shareWith`, and `myRights` carries all six
    rights, `mayShare` among them and true on one's own nodes. So the
    menu entry has a real right to gate on -- unlike folder sharing,
    which is offered ungated because `MailboxRights` has no such right
  - `Principal/query` answers now that `allowDirectoryQueries` is on:
    six individuals, no groups
  - `ShareNotification/get` is implemented, which is worth knowing for
    later; nothing here reads it yet

The editor preset grants read, add files and edit contents, and stops
there. Rename and delete stay with whoever shared the folder: someone
given a folder to work in should not be able to rename the thing they
were given, or delete it out from under the person who shared it. Both
are still there to tick by hand.

One finding is worth a test of its own, and has one. Stalwart answers
`shareWith` as `{}` for a node shared with nobody, not `null` -- every
unshared node in a live account came back that way. A truthiness test on
the property is therefore true for every node the server has ever
returned, and the badge driven by it would report the whole account as
shared while being, technically, about the right property. `isShared`
counts keys, and the test says why.

Verified against the mock end to end: sharing Documents with a principal
as Editor persists `mayRead`, `mayAddChildren` and `mayModifyContent` and
nothing else, the badge appears on that folder and not on the file beside
it, and re-opening the dialog shows the saved rights rather than an empty
form -- which is what proves `fileNodeProps` is really asking for the
property.
2026-08-27 09:07:13 -07:00
jcoffey-dev 645b8b510f ihasmail 2.0: rebuild as Stalwart-first JMAP webmail
Replace the FastAPI/HTMX prototype with a Node/Hono session proxy and a
React 19/Vite SPA. Mail (conversation view, search operators, labels,
sanitised HTML, privacy image proxy, invites, undo send, templates),
calendar (month/week/day/agenda, invites, free/busy, categories,
context menus), contacts (JSContact, groups, vCard), files, Sieve filter
builder (incl. filter-from-message with retroactive apply), vacation,
identities with default + Reply-To, PWA/mobile layout, push via SSE,
in-memory mock Stalwart for dev, Docker + CI.
2026-08-23 01:07:13 -07:00