Commit Graph
10 Commits
Author SHA1 Message Date
jcoffey-dev 8badf48c4a Give the address books the menus the calendars have
Two remarks from the reporter's colleague, both the same underlying thing:
contacts and calendar grew their menus at different times and it shows.

The dots button on hover. The calendar has offered its per-item menu two ways
since it was written -- the button and right-click -- and contacts only had
right-click, which is undiscoverable and unavailable on touch. The rows are
already .nav-item, which has carried the hover-reveal rule for mail folders
all along, so this is the button and no CSS.

Import and export move into those menus. As a pair of buttons at the foot of
the sidebar they did not say which address book they acted on -- they meant
"whatever is selected", which is not something a button can tell you. The
calendar settled this already: its iCAL import lives in the calendar's own
menu, because that is where "which one?" is answered by where you clicked.
The events they dispatch now name the book instead of meaning the selection.

Exporting a book now exports that book, rather than the list on screen. The
old one handed you whatever was showing, so a search box with something in it
quietly narrowed the export -- fine while the button sat under that list,
wrong from a menu in the sidebar.

Two things that would otherwise have been lost with the buttons. "All
contacts" gets the same menu, so exporting everything still has a home; and
a book somebody shared gets a menu rather than the bare X, since it can be
exported too and losing that would have been a regression dressed as a
tidy-up. The X moves inside as "Remove from my contacts".

Closes #224.
2026-09-02 09:06:00 -07:00
jcoffey-dev 74f6d1d0aa Skip events on re-import that the calendar already has
Importing an export twice left second copies of everything. The import has
kept the file's own UID since it was written -- inventing one only where an
event arrives without -- so what was needed to recognise an event that is
already here was there all along, and nothing looked at it.

Asked for on #173 after the reporter's colleague hit the duplication in
testing, and decided there: skip on a UID the calendar already holds, import
what arrives without one. An event with no UID is not one anything can match
to, and a softer match -- title and time, say -- guesses in both directions.

The UIDs are read once per import rather than once per event. CalendarEvent/
query does take a uid filter, which is what findByUid uses, but a file of two
thousand events would be two thousand queries. Read without expandRecurrences
so a weekly series is one event with one UID rather than one per occurrence,
and narrowed to the target calendar from calendarIds rather than through an
inCalendar filter this client has not confirmed the server supports.

Matching is per calendar. A UID is what makes an event the same event across
calendars, so the same event being in two of them is not a duplicate and the
second calendar still gets its copy.

importIcs now answers with both counts. "Imported 40 events" over a file of
240 reads as a failure when 200 of them were simply already there, and a
re-import of an unchanged file would otherwise report importing nothing at
all rather than saying everything was already here.

The three import toasts are translated in all nine catalogues while the
messages were being written -- the plural for the existing one had never been
added and was falling back to English.

Closes #222.
2026-09-02 08:52:33 -07:00
jcoffey-dev 5e6e049eef Set the Archive role from ihasmail, rather than describing it
#220 corrected the message and left it useless: it told you a folder needs
the Archive role on the server, which was true, and gave you nothing to do
about it here. Roles were shown in Folders settings and never settable.

Mailbox/set takes `role`. Confirmed live against 0.16.20 on 2026-09-02, as an
ordinary user through the proxy, with no admin API: setting role "archive" on
a folder that had none returned updated and the folder began working as the
Archive immediately. Stalwart parses the role names in SpecialUse::parse,
"archive" among them, refuses a second holder of a role, and refuses to move
the role of Inbox, Junk or Trash.

So the toast now carries the fix. "No Archive folder is set yet." with a
Create one that makes the folder and then completes the archiving that could
not happen -- rather than leaving someone to select the same messages again.

A folder already named Archive and carrying no role is adopted rather than
duplicated. That is the state #217 was reported from, and a second Archive
beside the first would be its own confusion. One named Archive that is really
the Sent folder is left alone: taking its role to fix archiving would break
sending.

Folders settings gains a Role column. Archive, Drafts and Sent are offered,
being the roles this client's behaviour depends on and the server will move;
Inbox, Junk and Trash show theirs and cannot change it, because 0.16.20
refuses. A role another folder holds is left out of the list rather than
offered and refused, so freeing it is a deliberate two steps.

The folder is created with the server's own name, never the localised one,
for the reason renaming already writes back the server's: a German session
must not create "Archiv" that an English one cannot find.

Closes #217 properly.
2026-09-02 08:38:08 -07:00
jcoffey-dev 1611ae6918 Say the Archive folder needs the role, not the name
Archiving looks the folder up by its special-use role and by nothing else --
roleId("archive"), falling back to roleId("all") -- and then, finding
neither, told you to create a folder named "Archive". Naming a folder does
not give it a role, and ihasmail has no way to assign one: Folders settings
shows the role beside a folder and offers no way to set it. So the advice
sent someone round a loop that could not end. They make the folder, it still
does not work, and the message says the same thing again.

It now says what is actually required and where it lives: a folder needs the
Archive role on the server, and naming it "Archive" is not enough.

All nine catalogues carry the correction rather than falling back to English,
and they need the same native review the rest of them do.

The existing test asserted only that archiving complained. It now checks what
the complaint says, since the words were the whole bug.

Closes #217.
2026-09-02 08:00:46 -07:00
jcoffey-dev 7ba749148f Make an event out of a message
Asked for in #167: a right-click on a mail that turns it into a calendar
entry, the way a bill or a task becomes a reminder. Nothing clever, and
deliberately so -- the subject becomes the title, the body becomes the
description, and the reader supplies the one thing the message cannot.

A due date is exactly that thing. "Due on the 14th" in an invoice is not
a date a parser could be trusted with, and a wrong guess quietly
scheduled is worse than no guess at all, so the editor opens on the next
half hour for an hour and the reader fixes it. Forward rather than now,
because a start time that has already passed by the time they press
Create is one more thing to correct.

The body is capped at 5000 characters. A newsletter is a message too,
and its whole body would be stored on the event, synced to every device,
and shown in a three-row textarea; what is worth keeping -- the amount,
the account, the address -- is near the top. The cut is marked, so a
truncated bill is not read as the whole of it.

One message only. The list menu acts on the selection everywhere else,
but there is no sensible event to make out of five mails, and the mobile
entry appears only when exactly one row is held.

The editor lives inside CalendarView and the reader is in the mail view
when they ask, so the draft waits in the calendar store until that view
mounts and takes it -- once, or it would reopen on every later visit.
It seeds a form rather than an event: the dialog still says New event
and still has to be pressed.

Called *Create event…* rather than "appointment", which is the word the
issue used: it opens the New event dialog, and each catalogue already
has its own settled noun for that -- Termin, événement, 日程.

Reachable three ways, since a phone has no right-click: the row context
menu, a message's ⋮, and the ⋮ of a held row on mobile. Hidden entirely
where the account has no calendar.
2026-08-31 22:52:59 -07:00
jcoffey-dev 94639e8420 Hang every folder off one edge, and give the drawer a way out
Two things the drill-down got wrong, both found on a phone-width window.

The folders did not line up. The rule that drops the twisty's 30px
gutter was hung on the rows offering a drill, so only folders with
children lost it -- they sat 18px left of every folder without any, and
the column of icons came apart. Whether a folder has children is not a
reason to hang it somewhere else. The class moves to the list, which is
what the indent is a property of; icons now share one column and labels
another, at every level and on the back row too.

There was no obvious way back out of the drawer. It covers the top bar
-- it is taller than it -- so the hamburger that opened it is
underneath, and pressing the same place again did nothing at all, since
that handler only ever set the drawer open. The dimmed strip beside the
drawer was the only exit, and nothing says so. There is now a close
where the hamburger was, moved by the same rule so it lands on exactly
the same pixels, and the hamburger itself toggles rather than only
opening. Escape closes it too, for a tablet with a keyboard.

Raising the top bar over the drawer instead would have been the smaller
change and is not available: the drawer is at 950 and a full-screen
composer at 800, so a top bar above the first is also above the second.
2026-08-31 22:04:25 -07:00
jcoffey-dev 6c958b8609 Folders one level at a time on a phone, and targets a thumb can hit
Three things the mobile interface got wrong, all of them measurable.

The folder tree spent its width on depth. Four levels down, the 16px
indent steps and the 18px twisty left a folder 85px of a 300px drawer to
print its name in, and the twisty had walked far enough right that
hitting it was luck -- a miss landed on the row, which is a link, so the
wrong tap also cost a navigation. Under 768px the tree is now a
drill-down: one level at a time, no indent, a back row above it, and a
chevron at the right edge that is the same size in the same place on
every row. Tapping the row still opens the folder; only the chevron
changes what the list shows. The tree is untouched above 768px, where a
wide sidebar can afford the indent and where dragging a folder onto
another folder -- still the only way to reparent one -- needs both of
them on screen at once.

Every control in the top bar was under the 44px a fingertip covers: the
icons at 36, the search filter at 30, the row menu at 24, and the
hamburger 6px from the bezel in the corner a thumb is worst at. They
keep the size they draw at and gain a transparent hit area, since
growing the boxes would reflow a bar with no room to give; rows grow for
real, because a 44px target inside a 36px row reaches into its
neighbours. The one exception is the row menu, held to 36px wide: at a
full 44 it overlapped the drill chevron by 4px, so its right edge
silently drilled instead.

Pinch was dead on the message list. `.msg-row` sets `touch-action:
pan-y` to feed the swipe gesture the horizontal movement the browser is
not using -- but naming any value drops every gesture not named, zoom
included. It worked on an open message and died on the list, which reads
as the zoom being broken at random rather than as a rule about rows.
`pan-y pinch-zoom` keeps the swipe and gives the zoom back.
2026-08-31 21:47:30 -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 f94cc2ce51 Translate the labels the extractor could not see
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.
2026-08-31 13:20:39 -07:00
jcoffey-dev 12f08acd32 Add a Japanese interface catalogue
781 strings, machine-made and marked Beta, on the same terms as the
languages before it: the report link stands in for the native speaker we
do not have, and a missing entry falls back to English.

Plurals: there are none. Intl.PluralRules("ja") returns `other` for every
number, so each counted string carries one form. Counters do the work a
plural would — 通 for messages, 件 for conversations and items — which is
why "{n} messages" and "{n} conversations" are separate entries rather
than one pattern. The number alone does not decide the word after it.

Register is です・ます throughout, with the pronoun dropped: where English
says "your mailbox" this file usually just says メールボックス. Buttons
are the bare noun or verb stem — 送信, 返信, 削除 — not a sentence, which
is what every other mail client the reader has used does.

Script mixing is deliberate. Kanji for the noun carrying the meaning,
katakana for the loanword the reader already knows (メール, フォルダー,
アーカイブ), hiragana between them. Long vowels keep their ー.

Verified against the mock server: role folders localise and custom ones
are untouched, dates follow the language, and "{used} of {total}" comes
out reordered as "2.0 GB 中 700 MB" rather than word-for-word.
2026-08-31 13:05:33 -07:00