Commit Graph
443 Commits
Author SHA1 Message Date
Linus Rath de3d697aa9 feat: scroll the calendar week and day views sideways #759
The week view showed one week and only the arrows moved it, and there
was no day view at all (a "day" default view opened the agenda). With
free scrolling on (the default) both are now one strip of day columns
that scrolls sideways, as in the webmail (15a5497a):

- the week view shows seven columns and snaps to single days; the new
  day view shows one column and moves a day per swipe; the title
  follows the first day in view and the arrows step from it
- reaching either end adds days and loads only their events; days added
  at the start keep the columns on screen in place
- the day headers and the all-day strip follow the sideways scroll on
  the native thread; all-day bars are cut per week (per day in the day
  view) so each part carries its title
- columns are windowed and lay out their events once per change

The day view is offered as a default view again. With free scrolling
off, or in right-to-left layouts, the week view pages as before and the
day view shows a single day.
2026-09-24 20:25:40 +02:00
Linus Rath caf61ece79 feat: scroll the calendar month view continuously #759
The month view showed one month at a time and only the arrows moved it.
With the new "Free scrolling" setting (on by default, the webmail's
`calendarFreeScroll`) it is one continuous list of weeks, as in the
webmail (15a5497a, ae403b06):

- the month under the upper part of the grid is drawn at full strength,
  names the title and is where the arrows step from; the first day of
  each month carries the month's name
- reaching the top or the bottom adds weeks there and loads only their
  events; weeks added above keep the rows on screen in place
- rows have a fixed height and snap to whole weeks, so the list stays
  windowed and navigation lands exactly on the month

Turning the setting off brings back the paged month and a fixed 30-day
agenda. The week view still pages.
2026-09-24 20:25:40 +02:00
Linus Rath 71522b9b92 feat: scroll the calendar agenda endlessly instead of a fixed 30 days
The agenda listed 30 days from the current date and the arrows paged
through it 30 days at a time. It now keeps a window of days that grows
as the user scrolls, like the webmail's agenda (60e05c00):

- reaching the end loads further ahead, doubling that side up to a year
- "Show earlier events" loads the past without moving what is on screen
- "Today" and the arrows scroll to their day while it lies inside the
  window and start a fresh window there otherwise; the title follows the
  day in view
- the "Today" row only anchors the list when today was actually fetched

Growing the window fetches only the new days: `extendRange` in the
calendar store loads the missing part and merges it into the loaded
window (up to about three years) instead of refetching all of it.

Navigation now goes through one focus for every view, so the week
view's arrows move the grid (it kept showing the selected day's week)
and the month arrows carry the selected day along.
2026-09-24 20:25:40 +02:00
Linus Rath 67935d2edc feat: translate the app-only strings into all 26 languages
Every language now carries its own `locales/rn/<lang>.json` with the
roughly 600 strings the webmail catalog does not have, so settings,
sign-in, files, calendar and push screens no longer fall back to
English.

Terms and the form of address follow each language's vendored webmail
catalog, and plural messages use the language's own CLDR categories.
Wording that had no precedent in the catalog would benefit from a
native speaker's review.
2026-09-24 20:25:40 +02:00
Linus Rath d7c890869b fix: reuse the startup identity read for the first message open 2026-09-24 20:20:59 +02:00
Linus Rath 4f3940f1d3 fix: catch up on a folder's changes in one request
Refreshing an open folder that was already loaded took up to four
round trips in a row: `Email/queryChanges`, `Email/changes`, `Email/get`
of the added and changed rows, and a `Thread/get` for their
conversation sizes.

They now go in one request, the `Email/get` of the added messages and
the `Thread/get` of their threads chained by result reference to
`/added/*/id` and `/list/*/threadId`. The list's own query and Email
states still drive it. Each part can fail on its own: a query the
server cannot diff falls back to the full re-query as before, an Email
state it cannot diff is forgotten as before, and added messages the
chained `Email/get` refused (more than one call takes) are fetched
apart. Only further `Email/changes` pages and rows that changed in
place without being re-added cost more requests.
2026-09-24 20:20:59 +02:00
Linus Rath 9c2f819eac fix: load the folder list once, and right away, when signing in
Signing in sent two full `Mailbox/get`s before the inbox loaded, and
the first only went out once the main screen had mounted and painted.
There is no deliberate delay behind the gap: the mail screen's mount
effect started the folder load, and the push setup read the same
folders again with a `Mailbox/get` of its own (plus the shared
accounts') just to find the Junk folders for its filter. The latter
also happened on every start with push notifications on.

- Sign-in (password and OAuth/pairing) starts the folder load as soon
  as the session is up, like a restored session already did.
- The mail screen's mount now joins a load on its way
  (`ensureMailboxes`) instead of queueing another behind it.
- The push filter takes the folders from the mail store (joining its
  load) and only fetches them itself when the store has none for the
  account.
2026-09-24 20:20:58 +02:00
Linus Rath 9fa931c589 fix: sync at once on resume and stop pinging the server while live updates run
Coming back to the app first waited for a `Core/echo` (sometimes two:
the keep-alive timer that was due fired alongside) and for the event
stream to be set up before the catch-up sync started. A failed echo
only led to `retrySession`, which does nothing while the session is
live. On top of that, a `Core/echo` went out every 30 s although the
event stream's own pings already show the server is reachable.

- On resume the stream reconnects and the folders and open list are
  synced right away; one echo runs alongside for the connection dot.
- The 30 s echo is only sent while the event stream is not open
  (polling fallback, reconnecting, client certificate). An open stream
  whose pings arrive (the live-updates handle's new `healthy`) marks
  the account connected instead.
- Echoes never overlap, and a periodic one right after the resume one
  is skipped.
2026-09-24 20:20:58 +02:00
Linus Rath 1196ad56c2 fix: probe the server's keyword sort order once instead of on every start
The list sorts pinned (and unread, starred or tagged, with an order
preset) messages first with `hasKeyword` comparators, and Stalwart reads
their direction the other way round (#718). To find out, the app sent a
probe request before the first list load of every start, because the
verdict was only kept in memory.

A conclusive verdict (RFC order, inverted, or keyword sorts refused) is
now stored per server and account under a versioned key and reused on
later starts. It is probed again once it is a week old, since a server
upgrade may change it. An inconclusive probe is still only kept in
memory and retried after a few minutes.
2026-09-24 20:20:58 +02:00
Linus Rath 3bf2c30c99 fix: stop asking for the Stalwart principal on every start and message open
Every sign-in and app start read the account's full name with
`x:Account/get`, and the first message opened (invitation or not)
looked up the account aliases with it too. Stalwart refuses that method
to everyone but admins, so for most users both were wasted requests
that always came back `forbidden`, and the full name was never
refreshed.

- The full name now comes from `x:AccountSettings/get` (its
  `description`), which every user can read, like the webmail (#900).
- The alias lookup only runs for a message that carries a calendar
  invitation (and the calendar).
- A refusal of `x:Account/get` is remembered per account for the
  session, so it is asked at most once.
2026-09-24 20:20:58 +02:00
Linus Rath 018accd865 fix: stop re-reading the mail list when the server echoes our own changes
Every change the app made (read, star, move, send, a draft save) came
back as a push, and the push re-read the open folder and the whole
folder list: `Mailbox/changes`, `Mailbox/get`, the shared accounts'
`Mailbox/get`, `Email/queryChanges`, `Email/changes`, `Email/get` and
`Thread/get`, often twice. One send cost about 10 requests.

- `jmapClient.request` now logs the Email states each of our own
  `Email/set` (also the implicit one of `onSuccessUpdateEmail`),
  `Email/import` and `Email/copy` responses moved between, with what
  they created, patched and destroyed.
- When a push reports an Email state that only our own writes led to
  from the state the open folder was read at, the store applies those
  writes to the rows (the optimistic updates mostly did already), moves
  the list's state on and skips the refresh. It still re-reads when
  anything else changed, when new mail was delivered, when a write adds
  a message to the folder or changes a keyword the list is sorted on,
  and when the folder's count shows a message it does not hold left.
  A push that overtakes its write's response waits for it briefly.
- A folder push re-reads only the part of the folder list that
  changed: the own folders or the shared accounts', and nothing for a
  Mailbox state it already holds.
2026-09-24 20:20:58 +02:00
Linus Rath 1c7c2a3be8 fix: stop reloading every account when returning to the unified inbox
The unified inbox reloaded page 1 of every account each time it came
back into focus, so opening a message and going back cost one
`Email/query` per account even when nothing had changed.

It now reloads on focus only when something may have changed since the
last load: the inputs or the active account changed, the live session
reported an `Email` state change, the opened message was unread (the
thread screen marks it read), the app was in the background (the event
stream is closed there), or accounts without a live session are shown
and the last load is over a minute old. An `Email` change that lands
within a few seconds of a focus that skipped the reload (the echo of an
archive or delete from the thread screen) reloads then.
2026-09-24 20:20:58 +02:00
Linus Rath 49ed48791d fix: name the webmail's Link Mobile App page in the QR sign-in hints 2026-09-24 20:16:36 +02:00
Linus Rath ef143a4dbb fix: take a conversation's size from the list row's own account
The placeholder cards held for a loading conversation looked its size up
by bare thread id whenever the open folder was the message's account.
Rows of an "All folders" search or a tag view now come from several
accounts and the list keys their conversation sizes by account, so the
viewer looks the size up the same way, and only for pages that are the
list's own rows.
2026-09-24 20:12:17 +02:00
Linus Rath 2a055ddfe2 fix: keep a star set while the mark-as-read delay runs 2026-09-24 20:12:17 +02:00
Linus Rath cd911ada79 fix: show toasts on every screen, including the Unified Inbox and contacts
Only the tab screens and the viewer had a toast host, so a viewer
action that failed after going back to the Unified Inbox or a contact
(both stack screens of their own) raised a toast nobody saw.

Every stack screen now gets a host through the navigator's
`screenLayout`, and only the focused screen's host renders, so the
screen kept live under the top one never shows the same toast a second
time. The tab screens' and the viewer's own hosts are gone.
2026-09-24 20:12:17 +02:00
Linus Rath e0b72883ac fix: show the tapped message when the viewer is already open
Opening the viewer while it was on top, e.g. by tapping a notification,
did not push a new screen: React Navigation handed the new target to
the same route as new params, and the viewer, which fixes its message,
account and page list when it opens, ignored them. The tap did nothing.

The route now mounts the viewer afresh for every new target it is
given, keyed by the message, its account and the open, so the new
message is shown with its own page snapshot. Params that stay the same
keep the viewer as it is.
2026-09-24 20:12:17 +02:00
Linus Rath 54b169c6c7 fix: open a message at its full height and keep the quick reply still
Every body started as a 120 px strip that grew once the page had
measured itself, so an open showed a skeleton, then a clipped strip of
the body, then the full body, and the quick reply box below it jumped
down as the WebView grew. A conversation arriving after the message
swapped the single-message layout for cards, which reloaded the body
and pushed it down under a "Loading conversation" row.

- `lib/body-heights` remembers the last measured height of each body
  (per width); a body shown before starts at that height.
- A page showing one message stretches its body over the space below
  the header until it has measured itself, and the quick reply sits at
  the bottom of the page, so it does not move whether the body turns
  out shorter or longer. Conversation cards start from an estimate
  (most of a screen for HTML, the wrapped line count for plain text).
- A single message is rendered through the conversation's card list,
  so its body stays mounted when the conversation arrives; while it
  loads, the size the folder list knows for it holds the older cards'
  places, and the loading indicator sits in the subject row.
- An open card whose body has not arrived shows its header over
  placeholder lines, with its reply buttons disabled until it has.
2026-09-24 20:12:17 +02:00
Linus Rath 6ebadf738f fix: sanitise a message body once instead of on every mount
The body view reset its inline-image map to a new empty object right
after mounting, which rebuilt the sandboxed document, so every body was
sanitised twice per mount. A body that mounted again (the page beside
the open one becoming the open one, a card closed and reopened, the
message opened a second time) was sanitised from scratch as well.

The document is now built by `lib/email-body-document`, which keeps the
last ten per message, body and rendering settings and hands back the
same one when they match, and the mount no longer replaces the empty
map.
2026-09-24 20:12:17 +02:00
Linus Rath 01fc6fbba5 fix: let the pages beside an opened message wait until it has loaded
The pager renders the pages before and after the open one, and each
fetched its message and conversation and mounted its own WebView at the
same time as the message the user had tapped, which competed with it
for the network, the JS thread and the WebView process.

The neighbouring pages now show their header and placeholder lines
until the open message's body has reported its height, the user starts
to swipe, or 1.5 s have passed. Their details are then loaded together
in one request, and their conversations and WebViews follow.
2026-09-24 20:12:17 +02:00
Linus Rath 7cdebc465a fix: stop asking for the identities on every message open
The viewer and the read-receipt banner each sent `Identity/get`
whenever the identity list was empty. Identities are not persisted, so
the first open of every session asked twice, and an account the server
lists no identities for asked on every open.

The settings store now remembers which signed-in account (server,
login, JMAP account) its identities were read for, and the viewer and
the banner only ask when they are not held for the current account.
Concurrent reads share one request. Switching accounts drops the old
account's identities instead of letting the quick reply send through
one the new account does not have.
2026-09-24 20:12:17 +02:00
Linus Rath ad1f053f72 fix: offer archiving in the viewer for a folder named Archive without the role 2026-09-24 20:12:17 +02:00
Linus Rath 1348e7d20a fix: start loading a message on the tap, before the viewer opens
The detail and conversation requests only went out once the viewer had
mounted and its effects ran, after the navigation had rendered.

A tap on a row in the folder list, the Unified Inbox or a contact's
activity, and a notification tap, now start the message's detail and
its conversation (when threading is on) through the shared cache, and
the viewer joins the requests already in flight. It runs on the tap and
not on press-in, because on a phone a touch that starts a scroll
presses rows too.

The Unified Inbox and contact activity also leave their rows in the
cache, so pages handed over by id get their header painted from the row
(and the right thread id) instead of a skeleton.
2026-09-24 20:12:17 +02:00
Linus Rath ac8e3fb2e9 fix: paint the message header from the list row while the body loads
The viewer showed a full skeleton until the message's detail had come
back, although the list row it was opened from already holds the
subject, sender, date and avatar. The header appeared about 0.4 s after
the tap.

A page whose list row is known now renders the real subject and header
from that row straight away, with placeholder lines where the body
goes; the body (and the star, reply and quick reply that need the full
message) follows when the detail arrives. Pages handed over by id only
(notification, deep link) keep the skeleton.
2026-09-24 20:12:17 +02:00
Linus Rath 33e303e248 fix: open a message from the cache and fetch only the open cards' bodies
Every open went to the network for the full message, even for a message
the viewer had just shown, and the conversation fetch then downloaded
every member's full body again, the opened one included. The pager's
three panes each started their own detail and thread fetch, so one open
from the Unified Inbox fetched the body twice and the thread twice.

Messages and conversations now live in a shared cache
(`lib/email-detail-cache`), keyed by server, login and JMAP account:

- a held copy renders at once; bodies never change, so it is only
  checked, with a keywords/folders-only `Email/get`, when the account's
  Email state (from push or our own reads) moved on or the list row the
  message was opened from disagrees with it;
- a copy in the offline cache is shown before anything goes out and is
  checked the same way;
- a conversation is listed from its members' headers (`Thread/get` and
  list properties in one request) and bodies are downloaded only for
  the open cards, in one batched `Email/get`;
- concurrent loads of the same message or thread share one request.

The store's network-first `getEmailDetail` is gone; its offline-copy
refresh moved into the cache.
2026-09-24 20:12:16 +02:00
Linus Rath 96c5a3f046 feat: let attendees answer a single occurrence of a recurring event
An answer to an invitation always went to the whole series, even when
given on one occurrence. The webmail asks which occurrences it covers
(#1086); the app now does too.

- answering on an occurrence opens the scope dialog with "This event
  only" and "All events" (an attendee cannot split the organizer's
  series, so "This and following" is not offered)
- "This event only" writes the answer to that occurrence: through its
  synthetic id, or as an override on the base event for one expanded
  on the device, with the whole participant map, the organizer and
  the sequence (`buildOccurrenceRsvpPatch`), so Stalwart replies with
  the occurrence's RECURRENCE-ID
- "All events" and answers on single events work as before
2026-09-24 20:10:08 +02:00
Linus Rath f397ee3e1b feat: change or delete one occurrence through its own id
"This event only" wrote a `recurrenceOverrides/<id>` pointer on the
master for every occurrence. That pointer fails while the series has
no overrides yet, replaced whatever the occurrence already overrode,
and left the new override without the occurrence's title, place or
reminders. Deleting one occurrence went the same way.

The store now keeps a change to an occurrence on that occurrence, the
way the webmail's `resolveMutationTarget` does:

- a server-expanded occurrence is updated or destroyed through its
  synthetic id; a server that refuses synthetic ids gets the same
  change as an override on the base event, and is remembered
- an occurrence expanded on the device becomes an override on its base
  event that keeps the existing override, pins start and duration,
  and is sent as the whole map when the series has none yet
- a new override carries the occurrence's details and sequence
  (`withNewOverrideDetails`), so it neither loses them nor ranks below
  the series
- "All events" and "This and following" still go to the master

The calendar's scope dialog hands "This event only" to `updateEvent`
and `deleteEvent` with the occurrence's id, and
`buildRecurrenceOverridePatch` is gone.
2026-09-24 20:10:08 +02:00
Linus Rath 84548898c1 feat: let the server expand recurring events when it can
Recurring series were always fetched whole and expanded on the device,
the webmail's fallback for Stalwart before 0.16.20. Newer servers
expand them with `expandRecurrences` and accept the synthetic
occurrence ids in `CalendarEvent/set`; the webmail uses that since
`lib/recurrence-instances.ts`.

- `supportsSyntheticCalendarIds` probes once per server and account
  with an update of an id that cannot exist (`notFound` means
  supported); the probe rides along with the first `Calendar/get`
- when supported, a range is loaded with `queryExpandedEvents`, the
  occurrences are fetched with `baseEventId` and hydrated with their
  base event's rules, overrides, all-day flag and duration; duplicate
  listings of one occurrence are collapsed like the webmail does
- a range the server refuses to expand ("expanded recurrences
  exceeds") and older servers keep the device-side expansion, which
  now passes server occurrences through untouched
- changes, deletes and RSVPs resolve a synthetic id to its base event
  (`resolveSeriesTarget`), including a master fetched for an
  occurrence of a shared calendar, and reload the range afterwards
  since synthetic ids reshuffle
- reminder keys use the base event and recurrence id, so a reshuffle
  does not reschedule them
- client-only fields are stripped from `CalendarEvent/set` payloads
2026-09-24 20:10:08 +02:00
Linus Rath 1a651a9c25 fix: read floating task dues in the calendar time zone like events
A task due without a time zone was read as device wall time, while
Stalwart computes a floating event's start in the calendar time-zone
setting the app sends with every query. With that setting away from
the device zone, a floating task and a floating event at the same
wall time showed hours apart, and task reminders fired at the device
reading.

`getTaskDueDate` now resolves a floating timed due in the effective
calendar zone; dues with their own zone and date-only or all-day dues
are unchanged. The device zone is cached for 30 seconds, since task
sorting resolves a due per comparison and building an
`Intl.DateTimeFormat` is slow on Hermes.
2026-09-24 20:10:08 +02:00
Linus Rath 83db70ac86 fix: open the event when a calendar reminder is tapped
Tapping a reminder notification only brought the app to the front:
nothing listened for expo-notifications responses, so the event never
opened.

- reminders now carry the event's server id, account, occurrence,
  start and the signed-in account alongside the store id
- `startCalendarReminderTapHandling` picks up the reminder that
  launched the app and any tapped while it runs; it only acts on
  reminders it scheduled, and mail push taps keep coming through the
  native tap store
- the app switches to the reminder's account if needed, parks the
  target in `pending-calendar-open` and opens the Calendar tab, which
  shows the event in the detail sheet or the task in the tasks sheet
- the event is looked up by its server identity, first in the loaded
  window, then in the day around its start, then as the stored event,
  so an occurrence outside the visible month still opens; a deleted
  one says "This event is no longer available."
2026-09-24 20:10:08 +02:00
Linus Rath a4ac6cad5c fix: list the events an .ics import could not bring in
A partial import dropped the refused events silently: the sheet said
"Imported 3 events" for a file of five, and the server's reason never
reached the user. The webmail reports how many events failed and why.

- `batchCreateEvents` returns the refused events by index with the
  server's reason instead of just the created count
- `importEvents` resolves with `{ imported, refused }`, also counting a
  refused link of a UID that lives in another calendar and every event
  of a chunk whose request failed; when nothing got in it rejects with
  an `ImportRefusedError` carrying the list, so the invitation banner
  still never reads a refusal as "Already in your calendar"
- the import sheet shows the imported count and lists each refused
  event with its date and reason
2026-09-24 20:10:07 +02:00
Linus Rath a7e4e84f5d fix: say when saving a task fails and keep what was typed
Saving in the tasks sheet ran `onCreate`/`onUpdate` in a `try/finally`
without a `catch`, so a refused create or update became an unhandled
rejection: the spinner stopped, nothing was shown, and the user could
not tell whether the task was saved.

The editor state and the save logic move to `src/lib/task-editor.ts`.
`submitTaskEditor` returns the server's reason instead of throwing; the
sheet shows it in an alert and leaves the editor untouched so the input
can be fixed or saved again. It is cleared only after a successful save.
2026-09-24 20:10:07 +02:00
Linus Rath e0a79ca5c6 test: keep the locale tests independent of which overlay keys are translated 2026-09-24 20:07:00 +02:00
Linus Rath edab181e60 fix: load list attachment chips from each search hit's own account #1082
The attachment chips on list rows fetched every row's parts from the
account of the open folder. In an "All folders" search or a tag view the
rows come from several accounts, so a hit from the team inbox asked the
user's own account, which answers for another message with the same id,
or not at all.

A row that carries an account stamp now loads its chips, and opens an
attachment from them, in its own account; the chips a row remembers are
keyed by that account too.
2026-09-24 20:05:38 +02:00
Linus Rath d097ae9ec3 fix: stop re-counting every tag each time the sidebar opens
Every drawer open sent the whole set of tag count queries again (14
Email/query calls for 7 tags, twice that with a shared account), although
nothing had changed since the last open.

The counts now live in a small store, keyed by the login, the accounts
and the tags they were fetched for. Opening the drawer only fetches when
there are no counts for that key yet or when an Email change was
reported for one of the accounts since (push or the poll fallback); a
change that arrives while the drawer is open refreshes them there and
then. Overlapping opens share one request, and a change that lands while
the counts are in flight leaves them due for another fetch.
2026-09-24 20:05:38 +02:00
Linus Rath 8d5bced7f3 fix: list and count tagged mail from every account in the tag view #1038
A tag sits on messages in the user's own account and in the shared
(group) accounts alike, but the tag view only asked the account of the
open folder, and the sidebar badge only counted the own account: with 2
own and 2 team messages tagged Red, the Red view showed 2, and which 2
depended on the folder it was opened from.

The tag view now fans out like the "All folders" search (#1082), which
it is: every folder of the own account and of each shared account with
folders in the sidebar, in one request, with rows stamped with their
account so opening, untagging and every other action reach the right
one. A search inside the tag view narrows it in every account.

The tag badges sum the counts of the same accounts; the query pairs for
all of them go out packed into as few requests as `maxCallsInRequest`
allows, and an account that can't be reached counts nothing instead of
failing the badges.
2026-09-24 20:05:38 +02:00
Linus Rath ee50418bac fix: search the shared accounts' folders too with "All folders" #1082
"All folders" sent its Email/query only to the account of the open
folder, so mail in a shared (group) account the same login reaches never
showed up: "zephyr" found the 3 own messages and missed the 5 in the team
inbox, although that inbox sits right there in the sidebar.

The search now asks the own account and every shared account with
folders in the sidebar, like the webmail's `searchAcrossAccounts`:

- one request carries Email/query, Email/get and Thread/get for each
  account (`queryEmailPagesAcrossAccounts`), and the pages are merged
  under the list order
- each row is stamped with its JMAP account (`Email.jmapAccountId`), so
  opening a hit opens it in that account and read, star, tag, archive,
  delete, spam and move act there, filing into that account's own
  Archive, Trash or Junk; one undo restores rows in every account
- "load more" continues each account from its own rows on screen rather
  than one shared offset, which would skip messages of the account with
  more hits
- an account that fails leaves the others' hits on screen with a notice
- a push for any account re-runs the search
- list keys, thread grouping and conversation sizes are scoped by
  account, as JMAP ids are only unique per account
- deleting a hit judges Trash and Junk by the hit's own folders, not the
  open folder, both for the move and for the permanent-delete confirm

A search scoped to one folder still asks only that folder's account.
2026-09-24 20:05:38 +02:00
Linus Rath ddff2f7b66 fix: queue star, tag and unread from the viewer while offline
The viewer's star, tag and mark-unread buttons called the API
directly, so offline they failed and the list row behind the viewer
kept its old state. They now go through the email store's keyword
action like the list's selection actions: offline the change waits in
the outbox, and the list row follows. The store action takes the
viewed message and its account, so it only touches the list when the
list holds that account and never a same-id row of another one.
2026-09-24 19:45:07 +02:00
Linus Rath d8db4a5597 fix: show the right image when two attachments share a name
The reader's attachment preview wrote every file to the same
`bulwark-exports/<name>`. React Native caches images by path, so a
second attachment with the same name (such as Outlook's `image001.png`)
could show the first one's picture. The preview now uses a temp folder
of its own per preview, as the Files tab does, also for parts unpacked
on the device, and deletes it when the preview closes unless the file
was shared to another app. A download that finishes after its preview
was closed no longer reopens it.
2026-09-24 19:45:07 +02:00
Linus Rath 1fb60a5c61 fix: tell the user when an automatic read receipt fails to send 2026-09-24 19:45:07 +02:00
Linus Rath 115e162b86 fix: delete large file selections in batches the server accepts
Deleting files sent every selected id in one FileNode/set. A selection
larger than the server's `maxObjectsInSet` was refused as a whole, so
nothing was deleted. The ids now go out in batches of that size; a node
already removed with its folder by an earlier batch counts as deleted,
and a refused item no longer stops the remaining batches.
2026-09-24 19:45:07 +02:00
Linus Rath a3193ebaf7 fix: create new groups in the address book being viewed 2026-09-24 19:45:07 +02:00
Linus Rath 40ace883b7 fix: keep a contact's recent-email previews on one line 2026-09-24 19:45:07 +02:00
Linus Rath 9135deed80 fix: translate the no-subject fallback in mail lists and notifications
The mail list, the unified inbox, a contact's recent emails and new-mail
notifications showed a hard-coded English "(no subject)". They now use
the webmail's `email_viewer.no_subject` (and `contacts.activity.no_subject`
for the contact view). The notification task reads the in-app language
override itself, as it cannot use the locale store.
2026-09-24 19:45:06 +02:00
Linus Rath 3c7b96e11a fix: remove shared-file copies left behind by an upload cut short
Files shared into the app are copied into the cache as `upload-*`
before they are uploaded. When the app was killed mid-upload the copy
was never deleted. The launch-time sweep of stale export files now also
removes upload copies made by an earlier run of the app; copies of the
current run may still be uploading and are left alone.
2026-09-24 19:45:06 +02:00
Linus Rath 2e78c9870c fix: remove the unsent copy when the server refuses a send
A send creates the message in Drafts and submits it in the same request.
When the server refused the submission, the created copy stayed in
Drafts without anyone tracking it, so sending again created a second
copy and left the first behind as a stray draft. The copy is now
destroyed again when the submission is refused; the composer still
holds the message and the previous draft version is left alone.
2026-09-24 19:45:06 +02:00
Linus Rath 46e285b3c9 feat: search settings from the Settings screen
The webmail's Settings screen has a search field; the app had none, so
finding a setting meant opening panes one by one. Settings now has a
search field above the pane list that narrows the list to the panes
that match and lists up to six matching settings under each.

The index follows the webmail's `lib/settings-search.ts`: per pane, the
translation keys it renders plus a few English keywords, walked for
label/description pairs. The paths list what the native panes show
rather than the webmail's, the walker prefers the `*_mobile` variants
the panes render, and every label goes through t() for the current
language. A test checks that each result is a key its pane renders,
so results do not point at webmail-only settings.

Tapping a result opens the pane, scrolls to the matching row and tints
it briefly. Back clears the search before leaving Settings.
2026-09-24 19:40:08 +02:00
Linus Rath 4c9efbc44d fix: stop an open settings page from catching back on other tabs 2026-09-24 19:40:08 +02:00
Linus Rath 7964e646c4 feat: add the Roundcube Elastic and Aurora Glass themes
The Themes pane offered four of the webmail's built-in themes. The
webmail also ships Roundcube Elastic and Aurora Glass, so their colour
tokens are now mapped onto the native palette the same way as the
other four, for both light and dark.

Aurora Glass dark uses translucent surfaces that the webmail frosts
with a backdrop blur. RN has no blur, so those fill tokens are
composited onto the theme background instead of letting the content
underneath show through. The skins (fonts, radii, Elastic's dark task
rail, Aurora's gradient mesh) and the "Flat fields" theme are not
ported.
2026-09-24 19:40:08 +02:00
Linus Rath 008bff29f8 feat: load app-only strings in every language, not just English
The RN overlay (`locales/rn/en.json`, the strings the webmail catalog
does not carry) only existed in English, so those 600 or so labels
showed in English whatever language the app was set to.

- load `locales/rn/<lang>.json` for every language next to its
  vendored catalog; an untranslated key still falls back to English
- a test keeps each translation to English keys and the same ICU
  arguments
- `node scripts/rn-missing-translations.mjs [lang]` lists what a
  language still lacks
2026-09-24 19:39:48 +02:00