Compare commits

...
127 Commits
Author SHA1 Message Date
Linus Rath e0f18713aa chore: update version number to 0.1.81 2026-10-16 19:27:04 +02:00
Linus Rath 56d826c508 test: cover how the PDF preview reports protected and unreadable files 2026-10-16 19:24:19 +02:00
Linus Rath e5492b89c8 feat: show PDFs in the app on Android 2026-10-16 19:23:17 +02:00
Linus Rath b50436b1ba chore: update version number to 0.1.80 2026-10-16 19:12:43 +02:00
Linus Rath 11e74042e2 fix: hand files with a generic type to other apps by the type their name implies 2026-10-16 19:11:50 +02:00
Linus Rath 95580b84ea feat: play video and audio, and show SVG, HTML and Markdown files in the preview 2026-10-16 19:11:24 +02:00
Linus Rath 9a15334bef chore: update version number to 0.1.79 2026-10-16 18:49:43 +02:00
Linus Rath 3aca2d80c0 fix: preview .eml attachments that carry attachments of their own 2026-10-16 18:48:26 +02:00
Linus Rath 7c534301ee refactor: open files from mail, the list, Files and compose through one preview 2026-10-16 18:48:26 +02:00
Linus Rath 14dc5ff2d4 Merge pull request #76 from lucletoffe/feat/calendar-sort-order
feat(calendar): order calendars by sortOrder and move them from the drawer
2026-10-15 23:37:37 +02:00
Linus Rath 14ba28c79c Merge pull request #74 from lucletoffe/fix/compose-visual-viewport
fix(composer): stop the body's first lines hiding under To/Cc/Subject on Android
2026-10-15 23:23:40 +02:00
Linus Rath 598adc154d Merge pull request #72 from lucletoffe/fix/draft-part-blobids
fix(drafts): keep a re-opened draft's attachments on live blobs across saves and sends
2026-10-15 23:23:26 +02:00
lucletoffeandClaude Opus 5.5 3c0af52c4e feat(calendar): order calendars by sortOrder and move them from the drawer
Calendars were listed in the order the server returns them (creation
order on Stalwart) and sortOrder was never read. Sort them like the
webmail now does and as JMAP Calendars asks: lower sortOrder first,
equal sortOrder by name (case- and accent-insensitive, numbers by
value), each account's calendars kept together. The store keeps the list
sorted, so the drawer, the event editor and the other pickers agree.

The drawer's long-press panel gains Move up / Move down for the user's
own calendars. A move renumbers that account's calendars 1, 2, 3... and
writes sortOrder only where it changes; the panel stays open so a
calendar can be moved several places.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 22:07:33 +02:00
Linus Rath 7108431dbb chore: update version number to 0.1.78 2026-10-15 16:58:08 +02:00
Linus Rath 744b17908d fix: open a settings page at its top instead of the list's scroll position 2026-10-15 15:36:59 +02:00
Linus Rath c7edae9ed3 fix: move a setting's control under its label when a long label leaves no room 2026-10-15 15:29:13 +02:00
Linus Rath c07b39a070 fix: keep device sync free of expo by registering device locales from i18n #75 2026-10-15 15:19:18 +02:00
Linus Rath 7e5e296271 fix: use the app switch for the sender override in compose 2026-10-15 15:16:32 +02:00
Linus Rath ad6dff79ba fix: draw contact form fields like the webmail and quiet the letter headers in the contact list 2026-10-15 15:16:32 +02:00
Linus Rath a4ca72fb3d fix: draw event and task fields like the webmail and use the app switch in the event form 2026-10-15 15:16:32 +02:00
Linus Rath 107eb807e3 chore: update version number to 0.1.77 2026-10-15 15:15:29 +02:00
Linus Rath e731357b89 fix: format dates in the device region for the app language #75 2026-10-15 15:15:19 +02:00
Linus Rath 9cf1d3e849 fix: draw the text field in dialogs like the other inputs 2026-10-15 15:04:01 +02:00
Linus Rath d3dcc4e830 fix: show folder roles, filter origins and app modes as outline badges 2026-10-15 15:04:01 +02:00
Linus Rath e0d2b190d3 fix: give the Files header the same title size as Mail and Contacts 2026-10-15 15:04:00 +02:00
Linus Rath b048eaca03 fix: tint unread mail and draw the mail search, subject, quick reply and thread buttons like the webmail 2026-10-15 15:04:00 +02:00
Linus Rath 6f515e15f7 feat: rework settings into the picked list, About and Storage & backup pages
The settings list now has the account card on top, title-case group
labels and 56 dp rows that show each pane's current value. About and
"About & Data" were one placeholder page with a letter logo, a fixed
name and a store link that pointed at GitHub releases.

- About: the real logo, the app name from app.config.js, the update
  state, a source link that a build can set with SOURCE_CODE_URL, and
  rows for the server, update checks (Android), open-source licences,
  privacy and a prefilled GitHub issue. iPhone updates link TestFlight.
- Storage & backup: usage card, offline mail, settings export/import
  and reset, and a Troubleshooting page for refresh and debug logging.
- Sender favicons moved to Appearance, like the webmail.
- The S/MIME and Plugins stub pages are gone; both are webmail plugins.

The licence list is generated by `scripts/generate-licences.mjs`.
2026-10-15 14:49:37 +02:00
Linus Rath 0a176ab51c feat: draw buttons, switches, selects, inputs and badges like the webmail 2026-10-15 14:49:37 +02:00
Linus Rath 75299244cf refactor: draw the webmail's Tabler icons instead of Lucide 2026-10-15 14:13:02 +02:00
Linus Rath 6aaa479875 chore: update version number to 0.1.76 2026-10-15 08:32:45 +02:00
Linus Rath 124c0258ad docs: add the final report to the native ui and ux audit 2026-10-15 04:19:41 +02:00
Linus Rath ccffd6b10d docs: tick the latest audit findings and note the deferred ones 2026-10-15 04:09:03 +02:00
Linus Rath 0d96d8d044 fix: fit every selection action and the count in the mail list header on a phone 2026-10-15 04:08:33 +02:00
Linus Rath 95c31631fb fix: show event titles instead of bare start times in a narrow month view 2026-10-15 04:05:55 +02:00
Linus Rath cf7275c9c3 fix: write calendar dates in the order of the app language 2026-10-15 04:05:55 +02:00
Linus Rath e452f72a8a fix: keep the all-day label in the day and week grid on one line 2026-10-15 04:05:49 +02:00
Linus Rath 776447dd41 fix: give sheet close buttons, chips and small text buttons a full-size touch target 2026-10-15 04:05:40 +02:00
Linus Rath 52eb18dc33 fix: show unified inbox rows with the list's text size, density and preview settings 2026-10-15 03:37:58 +02:00
Linus Rath 33dc3cd0dc fix: move through the contact name fields with the return key 2026-10-15 03:37:58 +02:00
Linus Rath 72b4f4abac fix: keep the edits when the recurring event question is cancelled 2026-10-15 03:37:58 +02:00
Linus Rath de1b955bfd docs: tick more fixed findings in the ux audit 2026-10-15 03:30:37 +02:00
Linus Rath c8434b0610 fix: make small buttons easier to hit 2026-10-15 03:30:00 +02:00
Linus Rath e19229a5c6 fix: keep screens clear of the camera cutout and side navigation bar in landscape 2026-10-15 03:29:59 +02:00
Linus Rath d7cee7ca78 fix: label the star button Unstar on a starred message 2026-10-15 03:29:27 +02:00
Linus Rath b59d00f0b7 fix: let the tag sheet scroll 2026-10-15 03:29:26 +02:00
Linus Rath 975ed08c82 fix: keep every thread toolbar button on screen, icon-only in landscape 2026-10-15 03:29:26 +02:00
Linus Rath 76170648f5 fix: open a long conversation at its first unread message 2026-10-15 03:29:25 +02:00
Linus Rath 509583e729 fix: widen the composer's field labels with the font size 2026-10-15 03:21:18 +02:00
Linus Rath 7b93265b4f fix: stop text fields cutting off their text at a larger font size 2026-10-15 03:21:18 +02:00
Linus Rath b5c4d62bde fix: put the mail search in the header row on a phone in landscape 2026-10-15 03:20:48 +02:00
Linus Rath c6cb44db61 fix: apply the text filters once typing pauses instead of querying per keystroke 2026-10-15 03:20:48 +02:00
Linus Rath 0db4bf00aa fix: say when a swipe, bulk action or import fails instead of failing silently 2026-10-15 03:20:48 +02:00
Linus Rath e94b782f8e fix: keep mail lines a readable length on wide screens 2026-10-15 03:18:58 +02:00
Linus Rath ae1ef5ba5e fix: move from username to password to code with the return key when signing in 2026-10-15 03:18:24 +02:00
Linus Rath f891bf3abb fix: announce buttons as buttons with their disabled and busy state 2026-10-15 03:17:08 +02:00
Linus Rath af55e0acef fix: name every format button for screen readers 2026-10-15 03:17:07 +02:00
Linus Rath 1ebc5c66f7 fix: ask about unsaved contact changes on every way out of the form 2026-10-15 03:16:46 +02:00
Linus Rath 0b3710265b fix: keep the address book chips whole in landscape 2026-10-15 03:16:46 +02:00
Linus Rath 56e1593646 fix: move from recipients to subject to body with the return key 2026-10-15 03:00:06 +02:00
Linus Rath e7857ef806 fix: close an untouched reply without asking to save it 2026-10-15 03:00:05 +02:00
Linus Rath b3290c3202 fix: use the webmail's dark theme colours: white primary on neutral greys 2026-10-15 02:59:45 +02:00
Linus Rath f231d1c5c6 docs: tick the fixed findings in the ux audit 2026-10-15 02:54:38 +02:00
Linus Rath 984c359bb4 fix: end the unified inbox and scheduled lists above the gesture bar 2026-10-15 02:53:07 +02:00
Linus Rath 50ab7145c4 fix: keep the compose button clear of the undo bar 2026-10-15 02:53:07 +02:00
Linus Rath 8f68d24bdb fix: make the filter warnings readable on the light theme 2026-10-15 02:51:56 +02:00
Linus Rath f22e851c82 fix: give the vacation subject the full width instead of squeezing its label 2026-10-15 02:51:55 +02:00
Linus Rath d4982f6932 fix: ask before removing someone's access to a shared file 2026-10-15 02:51:55 +02:00
Linus Rath 275d674577 fix: ask before deleting a tag 2026-10-15 02:51:54 +02:00
Linus Rath 0460da51e5 fix: ask before deleting an event, a task or a calendar feed 2026-10-15 02:51:54 +02:00
Linus Rath 90170ea0e7 fix: show one signature separator when the signature brings its own 2026-10-15 02:36:11 +02:00
Linus Rath cbc41c963b fix: fit the files header under the status bar and clear of landscape insets 2026-10-15 02:36:00 +02:00
Linus Rath 63eefd7700 fix: show file dates in the app's language and time format 2026-10-15 02:36:00 +02:00
Linus Rath d97571cf0f fix: open folder settings from the drawer's folders icon 2026-10-15 02:35:31 +02:00
Linus Rath 8403843e76 fix: ask before signing out 2026-10-15 02:35:30 +02:00
Linus Rath 756ceaa436 fix: leave contact selection and search with back before leaving the tab 2026-10-15 02:35:21 +02:00
Linus Rath 99de74de93 fix: show the logo in the mail header on the light theme 2026-10-15 02:35:21 +02:00
Linus Rath 46d30169b2 fix: clear the mail search with back instead of leaving the app 2026-10-15 02:35:21 +02:00
Linus Rath 512a28c494 fix: show the app in the chosen language right after a cold start 2026-10-15 02:35:11 +02:00
Linus Rath 29c172f2af fix: use the theme's tag colours in the tag sheet 2026-10-15 02:35:11 +02:00
Linus Rath 9e463d3a66 fix: show the whole section labels on the contact page 2026-10-15 02:35:10 +02:00
Linus Rath ab712f11ba fix: say why an event couldn't be saved instead of failing silently 2026-10-15 02:35:10 +02:00
Linus Rath 053fde8d56 fix: keep the text typed in quick reply when opening the full composer 2026-10-15 02:35:09 +02:00
Linus Rath 250c0a9c26 feat: centre the sign-in steps on tablets 2026-10-15 02:35:09 +02:00
Linus Rath d62805f805 docs: tick the rotation and tablet layout findings 2026-10-15 02:11:08 +02:00
Linus Rath b9e6ba417d fix: keep the compose button and list ends above the tablet taskbar 2026-10-15 02:11:08 +02:00
Linus Rath 0087e4c8ff feat: open the composer as a centred panel on tablets 2026-10-15 02:08:18 +02:00
Linus Rath 2470e99dc8 feat: fit the files grid to the width and preview files beside the list on tablets 2026-10-15 02:07:49 +02:00
Linus Rath 8ab82cdeb7 feat: show settings sections and the open page side by side on tablets 2026-10-15 02:07:21 +02:00
Linus Rath 64a62b6306 feat: show contacts as list and details side by side on tablets 2026-10-15 02:06:50 +02:00
Linus Rath f4ff07da7a feat: centre sheets and float toasts bottom right on tablets 2026-10-15 02:06:19 +02:00
Linus Rath 2288d28e46 feat: show five or seven days in the multi-day calendar view on wider screens 2026-10-15 02:05:31 +02:00
Linus Rath 295e292e47 fix: stop the contact and group screens crashing after a delete 2026-10-15 01:49:49 +02:00
Linus Rath 802474d3f2 feat: show mail as folders, list and message side by side on tablets 2026-10-15 01:45:57 +02:00
Linus Rath bfa02f1177 feat: let the app rotate and work in landscape
The app was locked to portrait in app.config.js and the hand-kept
AndroidManifest.xml. Android 16 ignores that lock on screens 600 dp and
wider, so tablets and foldables rotated into layouts that were never
tested. This removes the lock in both places, keeps React state across
rotation and split-screen resizes (`smallestScreenSize` joins
`configChanges`), and fixes what broke in landscape:

- the thread pager sizes its pages from its own width and snaps back to
  the open message after a rotation instead of showing half a neighbour
- compose and the thread view share one keyboard tracker; a rotation
  with the keyboard open closes it, since Android sends no new keyboard
  frame and the old portrait one blanked the whole landscape composer
- in a short window with the keyboard up the composer hides the format
  bar and tightens its header so a few lines of text stay visible
- inputs with suggestions or context around them opt out of Android's
  full-screen landscape keyboard
- month views fit whole rows to the real height, keep the month across a
  remount, and the month dropdown re-measures and caps its height
- the week grid's header follows the remounted list after a rotation
- the event sheet's actions stay on screen in landscape
- every RN Modal allows all orientations, or iOS forces portrait
2026-10-15 01:45:23 +02:00
Linus Rath 4b29cdfd3c feat: add window size classes for tablet and landscape layouts 2026-10-15 01:44:45 +02:00
Linus Rath 2a47913d70 docs: add native ui and ux audit 2026-10-15 01:19:00 +02:00
lucletoffeandClaude Opus 5.5 8ce53816af fix(composer): stop the body's first lines hiding under To/Cc/Subject on Android
Android's WebView shrinks the editor page's visual viewport to the part of
the view that is on screen and pans it to reveal the caret while typing.
The editor is an auto-height WebView inside the composer's ScrollView, so
nothing ever resets that pan: scrolled back to the top, the body is still
drawn shifted up by the pan offset and its first lines sit hidden under the
header fields until a scroll to the very bottom clamps it away.

The page now undoes every pan as it happens (scrolling the page top into
view, since window.scrollTo only moves the layout viewport) and reports the
offset as a `viewportShift` message; the composer scrolls its ScrollView by
the same amount so the caret stays visible. Android only; iOS and the other
RichTextEditor hosts keep their behaviour (hosts without the callback still
get the reset).

Reproduced and verified on a Pixel 4 (Android 13, WebView 153): before,
visualViewport.offsetTop stayed at ~18 px at the top of the composer and
cut the first line; after, it stays 0 and the caret still follows typing
above the keyboard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 08:49:57 +02:00
lucletoffeandClaude Opus 5.5 767d4b9f9f fix: keep a re-opened draft's attachments on live blobs across saves and sends
A draft's attachments and inline images are MIME parts of that draft, and
their blobIds die with the version that holds them. Each save creates the
next version and then destroys the previous one (#849), but the composer kept
sending the blobIds it was seeded with. The first save worked, every later
save failed with "blobId ... does not exist on this server", and so did the
send. This is the second half of webmail #849, which the webmail fixed in
5d2b937a but which was never ported here.

- saveDraftVersion() reads the new version's attachment parts back in the
  same request (Email/get on the creation id "#draft"; Stalwart 0.16 does not
  resolve a result reference into Email/set "created"). createDraft() keeps
  its signature on top of it.
- The composer maps each held blobId to the matching part of the latest
  version (inline by Content-ID, the rest by name and size) and sends that.
  Uploads are re-pointed too, since an unreferenced upload expires. The map
  lives in a ref, so the dirty snapshot does not change on every save.
- Opening a draft with inline images no longer looks like an edit. The
  hydration swapped the editor body for its normalised DOM HTML but compared
  it with the raw server HTML, so the autosave replaced the draft about two
  seconds after it opened. That destroyed the opened version, and a plain
  "open, then Send" failed with the same blobNotFound.

Tests: unit tests for the matching and rebasing, a fake-server test that
reproduces the failure without the rebase, and an opt-in live Stalwart test
(verified against Stalwart 0.16.22).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 08:47:21 +02:00
Linus Rath e19885dc72 chore: update version number to 0.1.75 2026-10-13 18:52:35 +02:00
Linus Rath 4816f0e08b chore: update version number to 0.1.74 2026-10-13 18:07:46 +02:00
Linus Rath ef1d4bc387 fix: stop the app crashing when a notification opens a mail 2026-10-13 18:07:46 +02:00
Linus Rath 61690603a2 chore: update version number to 0.1.73 2026-10-10 23:54:40 +02:00
Linus Rath 765ff26aa5 Merge remote-tracking branch 'origin/main' 2026-10-10 23:20:25 +02:00
Linus Rath b832ac0c49 feat(calendar): tap an empty time slot to start a new event
In the day, 3 days and week grids an event could only be started with a
long press, which few people find. Tapping an empty slot now marks it
with a "New event" block, and tapping that block opens the editor at
that hour; a second tap elsewhere moves the block. Long press still
opens the editor straight away. Tapping a day's header in the week or
3 days grid opens that day.

The editor also started events from a slot half an hour late, because
it rounded a time already on the hour up to the next half hour.
2026-10-10 22:59:35 +02:00
Linus Rath f53cee7ec9 feat(calendar): lay out the agenda as a schedule with dates on the left
The agenda showed each day as a grey header bar above its events. It now
reads like a schedule: the weekday and date sit in a column on the left
of the day's first event (today in a filled circle), a heading marks
where each month starts and a quiet line names each week's range. An
empty today says "Nothing planned. Tap to create." and tapping it opens
a new event. In the drawer the view is now called Schedule.

Events that fill a whole day are listed as all day instead of
"00:00 – 00:00".
2026-10-10 22:53:40 +02:00
Linus Rath 21d469f7e8 feat(calendar): show the month as a full-screen grid of events
The month view used to be a small grid of dots with the selected day's
events listed underneath, so you only ever saw one day's events at a
time. It now fills the screen: each day is a cell with its events as
solid bars in the calendar colour and "+N" for the ones that don't fit.
Tapping a day opens it in the day view, and long-pressing still creates
an event there. Events that fill the whole day no longer show 00:00.

The dotted month stays for the month that drops down under the title.
2026-10-10 22:47:13 +02:00
Linus Rath c1e0cb36c9 feat(calendar): move views into the drawer and add a create button
The calendar header was crowded: four icon toggles, a separate row of
arrows and a Today button, and a small create button squeezed in next
to them. Navigation now follows Google Calendar's layout while keeping
the app's own look:

- the header shows the month; tapping it drops down a month you can
  swipe through and pick a day from
- a calendar icon with today's date jumps to today
- Schedule, Day, 3 days, Week and Month are listed at the top of the
  drawer, styled like the mail drawer's folders, with Refresh below
- a round create button sits in the corner like the mail compose
  button; with tasks on it offers Event or Task
- a new 3 days view shows three columns of the time grid

The arrows stay in the header when free scrolling is off, since the
views can't be swiped then.
2026-10-10 22:41:46 +02:00
Linus Rath d8879617ed fix: never destroy mail from the notification Delete action #68
With no Trash mailbox, or when Mailbox/get came back as an error, the
Delete action fell through to destroying the email. A shared account
without its own Trash lost mail permanently on one tap.

- only move to Trash; do nothing when there is no Trash or the lookup
  failed
- check that Email/set actually updated the email
- show "(No Subject)" in the collapsed text instead of a blank line
- require unlock for Delete (Android 12+)
2026-10-10 19:20:53 +02:00
Linus Rath 7b62755aee Merge pull request #68 from arnauda-gh/feat/notification-preview-and-actions
feat(notifications): add rich preview snippet and quick actions (mark read, delete, reply)
2026-10-10 19:20:46 +02:00
Linus Rath 96e1cc1c3f fix: open a list mail without mounting every pane before it #67
The tap fix makes a notification open a single page, so the viewer no
longer needs to render up to the opened index. That setting mounted
about N mail panes on every list open, on iOS too. Also drop the
onScrollToIndexFailed handler, which FlatList never calls when
getItemLayout is set.
2026-10-10 19:20:46 +02:00
Linus Rath ec137e931b Merge pull request #67 from arnauda-gh/fix/notification-open-target-email
fix(notifications): ensure notification tap always opens target email
2026-10-10 19:20:40 +02:00
Linus Rath cf78b07939 Merge pull request #69 from arnauda-gh/fix/quick-reply-keyboard-avoidance
fix(viewer): adapt screen and auto-scroll quick reply box when keyboard appears
2026-10-10 19:20:39 +02:00
arnauda-gh 5237d9004d fix(viewer): adapt screen and auto-scroll quick reply box when keyboard appears 2026-10-09 15:00:51 +02:00
arnauda-gh be15af5d4c fix(notifications): execute background actions directly via HeadlessJs without service 2026-10-09 12:41:53 +02:00
arnauda-gh 90fe43882a feat(notifications): add email preview snippet and quick actions (mark read, delete, reply) 2026-10-09 09:16:53 +02:00
arnauda-gh 465eeffd1e fix(viewer): use info.index * windowWidth for onScrollToIndexFailed offset 2026-10-09 09:05:27 +02:00
arnauda-gh 200404483c fix(notifications): ensure notification tap always opens target email 2026-10-09 09:01:28 +02:00
Linus Rath 28f6787279 fix: confirm a copied event location on iOS too #59 2026-10-06 23:32:04 +02:00
Linus Rath 89f0524ad4 fix: only offer meeting links the app can open #59 2026-10-06 23:30:57 +02:00
Linus Rath 172110f7b0 Merge pull request #59 from lucletoffe/fix/calendar-location-links
fix(calendar): join the meeting and open the location from the event sheet
2026-10-06 23:30:07 +02:00
Linus Rath aa612e6f0e Merge pull request #55 from michaelbrusegard/fix/external-idp-handoff
fix(auth): verify external identity providers during handoff
2026-10-06 23:29:29 +02:00
Linus Rath 76180b332d ci: put every iOS release on the public TestFlight link
Uploads only reached internal testers. The public link's "external" group
still served 0.1.66, because nothing added later builds to it or
submitted them for Beta App Review.

A new `public-testflight` job runs after the upload on a Linux runner.
It waits for App Store Connect to process the build, adds it to the
external group, sets "What to Test" to the GitHub release and submits
it for review (`scripts/testflight-public.mjs`, safe to re-run). The
README links the TestFlight beta and the latest APK.
2026-10-02 16:33:07 +02:00
Linus Rath f5c491f6d7 chore: update version number to 0.1.72 2026-10-02 15:02:12 +02:00
Linus Rath 23af324d79 fix: verify app updates without a second slow progress bar
Since releases ship an `.apk.sha256` file, every in-app update checks
the downloaded APK against it. The check read the 40 MB file in base64
chunks and hashed it in JS, which took longer than the download, and it
reset the progress bar to 0 under the "Downloading" label, so the update
looked like it downloaded twice, the second time slowly.

The file is now read and hashed natively through expo-crypto (the JS
hasher remains as a fallback), and the bar stays full under a
"Verifying…" label while the check runs.
2026-10-02 15:02:10 +02:00
lucletoffeandClaude Opus 5.5 bd1b2f3fd3 fix(calendar): keep app-scheme meeting links and match service labels exactly
A virtual location in an app scheme (msteams:, zoommtg:) is kept as a last
resort instead of hiding the meeting row; script schemes never are. A
location only joins the meeting when the whole label is a service name, so
"Salle Teams" still opens the maps. Recognise Zoom's /wc/ join path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 09:59:58 +02:00
lucletoffeandClaude Opus 5.5 7c4b584d9c fix(calendar): join the meeting and open the location from the event sheet
Invitations - Teams above all - leave virtualLocations empty: LOCATION reads
"Microsoft Teams Meeting" and the join URL sits in the description between
help and dial-in links, so the sheet showed no meeting button. Find the
join URL of known conferencing services (Teams, Zoom, Meet, Webex, Jitsi,
Whereby, GoTo) in the location or description, unwrapping Outlook Safe
Links, and offer it as the meeting link. Nothing is written to the event.

The location was plain text. Tapping it now opens it: a URL opens as a
link, a location that only names the service joins the meeting, anything
else is searched in the maps app (geo: intent on Android, Google Maps
universal link elsewhere). Long-press copies it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 09:47:15 +02:00
Michael Brusegard e35cd82f5f fix(auth): verify external identity providers during handoff
Allow webmail-configured OAuth providers on separate hosts without trusting callback-supplied endpoints or broadly allowing sibling domains. Bind the client, JMAP server, issuer and token endpoint to HTTPS metadata from the selected webmail for both handoff and pairing.
2026-09-18 04:01:58 -07:00
258 changed files with 12485 additions and 3294 deletions
+35
View File
@@ -25,6 +25,9 @@ jobs:
timeout-minutes: 90
permissions:
contents: write
outputs:
version: ${{ steps.export.outputs.version }}
uploaded: ${{ steps.upload.outputs.done }}
env:
EXPO_NO_TELEMETRY: "1"
# App Store Connect refuses a CFBundleVersion it has already seen for this
@@ -254,6 +257,7 @@ jobs:
if-no-files-found: error
- name: Upload to TestFlight
id: upload
# The IPA is not attached to the GitHub release: without an Apple device
# provisioned for it, it is not installable, and TestFlight is the only
# distribution channel for it.
@@ -271,4 +275,35 @@ jobs:
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
xcrun altool --upload-app --type ios --file "$IPA" \
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
echo "done=true" >> "$GITHUB_OUTPUT"
echo "::notice::Uploaded build ${IOS_BUILD_NUMBER} to App Store Connect. Processing usually takes 5-30 minutes before it appears in TestFlight."
# Puts the upload on the public TestFlight link (README): waits for App Store
# Connect to process it, adds it to the external group and submits it for
# beta review. On Linux, because most of this job is waiting.
public-testflight:
needs: build
if: needs.build.outputs.uploaded == 'true'
runs-on: ubuntu-latest
timeout-minutes: 75
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install the App Store Connect API key
env:
ASC_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
run: |
mkdir -p ~/private_keys
echo "$ASC_KEY_BASE64" | base64 -d > ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
chmod 600 ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
- name: Add the build to the public group and submit it for beta review
env:
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
run: node scripts/testflight-public.mjs "${{ needs.build.outputs.version }}" "${{ github.run_number }}"
+36 -36
View File
@@ -1,13 +1,14 @@
import React from 'react';
import { ActivityIndicator, AppState, Linking, StyleSheet, Text, View } from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useColorScheme } from 'react-native';
import {
DarkTheme, DefaultTheme, NavigationContainer, createNavigationContainerRef, useIsFocused,
} from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { createNativeStackNavigator, type NativeStackScreenProps } from '@react-navigation/native-stack';
import { Mail, Calendar, BookUser, HardDrive, Settings } from 'lucide-react-native';
import { Mail, Calendar, BookUser, HardDrive, Settings } from './src/components/icons';
import { startLiveUpdates, type LiveUpdatesHandle } from './src/api/push-stream';
import { jmapClient } from './src/api/jmap-client';
@@ -43,10 +44,10 @@ import ComposeScreen from './src/screens/ComposeScreen';
import EmailThreadScreen from './src/screens/EmailThreadScreen';
import EmailSourceScreen from './src/screens/EmailSourceScreen';
import LoginScreen from './src/screens/LoginScreen';
import EmailListScreen from './src/screens/EmailListScreen';
import MailTab from './src/screens/MailTab';
import FilesScreen from './src/screens/FilesScreen';
import CalendarScreen from './src/screens/CalendarScreen';
import ContactsScreen from './src/screens/ContactsScreen';
import ContactsTab from './src/screens/ContactsTab';
import ContactDetailScreen from './src/screens/ContactDetailScreen';
import ContactFormScreen from './src/screens/ContactFormScreen';
import GroupDetailScreen from './src/screens/GroupDetailScreen';
@@ -57,12 +58,12 @@ import { useAccountStore } from './src/stores/account-store';
import { useAuthStore } from './src/stores/auth-store';
import { useCalendarStore } from './src/stores/calendar-store';
import { useContactsStore } from './src/stores/contacts-store';
import { useEmailStore, viewerParamsForRow } from './src/stores/email-store';
import { mailboxAccountId } from './src/lib/mailbox-tree';
import { useEmailStore } from './src/stores/email-store';
import { loadDetail, prefetchMessage } from './src/lib/email-detail-cache';
import { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities';
import { useSettingsStore } from './src/stores/settings-store';
import { useLocaleStore } from './src/stores/locale-store';
import { useLayout } from './src/lib/use-layout';
import { toast } from './src/stores/toast-store';
import { useNetworkStore } from './src/stores/network-store';
import { useUpdatesStore } from './src/stores/updates-store';
@@ -119,9 +120,11 @@ async function navigateToNotificationTap(payload: NotificationTapPayload): Promi
navigationRef.navigate('EmailThread', {
emailId: payload.emailId,
threadId: payload.threadId,
emailIds: [payload.emailId],
subject: payload.subject,
// A group mailbox's message lives under another JMAP account (#839).
jmapAccountId: notificationTapJmapAccountId(payload),
...(payload.action === 'reply' ? { action: 'reply' as const } : {}),
});
}
@@ -241,9 +244,15 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
settings: t('sidebar.settings', 'Settings'),
};
const unavailable = (name: string) => t('sidebar.tab_unavailable', '{name} (unavailable)', { name });
// Side by side layouts move the tabs into a rail on the left, like the
// webmail's navigation rail.
const { isSplit } = useLayout();
// A bottom tab bar keeps the screens clear of the gesture bar or taskbar;
// with the tabs in a side rail the whole tab area has to.
const bottomInset = useSafeAreaInsets().bottom;
return (
<View style={{ flex: 1, backgroundColor: c.background }}>
<View style={{ flex: 1, backgroundColor: c.background, paddingBottom: isSplit ? bottomInset : 0 }}>
<UpdateBanner />
<OfflineCacheBanner />
<PushOnboardingPrompt />
@@ -253,9 +262,21 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
headerShown: false,
// Tabs out of view stop re-rendering until they are shown again.
freezeOnBlur: true,
tabBarPosition: isSplit ? 'left' : 'bottom',
tabBarVariant: isSplit ? 'material' : 'uikit',
// A narrow rail (icon over label), not a wide sidebar with labels
// beside the icons.
...(isSplit ? { tabBarLabelPosition: 'below-icon' as const } : null),
tabBarActiveTintColor: c.text,
tabBarInactiveTintColor: c.textSecondary,
tabBarStyle: {
tabBarStyle: isSplit ? {
backgroundColor: c.secondary,
borderRightColor: c.border,
borderRightWidth: 1,
elevation: 0,
shadowOpacity: 0,
shadowColor: 'transparent',
} : {
backgroundColor: c.background,
borderTopColor: c.border,
borderTopWidth: 1,
@@ -289,30 +310,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
},
}}
>
{() => (
<EmailListScreen
onComposePress={() => navigation.navigate('Compose')}
onEmailPress={(email) => {
// The list holds the open folder's mail: name its account so the
// viewer never has to guess it from whatever folder is open.
const { mailboxes: all, currentMailboxId } = useEmailStore.getState();
const target = {
jmapAccountId: mailboxAccountId(all, currentMailboxId),
// A row of an "All folders" list or a tag view names its own
// account instead, and pages over that account's rows.
...viewerParamsForRow(email),
};
// Start on the body now: mounting the viewer takes a while.
prefetchMessage(email, target.jmapAccountId);
navigation.navigate('EmailThread', {
emailId: email.id,
threadId: email.threadId,
subject: email.subject,
...target,
});
}}
/>
)}
{() => <MailTab navigation={navigation} />}
</Tab.Screen>
<Tab.Screen
name="Calendar"
@@ -331,7 +329,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
/>
<Tab.Screen
name="Contacts"
component={ContactsScreen}
component={ContactsTab}
options={{
title: tabLabels.contacts,
tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />,
@@ -400,6 +398,7 @@ export default function App() {
// cached mail list instead of the "Restoring session" spinner; the real
// JMAP session comes up in the background.
const hasPersistedAccount = useAccountStore((state) => state.activeAccountId != null);
const { isSplit } = useLayout();
React.useEffect(() => {
if (!hasRestoredSession) {
@@ -830,10 +829,11 @@ export default function App() {
<Stack.Screen
name="Compose"
component={ComposeScreen}
options={{
presentation: 'modal',
animation: 'slide_from_bottom',
}}
// A tablet shows the composer as a panel over the screen it came
// from, so that screen stays drawn behind the dimmed backdrop.
options={isSplit
? { presentation: 'transparentModal', animation: 'fade' }
: { presentation: 'modal', animation: 'slide_from_bottom' }}
/>
<Stack.Screen name="ContactDetail" component={ContactDetailScreen} />
<Stack.Screen
+6 -1
View File
@@ -14,6 +14,11 @@
React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwarkmail/webmail) - a JMAP-based mail, calendar, and contacts app.
## Try it
- **iPhone and iPad:** join the beta on TestFlight: https://testflight.apple.com/join/rumcjNSp. A new release shows up there once Apple has reviewed it, usually within a day.
- **Android:** download the APK from the [latest release](https://github.com/bulwarkmail/native/releases/latest).
## What works today
- Sign in to any JMAP server (e.g. Stalwart)
@@ -30,7 +35,7 @@ React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwa
- iOS builds, but push notifications, client certificates and syncing with the phone's contacts and calendars are Android-only so far
- S/MIME, plugins, themes - UI stubs only (filters & rules, the vacation responder and file storage are real implementations)
- Calendar editing is partial; contacts editing is basic
- No Play Store distribution yet (sideload APK from Releases); iOS ships via TestFlight
- No Play Store or App Store listing yet: Android is a sideloaded APK, iOS a TestFlight beta (see [Try it](#try-it))
## Run locally
+1 -1
View File
@@ -1 +1 @@
0.1.71
0.1.81
+2 -1
View File
@@ -25,7 +25,7 @@
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
@@ -76,6 +76,7 @@
<service android:name=".BulwarkPushTaskService" android:exported="false"/>
<service android:name=".BulwarkWidgetRefreshService" android:exported="false"/>
<service android:name=".BulwarkPushJobService" android:permission="android.permission.BIND_JOB_SERVICE" android:exported="false"/>
<receiver android:name=".BulwarkNotificationActionReceiver" android:exported="false"/>
<service android:name=".BulwarkUnifiedPushService" android:exported="false">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT"/>
@@ -4,6 +4,7 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
@@ -72,14 +73,20 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
val groupTitle = options.takeIf { it.hasKey("groupTitle") }?.getString("groupTitle")
?: accountId ?: "Bulwark Mail"
val preview = options.takeIf { it.hasKey("preview") }?.getString("preview")
val markReadLabel = options.takeIf { it.hasKey("markReadLabel") }?.getString("markReadLabel") ?: "Mark as read"
val deleteLabel = options.takeIf { it.hasKey("deleteLabel") }?.getString("deleteLabel") ?: "Delete"
val replyLabel = options.takeIf { it.hasKey("replyLabel") }?.getString("replyLabel") ?: "Reply"
// Bitmap fetch + draw off the bridge thread so the caller doesn't
// block waiting for the favicon request.
thread(name = "bulwark-notification") {
val largeIcon = iconUrl?.let { fetchBitmap(it) }
?: makeLetterAvatar(initials, bgColorHex)
postNotification(
notificationId, title, body, largeIcon, bgColorHex,
notificationId, title, body, preview, largeIcon, bgColorHex,
emailId, threadId, subject, accountId, jmapAccountId, groupKey,
groupTitle, markReadLabel, deleteLabel, replyLabel,
)
if (groupKey != null) postGroupSummary(groupKey, groupTitle, bgColorHex, accountId)
promise.resolve(null)
@@ -102,6 +109,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
notificationId: String,
title: String,
body: String,
preview: String?,
largeIcon: Bitmap,
colorHex: String,
emailId: String?,
@@ -110,10 +118,15 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
accountId: String?,
jmapAccountId: String?,
groupKey: String?,
groupTitle: String?,
markReadLabel: String,
deleteLabel: String,
replyLabel: String,
) {
val ctx = reactApplicationContext
val intent = Intent(ctx, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
data = Uri.parse("bulwark-notification://$notificationId")
if (emailId != null) putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId)
if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject)
@@ -127,12 +140,27 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val bigText = buildString {
if (!subject.isNullOrBlank()) append(subject)
if (!preview.isNullOrBlank()) {
if (isNotEmpty()) append("\n")
append(preview)
}
}
val builder = NotificationCompat.Builder(ctx, BulwarkMessagingService.CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setLargeIcon(largeIcon)
.setContentTitle(title)
// body is the subject with the "(No subject)" fallback; an empty
// subject is "" here, not null, so `subject ?: ...` showed a blank line.
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setStyle(
NotificationCompat.BigTextStyle()
.bigText(if (bigText.isNotBlank()) bigText else body)
.setBigContentTitle(title)
.setSummaryText(groupTitle)
)
.setColor(parseColor(colorHex, fallback = Color.parseColor("#2563eb")))
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
@@ -142,6 +170,72 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
}
if (emailId != null && accountId != null) {
// Action 1: Mark as read
val markReadIntent = Intent(ctx, BulwarkNotificationActionReceiver::class.java).apply {
action = ACTION_MARK_READ
data = Uri.parse("bulwark-action-read://$notificationId")
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
putExtra(EXTRA_ACTION, "markRead")
putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
}
val markReadPending = PendingIntent.getBroadcast(
ctx,
(notificationId + ":read").hashCode(),
markReadIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
builder.addAction(
NotificationCompat.Action.Builder(0, markReadLabel, markReadPending).build()
)
// Action 2: Delete
val deleteIntent = Intent(ctx, BulwarkNotificationActionReceiver::class.java).apply {
action = ACTION_DELETE
data = Uri.parse("bulwark-action-delete://$notificationId")
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
putExtra(EXTRA_ACTION, "delete")
putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
}
val deletePending = PendingIntent.getBroadcast(
ctx,
(notificationId + ":delete").hashCode(),
deleteIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
builder.addAction(
NotificationCompat.Action.Builder(0, deleteLabel, deletePending)
// Android 12+: unlock first, so a locked phone can't delete mail.
.setAuthenticationRequired(true)
.build()
)
// Action 3: Reply
val replyIntent = Intent(ctx, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
data = Uri.parse("bulwark-reply://$notificationId")
putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId)
if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject)
putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
putExtra(EXTRA_ACTION, "reply")
}
val replyPending = PendingIntent.getActivity(
ctx,
(notificationId + ":reply").hashCode(),
replyIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
builder.addAction(
NotificationCompat.Action.Builder(0, replyLabel, replyPending).build()
)
}
val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(notificationId, notificationId.hashCode(), builder.build())
}
@@ -157,7 +251,11 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
it.groupKey?.endsWith(groupKey) == true &&
(it.notification.flags and android.app.Notification.FLAG_GROUP_SUMMARY) == 0
}
val count = maxOf(children.size, 1)
if (children.size < 2) {
manager.cancel(groupKey, groupKey.hashCode())
return
}
val count = children.size
val inbox = NotificationCompat.InboxStyle()
children.sortedByDescending { it.postTime }.take(5).forEach { sbn ->
val extras = sbn.notification.extras
@@ -172,6 +270,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
val intent = Intent(ctx, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
data = Uri.parse("bulwark-group-summary://$groupKey")
if (accountId != null) putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
}
val pending = PendingIntent.getActivity(
@@ -273,6 +372,11 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
@Volatile private var currentInstance: BulwarkFcmModule? = null
const val EXTRA_ACTION = "bulwark.notification.action"
const val EXTRA_NOTIFICATION_ID = "bulwark.notification.notificationId"
const val ACTION_MARK_READ = "com.anonymous.bulwarkmobile.ACTION_MARK_READ"
const val ACTION_DELETE = "com.anonymous.bulwarkmobile.ACTION_DELETE"
fun emit(eventName: String, params: WritableMap?) {
val module = currentInstance ?: return
val ctx = module.reactApplicationContext
@@ -15,6 +15,7 @@ class BulwarkFcmPackage : ReactPackage {
add(BulwarkClientCertModule(reactContext))
add(BulwarkDeviceSyncModule(reactContext))
add(BulwarkWidgetsModule(reactContext))
add(BulwarkPdfModule(reactContext))
// The bridge registers React Native's own module under this name.
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
add(BulwarkHeadlessJsTaskSupportModule(reactContext))
@@ -0,0 +1,124 @@
package com.anonymous.bulwarkmobile
import android.app.Application
import android.app.NotificationManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.facebook.react.bridge.Arguments
import com.facebook.react.jstasks.HeadlessJsTaskConfig
import com.facebook.react.jstasks.HeadlessJsTaskContext
import com.facebook.react.jstasks.HeadlessJsTaskEventListener
class BulwarkNotificationActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val notificationId = intent.getStringExtra(BulwarkFcmModule.EXTRA_NOTIFICATION_ID)
val action = intent.getStringExtra(BulwarkFcmModule.EXTRA_ACTION)
val emailId = intent.getStringExtra(NotificationTapStore.EXTRA_EMAIL_ID)
val accountId = intent.getStringExtra(NotificationTapStore.EXTRA_ACCOUNT_ID)
val jmapAccountId = intent.getStringExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID)
// Dismiss the notification immediately from the tray
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (notificationId != null) {
manager.cancel(notificationId, notificationId.hashCode())
}
if (emailId.isNullOrBlank() || action.isNullOrBlank() || accountId.isNullOrBlank()) {
Log.w(TAG, "Missing parameters for notification action: action=$action, emailId=$emailId, accountId=$accountId")
return
}
// On Android 8+, a broadcast receiver in background cannot start a Service
// (BackgroundServiceStartNotAllowedException). Instead, we use goAsync() and
// HeadlessJs.withReadyReactContext to execute the headless JS task directly.
val pendingResult = goAsync()
val app = context.applicationContext as Application
val mainHandler = Handler(Looper.getMainLooper())
// Safety timeout to ensure pendingResult.finish() is always called
val timeoutRunnable = Runnable {
Log.w(TAG, "Notification action task timed out or failed to initialize ($action, $emailId)")
try {
pendingResult.finish()
} catch (_: Exception) {}
}
mainHandler.postDelayed(timeoutRunnable, 20_000L)
try {
HeadlessJs.withReadyReactContext(app) { reactContext ->
if (!reactContext.hasActiveReactInstance()) {
Log.w(TAG, "React context does not have active instance")
mainHandler.removeCallbacks(timeoutRunnable)
try {
pendingResult.finish()
} catch (_: Exception) {}
return@withReadyReactContext
}
val tasks = HeadlessJsTaskContext.getInstance(reactContext)
val taskBundle = Bundle().apply {
putString("action", action)
putString("emailId", emailId)
putString("accountId", accountId)
if (jmapAccountId != null) putString("jmapAccountId", jmapAccountId)
// Keep raw extras as well
putString(BulwarkFcmModule.EXTRA_ACTION, action)
putString(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
putString(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
if (jmapAccountId != null) putString(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
}
val config = HeadlessJsTaskConfig(
"BulwarkNotificationAction",
Arguments.fromBundle(taskBundle),
15_000L,
true // allowExecutionInForeground
)
val listener = object : HeadlessJsTaskEventListener {
override fun onHeadlessJsTaskStart(taskId: Int) = Unit
override fun onHeadlessJsTaskFinish(taskId: Int) {
Log.i(TAG, "Notification action task $taskId finished for action $action")
tasks.removeTaskEventListener(this)
mainHandler.removeCallbacks(timeoutRunnable)
try {
pendingResult.finish()
} catch (_: Exception) {}
}
}
tasks.addTaskEventListener(listener)
HeadlessJs.startTask(
tasks,
config,
onStarted = { taskId ->
Log.i(TAG, "Started notification action task $taskId for action $action ($emailId)")
},
onRefused = { e ->
Log.w(TAG, "Notification action task refused: ${e.message}")
tasks.removeTaskEventListener(listener)
mainHandler.removeCallbacks(timeoutRunnable)
try {
pendingResult.finish()
} catch (_: Exception) {}
}
)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to dispatch headless task from action receiver", e)
mainHandler.removeCallbacks(timeoutRunnable)
try {
pendingResult.finish()
} catch (_: Exception) {}
}
}
companion object {
private const val TAG = "BulwarkNotifAction"
}
}
@@ -0,0 +1,141 @@
package com.anonymous.bulwarkmobile
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.pdf.PdfRenderer
import android.net.Uri
import android.os.ParcelFileDescriptor
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.Executors
/**
* Renders PDF pages to PNG files for the in-app file preview, with the
* platform's own PdfRenderer: Android has no PDF view, and its WebView
* doesn't show PDFs either. The preview draws the pages in a WebView of
* its own so they zoom, asking for each page as it scrolls into view.
*
* PdfRenderer is not thread-safe and allows one open page at a time, so
* every call runs on one background thread, and the open document is kept
* between calls for the file the preview shows.
*/
class BulwarkPdfModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
private val worker = Executors.newSingleThreadExecutor { r -> Thread(r, "BulwarkPdf").apply { isDaemon = true } }
private var openPath: String? = null
private var descriptor: ParcelFileDescriptor? = null
private var renderer: PdfRenderer? = null
override fun getName(): String = "BulwarkPdf"
override fun invalidate() {
worker.execute { closeDocument() }
worker.shutdown()
super.invalidate()
}
/** Page count and each page's size in points: `{ pages: [{ width, height }] }`. */
@ReactMethod
fun open(fileUri: String, promise: Promise) {
worker.execute {
try {
val doc = document(fileUri)
val pages = Arguments.createArray()
for (i in 0 until doc.pageCount) {
doc.openPage(i).use { page ->
pages.pushMap(Arguments.createMap().apply {
putInt("width", page.width)
putInt("height", page.height)
})
}
}
promise.resolve(Arguments.createMap().apply { putArray("pages", pages) })
} catch (e: SecurityException) {
// Password-protected; PdfRenderer can't open those.
closeDocument()
promise.reject("E_PDF_PROTECTED", e.message, e)
} catch (e: Throwable) {
closeDocument()
promise.reject("E_PDF_OPEN", e.message, e)
}
}
}
/** Render one page `width` pixels wide into `outUri` (a PNG). Resolves with the file URI. */
@ReactMethod
fun renderPage(fileUri: String, index: Int, width: Int, outUri: String, promise: Promise) {
worker.execute {
try {
val doc = document(fileUri)
if (index < 0 || index >= doc.pageCount) throw IndexOutOfBoundsException("page $index")
doc.openPage(index).use { page ->
val w = width.coerceIn(1, MAX_PAGE_PX)
val h = (w.toLong() * page.height / page.width.coerceAtLeast(1)).toInt().coerceIn(1, MAX_PAGE_PX * 2)
val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
try {
// Pages are transparent where nothing is drawn.
bitmap.eraseColor(Color.WHITE)
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
val out = fileOf(outUri)
out.parentFile?.mkdirs()
FileOutputStream(out).use { stream -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) }
} finally {
bitmap.recycle()
}
}
promise.resolve(outUri)
} catch (e: Throwable) {
promise.reject("E_PDF_RENDER", e.message, e)
}
}
}
/** Close the document the preview showed. */
@ReactMethod
fun close(fileUri: String, promise: Promise) {
worker.execute {
if (openPath == fileOf(fileUri).absolutePath) closeDocument()
promise.resolve(null)
}
}
private fun document(fileUri: String): PdfRenderer {
val path = fileOf(fileUri).absolutePath
renderer?.let { if (openPath == path) return it }
closeDocument()
val fd = ParcelFileDescriptor.open(File(path), ParcelFileDescriptor.MODE_READ_ONLY)
try {
val doc = PdfRenderer(fd)
descriptor = fd
renderer = doc
openPath = path
return doc
} catch (e: Throwable) {
fd.close()
throw e
}
}
private fun closeDocument() {
try { renderer?.close() } catch (_: Throwable) {}
try { descriptor?.close() } catch (_: Throwable) {}
renderer = null
descriptor = null
openPath = null
}
private fun fileOf(uri: String): File {
val path = if (uri.startsWith("file:")) Uri.parse(uri).path else uri
return File(path ?: throw IllegalArgumentException("not a file: $uri"))
}
companion object {
// A 4096 px page bitmap is 64 MB; keep well below.
private const val MAX_PAGE_PX = 2480
}
}
@@ -43,7 +43,8 @@ object NotificationTapStore {
// The JMAP account a group/shared mailbox's message lives in - a JMAP
// id like the email id. Optional: older notifications don't carry it.
val jmapAccountId = extras.getString(EXTRA_JMAP_ACCOUNT_ID)?.takeIf { ID_PATTERN.matches(it) }
val payload = TapPayload(emailId, threadId, subject, accountId, jmapAccountId)
val action = extras.getString(EXTRA_ACTION)?.takeIf { it == "reply" }
val payload = TapPayload(emailId, threadId, subject, accountId, jmapAccountId, action)
pending = payload
// Clear so a subsequent activity lifecycle event doesn't replay this.
extras.remove(EXTRA_EMAIL_ID)
@@ -51,6 +52,7 @@ object NotificationTapStore {
extras.remove(EXTRA_SUBJECT)
extras.remove(EXTRA_ACCOUNT_ID)
extras.remove(EXTRA_JMAP_ACCOUNT_ID)
extras.remove(EXTRA_ACTION)
return payload
}
@@ -60,6 +62,7 @@ object NotificationTapStore {
val subject: String?,
val accountId: String?,
val jmapAccountId: String?,
val action: String? = null,
) {
fun toMap(): WritableMap = Arguments.createMap().apply {
putString("emailId", emailId)
@@ -67,6 +70,7 @@ object NotificationTapStore {
if (subject != null) putString("subject", subject)
if (accountId != null) putString("accountId", accountId)
if (jmapAccountId != null) putString("jmapAccountId", jmapAccountId)
if (action != null) putString("action", action)
}
}
@@ -75,4 +79,5 @@ object NotificationTapStore {
const val EXTRA_SUBJECT = "bulwark.notification.subject"
const val EXTRA_ACCOUNT_ID = "bulwark.notification.accountId"
const val EXTRA_JMAP_ACCOUNT_ID = "bulwark.notification.jmapAccountId"
const val EXTRA_ACTION = "bulwark.notification.action"
}
+4 -1
View File
@@ -31,7 +31,7 @@ module.exports = {
// Android's manifest is maintained by hand and lists the same schemes.
scheme: ['bulwarkmobile', 'bulwarkmail', 'mailto'],
version: VERSION,
orientation: 'portrait',
orientation: 'default',
icon: './assets/icon.png',
userInterfaceStyle: 'automatic',
newArchEnabled: true,
@@ -99,6 +99,9 @@ module.exports = {
],
extra: {
commit: COMMIT,
// Settings → About → Source code. A modified build sets SOURCE_CODE_URL
// to where its own source is (AGPL §13); the default is upstream.
sourceCodeUrl: process.env.SOURCE_CODE_URL || 'https://github.com/bulwarkmail/native',
},
},
};
-1
View File
@@ -10,6 +10,5 @@ module.exports = function babelConfig(api) {
api.cache(true);
return {
presets: [presetExpo],
plugins: [require.resolve('./scripts/babel-plugin-lucide-imports')],
};
};
+14
View File
@@ -142,6 +142,20 @@ Internal testers (up to 100, must be on your App Store Connect team) get it
immediately. External testers (up to 10,000) need a Beta App Review, which takes
about a day the first time and is usually waved through afterwards.
The workflow's `public-testflight` job does the external part on its own. It
waits for processing, adds the build to the `external` group (the one behind
the public link in the README), sets "What to Test" to the GitHub release and
submits it for Beta App Review (`scripts/testflight-public.mjs`). To do this by
hand for a build, for example after a failed run:
```bash
ASC_KEY_ID=… ASC_ISSUER_ID=… ASC_KEY_PATH=path/to/AuthKey_….p8 node scripts/testflight-public.mjs 0.1.72 18 # marketing version, build number
```
If App Store Connect answers `REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED` (altool
reports it as "Cannot determine the Apple ID from Bundle ID"), the Account
Holder has to accept an updated agreement under Business in App Store Connect.
## What does not work on iOS yet
- **Push notifications.** The push path is a hand-written Android module talking
+649
View File
@@ -0,0 +1,649 @@
# Native UI and UX audit, 2026-10
Scope: every screen, sheet, dialog, banner and gesture of the Android app, on a phone and a tablet, in
portrait and landscape. Started from `e19885d` (0.1.75).
## Method
- Devices (emulators):
- phone `Bulwark_Pixel7_API35`, 411x914 dp;
- tablet `Pixel_Tablet_API_34`, 1280x800 dp.
The tablet's natural orientation is landscape, so `user_rotation 1` is tablet portrait.
- Cells used below:
| Cell | Device and orientation |
|---|---|
| PP | phone portrait |
| PL | phone landscape |
| TL | tablet landscape |
| TP | tablet portrait |
"all" means all four.
- Build: release APK from `C:\rn`, with the portrait lock removed for the audit only.
- Data: Stalwart 0.16 on VM 106, seeded with:
- 520+ inbox mails, a 16-message thread, HTML-only newsletters, a wide table, all attachment types
and an inline cid image;
- RTL mail (Arabic, Hebrew), a read-receipt request, very long subjects and names;
- nested folders;
- recurring, all-day, multi-day, overlapping, declined and cancelled events, and tasks;
- 65 contacts with and without photos, plus two groups;
- files and folders, and an active vacation reply.
The phone ran as `usera@`, the tablet as a separate `uxaudit@` account with the same seed.
- Every area was captured in all four cells in light English (tablet) and dark English (phone).
Then a second pass ran with German and font scale 1.3, on PP and TL, with the tablet in dark.
- Four read-only code passes (mail list, thread and compose, calendar, contacts/files/settings/login)
gave the `file:line` references. Device findings were checked against them.
- Screenshots are in the session scratchpad (`shots/q_<screen>.png`, a 4-cell strip: PP, PL, TL, TP;
`shots/d_<screen>.png` for the German/1.3 pass). They are not committed.
## Inventory
### Navigation
- Root native stack (`App.tsx`):
- MainTabs
- EmailThread
- EmailSource
- Compose (modal)
- ContactDetail
- ContactForm (modal)
- GroupDetail
- Scheduled
- UnifiedInbox
- AddAccount (modal)
- Bottom tabs: Mail, Calendar, Contacts, Files, Settings. A tab the server lacks is dimmed and does
nothing.
- Above the tabs:
- UpdateBanner
- OfflineCacheBanner
- PushOnboardingPrompt (floating card)
- AppIconBadge (no UI)
- On the focused stack screen: ToastHost and UndoSnackbar (none on Compose).
- Entry points:
- deep links: `bulwarkmobile://`, webmail permalinks, `mailto:`, and sign-in links
(`bulwarkmail://pair`, `connect`);
- Android share intents into Compose;
- notification taps (mail push, calendar reminders);
- widgets (inbox, agenda), which deep-link.
### Login (`LoginScreen` + `screens/login/*`)
- Steps: choose → email → (discovery) confirm or server → confirm → OAuth hand-off in the browser, or
password (+ TOTP when the server asks) → signing in.
- Side paths:
- QR scan modal (camera permission, blocked → open settings);
- paste-link modal;
- external sign-in link confirm alert;
- refused-link notice;
- Add account mode with a close button.
### Mail
- **Mail list** (`EmailListScreen`):
- Header: menu, folder title, import, logo.
- Search row: select-all, search with recent searches and people suggestions, sort toggle,
filter.
- Filter chips.
- Banners: empty folder, offline, failed outbox, unreachable accounts.
- States: loading, error + retry, no mailboxes, empty / no results.
- Gestures: pull to refresh, endless scroll, long-press to select, swipe actions (instant or
reveal).
- Selection header: star, read, tag, move, spam, archive, delete.
- Compose FAB.
- Sheets: filter modal (with date pickers), MoveSheet, TagSheet, attachment preview.
- **Sidebar drawer** (`SidebarDrawer`):
- Account switcher and account menu: add, set default, sign out, sign out of all.
- Unified Inbox, Scheduled.
- Unified Mailbox section, folders tree with unread counts and long-press folder sheet, shared
accounts, tags.
- Nested ActionSheet and NamePrompt.
- **Unified inbox** (`UnifiedInboxScreen`): its own header, search, selection, swipe, account dots,
pull to refresh, load more.
- **Scheduled** (`ScheduledScreen`): list, per-item actions (send now, reschedule, edit, cancel),
reschedule presets, custom date-time picker.
### Thread view (`EmailThreadScreen` + `components/email/*`)
- Layout:
- Horizontal pager over the list.
- Toolbar: back, delete, archive (≥400 dp), unread (≥340 dp), star, more.
- Subject with star.
- Single message, or conversation cards (collapsed rows, expanded cards with Reply / Reply all /
Forward pills).
- Message header: sender and recipients open the address sheet; "Show details".
- Attachments: preview, open, save and share; "Download all".
- Banners: blocked images, truncated body, read receipt, unsubscribe, calendar invitation (RSVP,
add to calendar).
- Quick reply box.
- Bottom bar: Prev, three configurable actions, Next.
- More sheet: archive, read, move, tag, spam, view mode, sender, forward as attachment, source,
export.
- Email source screen.
### Compose (`ComposeScreen`)
- Header: close, title + draft status, attach, schedule, send.
- Fields:
- From (IdentitySheet, From options);
- To / Cc / Bcc with chips, autocomplete and a chip menu;
- Subject.
- Attachments: sheet with photos, camera, files; chips with progress.
- Body: rich editor WebView or plain text.
- Format bar: templates, read receipt, plain/rich, B/I/U/S, colours, H1/H2, lists, quote, table,
alignment, link, image, clear, undo/redo.
- Modals: link, colour palette, table, schedule sheet, date-time pickers, template placeholders,
TemplateSheet.
- Flows:
- draft autosave;
- close guard (cancel / discard / save draft);
- empty-subject and forgotten-attachment checks;
- undo send through UndoSnackbar;
- scheduled-send confirmation.
### Calendar (`CalendarScreen` + `components/calendar/*`)
- Views: month (free scroll or paged), week (free scroll or paged), 3 days, day, schedule (agenda).
- Header: menu, month title with month dropdown, prev/next (paged only), today, tasks.
- FAB: event, or a menu with Event / Task.
- Sheets and dialogs:
- EventDetailSheet: RSVP, edit, duplicate, .ics, link, delete.
- EventModal (editor): RecurrenceEditor, ParticipantInput, pickers.
- RecurrenceScopeDialog.
- CalendarSidebarDrawer: views, refresh, calendars with a long-press panel, new calendar,
subscriptions, import.
- TasksSheet, ICalImportSheet, ICalSubscriptionSheet, CalendarEditSheet, CalendarShareSheet.
- Gestures:
- tap a slot for a draft, then tap the draft to create;
- long-press a slot to create;
- tap a month cell to open the day;
- free scroll with snapping.
- There is no drag-to-move or drag-to-resize, so there is nothing to break mid-drag.
### Contacts
- `ContactsScreen`:
- Header: drawer, count, search, import, add (long-press: contact or group).
- Address book / group chips.
- Long-press to select.
- Selection header: select all, export, move, tag, delete.
- Pull to refresh.
- `ContactsSidebarDrawer`: books, shared books, groups, tags with rename.
- `ContactDetailScreen`:
- hero and quick actions (email, call, SMS, share);
- sections: email, phone, address, work, personal, online, keys, categories, related, members,
notes, address book;
- recent mail and events;
- More sheet (duplicate, add to group, move, export, delete).
- `ContactFormScreen`: person, organisation or group, with many collapsible sections and a date
picker.
- `GroupDetailScreen`: members, add or remove.
- Sheets: AddressBookPicker, TagAssign, ContactPicker, ContactImport.
### Files (`FilesScreen`)
- Header: back, search, new folder, upload, grid/list.
- Breadcrumb, sort chip, upload progress.
- List or 3-column grid; long-press to select (download zip, delete).
- Per-item ⋮ sheet: preview, open with, download, share, move, duplicate, rename, delete.
- Folder picker, sort sheet, prompt modal, share sheet, preview modal.
### Settings (`SettingsScreen` + `components/settings/*`)
- One screen that swaps between a searchable section list and a page.
- Pages:
- account, language, notifications
- appearance, layout
- reading, composing, identities, vacation, filters (rule modal, sieve editor), templates,
folders, tags, downloads
- security, encryption (disabled), content & senders
- calendar and contacts (with device sync), files, sidebar apps
- about & data, themes, plugins, updates
- Sign out.
### Other
- PushOnboardingPrompt, OfflineBanner, OfflineCacheBanner, UpdateBanner.
- Toasts: swipe to dismiss, action button.
- UndoSnackbar: undo, send now.
- Android widgets (out of scope for fixes): inbox and agenda.
## Findings
Severity: **P1** broken or blocking, **P2** clearly wrong or clumsy, **P3** polish. Each finding
lists the cells where it occurs, the screenshot, the code location and the intended fix. A tick
carries the commit that fixed it.
### Cross-cutting
- [x] **U-001 · P1 · PL TL TP · The app is locked to portrait and has no large-screen layout.** *(fixed in bfa02f1)*
- Where: `app.config.js:34`, `AndroidManifest.xml:28`.
- Android 16 ignores the lock on screens 600 dp and wider, so tablets and foldables rotate anyway.
- Fix: remove the lock in both places. Keep `configChanges` (and add `smallestScreenSize` for
split-screen and foldables). Then do phase 2.
- [x] **U-002 · P2 · iPad, landscape · No RN `<Modal>` sets `supportedOrientations`.** *(fixed in bfa02f1)*
- Where: 56 modals in 40 files.
- iOS presents these portrait-only and forces a rotation.
- Fix: pass all orientations everywhere.
- [x] **U-003 · P2 · TL TP · Every screen is the phone layout stretched to 1280 dp.** *(fixed in 802474d, 64a62b6, 8ab82cd, 2470e99, 0087e4c, 250c0a9)*
- Affected: mail rows, thread body, compose, contacts, files, settings, login.
- Screenshots: `q_mail_list`, `q_thread_receipt`, `q_contacts`, `q_settings`, `t_login_confirm`.
- Fix: the adaptive layout plan below.
- [x] **U-004 · P2 · TL TP · Sheets and floating cards span the full width.** *(fixed in f4ff07d)*
- Affected: bottom sheets, toasts, the undo bar and the push prompt.
- Screenshots: `q_event_detail`, `q_mail_list` (push prompt).
- Fix: on medium and expanded widths, cap them at about 560 dp and centre them.
- [x] **U-005 · P2 · PL · Left and right safe-area insets are ignored.** *(fixed in bfa02f1, f4ff07d, e19229a)* *(partly fixed in bfa02f1 (compose), f4ff07d (sheets))*
- Most screens use `SafeAreaView edges={['top']}` and position sheets `left: 0, right: 0`.
- A side navigation bar or a camera cutout covers the content.
- Fix: add the side edges to screen roots, and inset sheets and the FABs.
- [x] **U-006 · P2 · all · The Files header pads a fixed 60 px instead of the status-bar inset.** *(fixed in cbc41c9)*
- Where: `FilesScreen.tsx:1438`.
- It is too tall in landscape and wrong on any other status bar height.
- [x] **U-007 · P2 · all · Uppercase section labels get clipped on Android.** *(fixed in 9e463d3)*
- Example: "WORK" shows as "WOR", "ADDRESS BOOK" as "ADDRESS" (contact detail, English).
- Cause: Android measures a shrink-wrapped, letter-spaced label a few pixels short, so its
last word wraps onto a second line that the one-line height hides. (Upper-casing the string
instead of `textTransform` made no difference.)
- Where: `ContactDetailScreen.tsx:976`, `ContactActivity.tsx:283`.
- Fix: let the label fill its row (`flex: 1`).
- [x] **U-008 · P2 · all · After a cold start in German, the tab labels and the folder title stay
English.** *(fixed in 512a28c)* The search placeholder is German. Screenshot `t_layo`.
- Fix: re-render the tab labels and the title when the locale store hydrates.
- [x] **U-009 · P2 · TL TP (font 1.3) · Tab labels truncate with plenty of room.** *(fixed in 802474d)*
- Example: "E-…", "Kalen…", "Einstellu…" in 256 dp-wide tabs (`q_de_mail`).
- The label sits beside the icon with a fixed font size and no width.
- Fix: let the label size itself; on wide screens use the side rail (phase 2).
- [x] **U-010 · P2 · all (font 1.3) · The mail search field clips the top of its text.** *(fixed in 7b93265)*
- Where: fixed height in `EmailListScreen.tsx:1885-1912`.
- Fix: use `minHeight` and vertical padding.
- [x] **U-011 · P2 · dark · The dark palette doesn't match the webmail's standard theme.** *(fixed in b3290c3)*
- The app uses a blue primary and zinc greys. The webmail uses a white `#fafafa` primary with
`#171717` text, a `#0a0a0a` ground, neutral greys, `#1c1c1c` popover and a `#262626` muted
surface.
- Where: `src/theme/tokens.ts` `DARK_COLORS`. APP.md lists this as open.
- Fix: port the webmail dark tokens.
- [x] **U-012 · P2 · light · The mail header logo is invisible on the light theme.** *(fixed in 99de74d)*
- It always loads `Bulwark Logo White.png` (`EmailListScreen.tsx:1105`). Screenshot `q_mail_list`
TL.
- Fix: pick the logo by the resolved theme.
- [x] **U-013 · P2 · light · TagSheet reads the static dark palette.** *(fixed in 29c172f)*
- Where: `TagSheet.tsx:7,101,124`. Tag colours are wrong on light.
- Fix: use `c.tags`.
- [x] **U-014 · P2 · all · `Button` has no `accessibilityRole` and no disabled or busy state.** *(fixed in f891bf3)*
- Where: `Button.tsx:35`. This affects every dialog and prompt button.
- [x] **U-015 · P2 · all · Many icon buttons are under 48 dp.** *(fixed in c8434b0,776447d)* *(partly fixed in c8434b0 (thread back, login header, Files header and row menu, account icons, switches, drawer chevron and folder icon, search clear, recipient chip, quick reply))*
- Mail search clear 36, recent-search remove 28, filter chip remove 32, attachment chips about 22,
drawer chevron 32.
- Viewer: card pills about 36, collapse handle 18, quick-reply Send 28, subject star 34.
- Compose: recipient chip X 28, Send 36, Cc/Bcc toggles 28.
- Files: header 44, row ⋮ 26.
- Settings: account icon buttons 28, toggle 24 tall.
- Login: header 32, show-password 20.
- Sheet close buttons are 28 everywhere.
- Lists with `file:line` are in the code passes; the fix commits name the places.
- Fix: a shared 48 dp target (box plus `hitSlop`), applied screen by screen.
- [ ] **U-016 · P3 · all · Sheet and dialog scrims are a literal `rgba(0,0,0,0.5)` in about 20
files.** *(deferred: all scrims are already the same black at 0.5 on both themes; a token is a refactor with no visible change)* Fix: an `overlay` token.
- [ ] **U-017 · P3 · all · Some confirmations use the system alert, others the app's `Dialog`.** *(deferred: switching every confirmation to Dialog changes a lot of flows for a style difference only; see product questions)*
Examples: compose discard, event delete, folder empty. This is a style mismatch.
- [ ] **U-018 · P3 · all · Changing the system font size while the app is open recreates the activity
and resets navigation to the mail list.** *(deferred: handling fontScale in the app (configChanges) was tried and left a mix of old and new text sizes until a restart, worse than Android's default recreate; needs RN to re-measure on a font scale change)* Text measured before the change can stay clipped until
the next restart ("Layo" for "Layout").
### Mail list, drawer, unified inbox, scheduled
- [x] **U-020 · P2 · all · Back with an active search exits the app instead of clearing the
search.** *(fixed in 46d3016)*
- Repro: search "Offsite", press Enter, then Back.
- Where: `EmailListScreen.tsx` has no back handler for search or selection.
- Fix: `useBackWhileFocused` that clears the selection, then the search.
- [x] **U-021 · P2 · PL · Phone landscape shows about 1.5 rows.** *(fixed in b5c4d62)*
- The 56 dp header, the 56 dp search row and the 64 dp tab bar take most of the 411 dp.
- The compose FAB covers the second row. Screenshot `q_mail_list` PL.
- Fix: in compact height, merge the title and search into one row and drop the tab labels.
- [x] **U-022 · P2 · PL (font 1.3) · Rows cut the sender to three letters and clip the time.** *(no change needed: after a restart at 1.3 the rows keep the full time and name; the clipping was stale measurement from changing the font size live, see U-018)*
- Example: "Us…", "17:4" (`q_de_mail` PL).
- Where: the row header in `EmailListScreen.tsx` around 158-250.
- Fix: let the time keep its width (`flexShrink: 0`) and let the name shrink.
- [x] **U-023 · P2 · PP · The undo bar sits on top of the compose FAB for 5 s after every archive or
delete.** *(fixed in 50ab714)*
- Where: `UndoSnackbar.tsx:161`, `EmailListScreen.tsx:2262`.
- Fix: lift the FAB while the bar shows.
- [x] **U-024 · P2 · all · Swipe and bulk actions on the list fail silently.** *(fixed in 0db4bf0)*
- Archive, spam, read, star, move and delete are called as `void ...` with no catch, so a server
rejection becomes an unhandled promise. The selection also stays.
- Where: `EmailListScreen.tsx:604-651, 715-799`; `handleImport` `:492`.
- Fix: report failures through `toast.error` as the viewer does.
- [x] **U-025 · P2 · all · Sign out and "Sign out of all accounts" run with no confirmation.** *(fixed in 8403843)*
- Where: drawer `SidebarDrawer.tsx:930,943`, settings `SettingsScreen.tsx:497`.
- Fix: a destructive `Dialog`.
- [x] **U-026 · P2 · all · The drawer's folder-settings icon only closes the drawer.** *(fixed in d97571c)*
- Where: `SidebarDrawer.tsx:1048`, `navigate('MainTabs')`.
- Fix: open Settings → Folders.
- [x] **U-027 · P2 · all · The filter modal runs a query per keystroke and has no keyboard
handling.** *(fixed in c6cb44d)*
- Where: `EmailListScreen.tsx:1507-1550`.
- Fix: debounce like the main search; add next/search return keys.
- [x] **U-028 · P2 · PP PL · The unified inbox and Scheduled lists end under the gesture bar.** *(fixed in 984c359)*
- Where: `UnifiedInboxScreen.tsx:384`, `ScheduledScreen.tsx:278` (top edge only, fixed padding).
- [x] **U-029 · P2 · all · Unified inbox rows ignore the font-size, density and preview settings.** *(fixed in 52eb18d)*
- Where: `UnifiedInboxScreen.tsx:576`.
- Its load and open failures are silent (`:103-112`, `:179-206`).
- [x] **U-030 · P3 · all · The menu button keeps a grey focus box after the drawer closes.** *(no change needed: did not reproduce after the drawer and rail changes; the button shows no focus box after closing the drawer by tap or Back)*
Screenshot `q_search_results`.
- [x] **U-031 · P3 · PP · The selection header holds up to 7 buttons; its count has no
`numberOfLines`.** *(fixed in 0d96d8d)* Where: `EmailListScreen.tsx:982-1066`.
- [x] **U-032 · P3 · all · Folders under a hidden empty parent show flattened at the top level.** *(no change needed: the tree only lifts a folder whose parent the server does not return; usera has no Clients folder, and on uxaudit Q4 nests under Clients / ACME / 2026)*
- Example: "Q4 contracts" without Clients / ACME / 2026 (`q_drawer` PP).
- This happens with "hide empty folders" on. To check before changing anything.
- [x] **U-033 · P2 · TL TP · With the tabs in the side rail, the compose FAB and list ends sit under
the tablet taskbar.** *(fixed in b9e6ba4)* Found after the mail split view landed (802474d): a bottom tab bar used to
keep the screens clear of the bottom inset, a side rail doesn't.
- Fix: pad the tab area by the bottom inset when the rail is shown.
### Thread view
- [x] **U-040 · P1 · PL TL TP · After a rotation the pager is misaligned.** *(fixed in bfa02f1)*
- Half of the neighbouring page shows and the open message is cut off at the right edge.
- It happens when rotating on the thread, and when rotating on Compose opened from it
(`q_thread_long` PL, `q_compose_close` TL).
- Where: `EmailThreadScreen.tsx:265-280`; the pager keeps its old pixel offset.
- Fix: re-scroll to the active index after a width change, without animation.
- [x] **U-041 · P2 · all · A long thread opens at the oldest message.** *(fixed in 7617064)*
- The newest (opened) message is expanded but off screen on a phone (`q_thread_long` PP).
- Fix: scroll the opened message into view once the cards are laid out.
- [x] **U-042 · P1 · all · Quick reply "More options" throws away the typed text.** *(fixed in 053fde8)*
- Where: `EmailThreadScreen.tsx:1185`.
- Fix: pass the text as `prefillBody` to the reply composer.
- [x] **U-043 · P2 · PL · Phone landscape leaves almost no room for the body.** *(fixed in 975ed08)*
- Toolbar with labels, subject, sender block, attachments, banner and the labelled bottom bar
fill the 411 dp (`q_thread_receipt` PL).
- Fix: icon-only toolbar and bottom bar in compact height.
- [x] **U-044 · P2 · PP (German, 1.3) · The toolbar overflows and pushes More off screen.** *(fixed in 975ed08)*
- "Stern hinzufügen" is long (`d_de_thread`).
- Fix: drop the toolbar labels when they don't fit; keep the a11y labels.
- [ ] **U-045 · P3 · German · Prev/Next read "Zurück"/"Weiter".** *(deferred: the strings come from the vendored webmail catalog (locales/de/common.json); the fix belongs in the webmail's German translation)*
- "Zurück" reads as Back. "Allen antworten" wraps in the bottom bar.
- Fix: "Vorherige"/"Nächste" (locale), and a single line with shrinking text.
- [x] **U-046 · P2 · all · The tag sheet doesn't scroll.** *(fixed in b59d00f)*
- With many tags, or in landscape, rows can't be reached.
- Where: `EmailThreadScreen.tsx:1472-1486`.
- [x] **U-047 · P2 · TL TP · Plain-text and fluid HTML bodies run 150+ characters per line.** *(fixed in e94b782)*
- Fix: a readable max width for the body on wide panes.
- [x] **U-048 · P3 · all · The toolbar star always says "Star", even when the message is starred.** *(fixed in d7cee7c)*
- Where: `EmailThreadScreen.tsx:746`.
- [x] **U-049 · P3 · PL TL · Attachment image preview is sized from the window width, so it is
taller than the screen in landscape.** *(fixed in 2470e99)* Where: `AttachmentPreviewModal.tsx:112`.
### Compose
- [x] **U-060 · P1 · PL · With the keyboard open in landscape, the whole compose body is blank.** *(fixed in bfa02f1)*
- Only the header stays, so you can't see what you type (`tri.png`).
- Where: the keyboard padding is computed from `Dimensions.get('screen').height`
(`ComposeScreen.tsx:400-425`), which doesn't follow the rotation.
- Fix: compute the overlap from the window, against the keyboard frame.
- [x] **U-061 · P2 · all · An untouched reply autosaves a draft and asks "Save or discard?" on
close.** *(fixed in e7857ef)*
- The signature insertion counts as an edit (`d_de_compose`). Opening and closing a reply should
leave nothing behind.
- Where: `ComposeScreen.tsx:1116-1184`, `:1331-1444`.
- [x] **U-062 · P2 · all · Recipient and subject fields have no return-key flow.** *(fixed in 56e1593)*
- Fix: To/Cc/Bcc use `next`; an empty submit moves on; Subject `next` focuses the body.
- Where: `ComposeScreen.tsx:2228-2395`.
- [x] **U-063 · P2 · all (German, 1.3) · Field labels wrap.** *(fixed in 509583e)* "Betreff" shows as "Betref/f": the
label column is a fixed width.
- [x] **U-064 · P3 · PP · The subject field shows the end of a long subject.** *(fixed in 56e1593)*
- Example: "…ning 2027: venue…". The cursor was put at the end on mount.
- [x] **U-065 · P2 · all · Format-bar buttons have no accessibility labels.** *(fixed in af55e0a)*
- Where: `ComposeScreen.tsx:2441-2513`; the toolbar button also has no role or state.
- [x] **U-066 · P2 · PL · Text inputs use Android's full-screen landscape keyboard.** *(fixed in bfa02f1)*
- The autocomplete and the composer are hidden behind it.
- Fix: `disableFullscreenUI` on recipients, subject, plain body and quick reply.
- [x] **U-067 · P2 · TL TP · Compose fills the tablet screen.** *(fixed in 0087e4c)* Fix: a centred panel (phase 2).
- [x] **U-068 · P2 · all · The signature shows two "--" lines.** *(fixed in 90170ea)* The seeded text signature starts
with the usual "-- " delimiter and the composer adds its own separator above it (screenshot
`t_compose_panel`).
- Fix: don't add a separator to a signature that already starts with one.
- [ ] **U-069 · P2 · PL · Recipient suggestions can't show in phone landscape.** *(deferred: with the keyboard up a phone in landscape has about 100 dp left; suggestions need a different design (a full-screen picker), which is a feature)* With the keyboard
up, about 50 dp are left under the To field, so the suggestion list is hidden behind the
keyboard. Typing a full address still works.
### Calendar
- [x] **U-080 · P1 · PL · Month view shows two week rows and cuts off the rest.** *(fixed in bfa02f1)*
- Rows keep a 72 dp minimum and the list is sized to six of them (`q_calendar_default` PL).
- Where: `MonthScrollView.tsx:127-128,250`, `MonthView.tsx:427-438`.
- Fix: size the list to the real height and let rows shrink.
- [x] **U-081 · P1 · TL TP · Rotating on month or week jumps back a month, and week view goes
blank.** *(fixed in bfa02f1)*
- After a rotation, month view showed September instead of October. Week view showed no day
headers and no events until Today was tapped (`q_calendar_default` TP, `q_cal_week` TL).
- Where: `MonthScrollView` samples the visible month at a fixed 40% of a stale height;
`TimeGridScrollView` keeps the old `scrollX` after its list remounts.
- Fix: keep the focused date across the remount and reset the header offset.
- [x] **U-082 · P1 · PL · The event sheet's action row (Edit, Duplicate, .ics, Delete) is below the
screen.** *(fixed in bfa02f1)*
- Screenshot `p_event_land`.
- Where: `EventDetailSheet.tsx:609,643` (body `maxHeight: 400`, no `flexShrink`).
- [x] **U-083 · P1 · all · Saving an event fails silently.** *(fixed in ab712f1)*
- `handleSave` has `try/finally` without catch: the spinner stops, the editor stays, nothing is
said.
- Where: `EventModal.tsx:301-368`.
- [x] **U-084 · P2 · all · Several deletes have no confirmation or undo.** *(fixed in 0460da5)*
- Deleting a single event (`CalendarScreen.tsx:632`, `EventModal.tsx:371`).
- Deleting a task (`TasksSheet.tsx:427`).
- Unsubscribing from a feed, which also deletes its calendar (`ICalSubscriptionSheet.tsx:278`).
- [x] **U-085 · P2 · all · Cancelling the recurrence-scope dialog after editing a recurring event
throws the edits away.** *(fixed in 72b4f4a)* Where: `CalendarScreen.tsx:786-790`.
- [x] **U-086 · P2 · TL TP · Wide screens get wider columns, not more days.** *(fixed in 2288d28)* 3 days stays 3 and
day stays 1. Fix: phase 2.
- [x] **U-087 · P2 · PL · The month dropdown is a fixed 346 dp tall and sized from the window.** *(fixed in bfa02f1)*
- It overflows in landscape and lands on a half page after rotation.
- Where: `MonthDropdown.tsx:23,52,72`.
- [x] **U-088 · P3 · all · Times and dates use English formats in other languages.** *(fixed in cf7275c)*
- Hour labels hard-code AM/PM (`TimeGridScrollView.tsx:437,640`, `WeekView.tsx:234,318`).
- Dates use US-order patterns (`EventDetailSheet.tsx:96-108`, `EventModal.tsx:525,545`,
`TasksSheet.tsx:212`).
- The AM/PM labels only show with the 12-hour setting, where they are the right notation; left
as is.
- [x] **U-089 · P3 · all · ICalSubscriptionSheet and RecurrenceEditor are English only.** *(no change needed: both components already go through t() and every key has a German entry; the parity doc is out of date)* From
`docs/parity/05-calendar.md`.
- [x] **U-090 · P3 · PP (font 1.3) · Month chips show only the start time, no title.** *(fixed in 95c3163)*
- [x] **U-091 · P3 · all · An event that starts above the visible hours shows as an untitled
block.** *(no change needed: this is a block scrolled partly out of the top of the grid, so its title is above the viewport; Google Calendar and Apple Calendar behave the same)*
### Contacts
- [x] **U-100 · P1 · all · Deleting a contact or group crashes the detail screen.** *(fixed in 295e292)*
- Hooks run after an early return: `ContactDetailScreen.tsx:149` before `:216`, and
`GroupDetailScreen.tsx:58` before `:130`.
- The delete empties the store, the screen re-renders with fewer hooks, and React throws. A
contact removed by a sync does the same.
- [x] **U-101 · P2 · PL · The address-book chip row is clipped at the bottom.** *(fixed in 0b37102)* Screenshot
`q_contacts` PL.
- [x] **U-102 · P2 · all · Back doesn't leave selection or search; it leaves the tab.** *(fixed in 756ceaa)*
- Where: `ContactsScreen.tsx`. Files and Settings already handle this.
- [x] **U-103 · P2 · all · The contact form's discard check only covers the on-screen back.** *(fixed in 1ebc5c6)*
- Hardware back drops edits silently.
- Where: `ContactFormScreen.tsx:764`. Fix: `usePreventRemove`.
- [x] **U-104 · P2 · all · Contact form fields have no next or submit chaining.** *(fixed in 33dc3cd)* Where:
`ContactFormScreen.tsx:1197-1283`.
- [x] **U-105 · P2 · TL TP · List and detail are separate full-screen pages.** *(fixed in 64a62b6)* Fix: phase 2.
### Files
- [x] **U-110 · P2 · all · The grid has a fixed 3 columns.** *(fixed in 2470e99)*
- The last row stretches its cells, so the columns don't line up. Tablet cells are huge
(`q_files_grid`).
- Where: `FilesScreen.tsx:989,1606`.
- [x] **U-111 · P2 · all · Dates and times ignore the app's language and the 12/24 h setting.** *(fixed in 63eefd7)*
- Example: "10:47 PM" next to 24 h times everywhere else.
- Where: `FilesScreen.tsx:127-132`.
- [x] **U-112 · P2 · all · Removing someone's share access has no confirmation.** *(fixed in d4982f6)* Where:
`ShareSheet.tsx:213`.
- [x] **U-113 · P2 · TL TP · There is no side-by-side preview.** *(fixed in 2470e99)* Fix: phase 2.
### Settings
- [x] **U-120 · P2 · all · Deleting a tag has no confirmation, while the reset next to it does.** *(fixed in 275d674)*
Where: `KeywordSettings.tsx:48,122`.
- [x] **U-121 · P2 · PP · The vacation subject input is a fixed 240 dp.** *(fixed in f22e851)*
- It squeezes its label into five lines.
- Where: `VacationSettings.tsx:434`; the same pattern in `AccountSecuritySettings.tsx:234`.
- [x] **U-122 · P2 · light · The amber banner in filters and the sieve editor is unreadable on
light.** *(fixed in 8f68d24)*
- Light yellow text on a faint brown fill.
- Where: `FilterSettings.tsx:515-519`, `SieveEditorSheet.tsx:208-212`.
- [x] **U-123 · P2 · TL TP · The section list and the page replace each other.** *(fixed in 8ab82cd)* Fix: phase 2.
- [ ] **U-124 · P3 · all · The language list is in neither name nor code order.** *(deferred: the order copies the webmail language switcher on purpose (comment in src/i18n/index.ts); change both together)* Example: Česky,
Slovenčina, Dansk.
### Login
- [x] **U-130 · P2 · TL TP · Login steps stretch to the full width.** *(fixed in 250c0a9)*
- Fields and buttons are 1248 dp wide (`t_login_confirm`).
- Fix: a centred column of about 480 dp.
- [x] **U-131 · P2 · all · Small targets: show-password 20 dp, header back 32 dp, email suggestion
chips about 26 dp.** *(fixed in ae1ef5b,c8434b0,776447d)* *(partly fixed in ae1ef5b (show password), c8434b0 (header); email suggestion chips still small)*
- [x] **U-132 · P2 · all · The password step's return keys don't move on.** *(fixed in ae1ef5b)*
- Username has no `next`. With TOTP on, the password field shows Next and does nothing.
- Where: `PasswordStep.tsx:56,78`.
- [ ] **U-133 · P2 · all · There is no way out while "Signing in" hangs.** *(deferred: a cancel needs an abortable sign-in in the auth flow, which is more than a UI fix; see product questions)* Hardware back is
swallowed. Where: `SigningInStep.tsx`, `LoginScreen.tsx:165`.
### Found in the re-walk
- [x] **U-134 · P3 · all · The all-day label in the day and week grid reads "All".** A one-row
strip cut the second line of "All day" ("Ganztägig" in German). *(fixed in e452f72)*
- [x] **U-135 · P2 · PP, split · The selection header pushed Delete off the screen.** Eight 44 dp
buttons with 12 dp gaps don't fit 412 dp; the count also kept its screen-reader label on the
normal header afterwards. *(fixed in 0d96d8d)*
- [ ] **U-136 · P3 · all · The day view keeps an empty all-day strip on days without all-day
events.** The strip takes the height of the busiest day in the loaded range so the grid doesn't
jump while paging (comment in `TimeGridScrollView.tsx`). *(deferred: a product call between a
steady grid and less blank space; see product questions)*
## Adaptive layout plan
**Breakpoints** come from the window width (`useWindowDimensions`), not the device type, so
split-screen, freeform windows and foldables work too:
| Class | Width | Typical |
|---|---|---|
| compact | < 600 dp | phones in portrait, narrow split-screen |
| medium | 600–839 dp | tablet portrait, unfolded foldables |
| expanded | ≥ 840 dp | tablet landscape |
A second flag, `shortHeight` (window height < 480 dp), covers phone landscape (914x411 dp). By
width alone that would be "expanded", but two panes and a rail in 411 dp of height are worse than
one pane. So a short window keeps the compact layouts and only compresses chrome: icon-only bars,
a merged header row, no tab labels.
A small hook, `src/lib/use-layout.ts`, returns `{ widthClass, shortHeight, isSplit }` and a pure
`layoutFor(width, height)` that can be tested.
**Navigation.** The phone stack stays as it is. A split view is a layout inside a tab, not a second
navigator:
- Medium and expanded put the tab bar on the left as a rail (`tabBarPosition: 'left'`, material
variant), like the webmail's navigation rail.
- Stack routes still exist and are still used on compact widths. On medium and expanded, the list
screens render the detail in a right-hand pane instead of pushing, and the detail screens' bodies
are shared components that take props instead of route params.
- If the window narrows while a pane shows something, the pane's selection is pushed as the normal
stack route, so nothing is lost.
**Per area:**
| Area | compact | medium | expanded |
|---|---|---|---|
| Mail | list → push thread | list (360 dp) + thread pane | persistent folder sidebar (280 dp) + list + thread |
| Calendar | as today | wider grid, 3-day shows 5 days | week shows 7 wide columns, 3-day shows 7, day shows 2 |
| Contacts | list → push detail | list + detail pane | list + detail pane |
| Files | list / grid | grid columns from width (about 140 dp each) | grid columns from width + preview pane when a file is selected |
| Settings | list ↔ page | section list (320 dp) + page | section list + page |
| Compose | full screen | centred panel, max 720 dp, dimmed backdrop | same |
| Sheets, dialogs, toasts | full width at the bottom | max 560 dp, centred | max 560 dp, centred |
| Login | as today | centred column, max 480 dp | same |
The thread, contact detail and settings pages cap their reading width (about 760 dp for the
mail body, 640 dp for forms) inside wide panes.
**State across rotation.** The activity keeps its state through `configChanges` (orientation,
screen size, smallest screen size, layout). Panes keep their selection in component state or the
store. Lists that depend on width remount with their focused index or date carried over.
## Final report (2026-10-15)
### Result
86 findings (U-001 to U-136; numbers were skipped where a candidate turned out to be a duplicate).
| Severity | Fixed | No change needed | Deferred |
| --- | --- | --- | --- |
| P1 | 9 | 0 | 0 |
| P2 | 57 | 1 | 2 |
| P3 | 7 | 4 | 6 |
Every logic change has a vitest test that fails without it. The full suite (3273 tests), `tsc`
and `npm run i18n:check` pass at the end.
### Areas and cells after the last walk
PP = phone portrait, PL = phone landscape, TP = tablet portrait, TL = tablet landscape. The last
walk ran the phone in German and dark, the tablet in English and light. Font scale 1.3 was checked
on PL and TL after a restart.
| Area | PP | PL | TP | TL |
| --- | --- | --- | --- | --- |
| Mail list, drawer, selection | ok | ok (search in header) | ok (list + message) | ok (folders + list + message) |
| Thread | ok | ok (icon-only toolbar) | ok (docked) | ok (docked) |
| Compose | ok | ok (format bar hidden while typing) | ok (panel) | ok (panel) |
| Calendar month / week / day | ok | ok | ok | ok |
| Contacts list, detail, form | ok | ok | ok (split) | ok (split) |
| Files and preview | ok | ok | ok (docked preview) | ok (docked preview) |
| Settings | ok | ok | ok (split) | ok (split) |
| Login | ok | ok | ok (centred column) | ok (centred column) |
### Deferred
- U-016 scrim token: all scrims are already the same; a refactor with no visible change.
- U-017 system alert vs Dialog: a style mismatch only; see the questions below.
- U-018 font size change while the app is open: handling `fontScale` in the app was tried and left
mixed old and new text sizes, worse than Android's recreate.
- U-045 "Zurück"/"Weiter": the strings come from the webmail's German catalog.
- U-069 recipient suggestions in phone landscape: no room above the keyboard; needs a new picker.
- U-124 language order: copies the webmail switcher on purpose.
- U-133 no way out of a hung sign-in: needs an abortable sign-in in the auth flow.
- U-136 empty all-day strip in the day view: a product call; see below.
### Product questions
1. Dark-mode links stay blue (`#60a5fa`) while the rest follows the webmail's neutral palette.
Recommendation: keep blue; links in mail bodies need to stand out.
2. Should every confirmation use the app's Dialog instead of the system alert (U-017)?
Recommendation: yes, for destructive actions first, as one pass of its own.
3. Sign-in that hangs (U-133): add a Cancel that aborts discovery and the token request.
Recommendation: yes; it is the only screen with no way back.
4. Day view all-day strip (U-136): keep one height across days, or size it to the day on screen?
Recommendation: size it to the visible days once paging settles.
5. Month view on a phone now shows titles instead of start times even with "show time" on, since
the time filled the whole chip. Recommendation: keep; times come back where cells are wide.
6. Files keeps a full-screen preview on phones and docks it on tablets. Recommendation: keep.
7. The dark background of plain-text mail is fixed at `#0a0a0a` and ignores the built-in themes.
Recommendation: take it from the theme palette.
8. APP.md still lists the dark palette as open; the app now uses the webmail standard theme. The
note should be updated in the branding repo.
### Not tested
- iOS and iPad: nothing was run. The risk is in the `supportedOrientations` change on every Modal,
the keyboard tracker (built around Android's missing frame after rotation) and the safe-area
insets in landscape.
- Real phones and tablets, foldables (fold and unfold mid-task), split-screen on real hardware.
- Push notifications and their deep links.
- Home-screen widgets: they truncate German labels; not changed here.
- The test account came from a login created for this audit ("Stalwart test – uxaudit@example.org"
in the Agent collection), because the "Stalwart test servers" item was not visible to the agent.
+6
View File
@@ -16,6 +16,12 @@ AppRegistry.registerHeadlessTask('BulwarkPushTask', () => async (data: Parameter
await Promise.allSettled([pushBackgroundTask(data), refreshWidgetsInBackground()]);
});
// Notification quick actions (Mark as read, Delete): run without launching the app UI.
AppRegistry.registerHeadlessTask('BulwarkNotificationAction', () => async (data: unknown) => {
const { handleNotificationAction } = require('./src/lib/push-background-task') as typeof import('./src/lib/push-background-task');
await Promise.allSettled([handleNotificationAction(data), refreshWidgetsInBackground()]);
});
// Device sync (Android, #34): the contacts and calendar sync adapters run the
// sync engine through this task, headless or next to the UI. The engine is
// required on first use so an app start does not load it.
+15 -2
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "رابط",
"export_ics_short": ".ics",
"nothing_planned": "لا شيء مخطط. انقر للإنشاء.",
"tap_to_create": "اضغط + لإنشاء حدث"
},
"fab": {
"event": "حدث",
"task": "مهمة"
},
"form": {
"description_placeholder": "ملاحظات، جدول أعمال…",
"location_placeholder": "المكان أو العنوان",
@@ -127,6 +132,10 @@
"not_found": "لم تعد هذه المهمة متاحة.",
"save_error": "فشل حفظ المهمة",
"update_error": "فشل تحديث المهمة"
},
"views": {
"schedule": "الجدول",
"three_days": "3 أيام"
}
},
"common": {
@@ -398,7 +407,10 @@
"preview": {
"open_external": "فتح في تطبيق",
"pdf_external": "تُفتح ملفات PDF في عارض PDF على هذا الجهاز.",
"unsupported": "لا توجد معاينة لهذا النوع من الملفات."
"unsupported": "لا توجد معاينة لهذا النوع من الملفات.",
"media_unsupported": "لا يمكن تشغيل هذا الملف هنا.",
"pdf_protected": "ملف PDF هذا محمي بكلمة مرور. افتحه في تطبيق آخر لإدخال كلمة المرور.",
"pdf_unreadable": "لا يمكن عرض ملف PDF هذا هنا."
},
"received_via": "إلى {name}",
"remove_color": "إزالة الوسم",
@@ -1168,6 +1180,7 @@
"release_page_pending_desc": "تابع تقدّم البناء أو نزّل ملف APK يدويًا.",
"severity_deprecated": "إلزامي",
"severity_security": "أمني",
"title": "تحديثات التطبيق"
"title": "تحديثات التطبيق",
"verifying": "جارٍ التحقق…"
}
}
+15 -2
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Enllaç",
"export_ics_short": ".ics",
"nothing_planned": "Res planificat. Toca per crear.",
"tap_to_create": "Toqueu + per crear-ne un"
},
"fab": {
"event": "Esdeveniment",
"task": "Tasca"
},
"form": {
"description_placeholder": "Notes, ordre del dia…",
"location_placeholder": "Lloc o adreça",
@@ -127,6 +132,10 @@
"not_found": "Aquesta tasca ja no està disponible.",
"save_error": "No s'ha pogut desar la tasca",
"update_error": "No s'ha pogut actualitzar la tasca"
},
"views": {
"schedule": "Agenda",
"three_days": "3 dies"
}
},
"common": {
@@ -398,7 +407,10 @@
"preview": {
"open_external": "Obre en una aplicació",
"pdf_external": "Els PDF s'obren amb el visualitzador de PDF d'aquest dispositiu.",
"unsupported": "No hi ha previsualització per a aquest tipus de fitxer."
"unsupported": "No hi ha previsualització per a aquest tipus de fitxer.",
"media_unsupported": "Aquest fitxer no es pot reproduir aquí.",
"pdf_protected": "Aquest PDF està protegit amb contrasenya. Obre'l en una altra aplicació per introduir-la.",
"pdf_unreadable": "Aquest PDF no es pot mostrar aquí."
},
"received_via": "per a {name}",
"remove_color": "Elimina l'etiqueta",
@@ -1168,6 +1180,7 @@
"release_page_pending_desc": "Seguiu el progrés de la compilació o baixeu l'APK manualment.",
"severity_deprecated": "Obligatòria",
"severity_security": "Seguretat",
"title": "Actualitzacions de l'aplicació"
"title": "Actualitzacions de l'aplicació",
"verifying": "S'està verificant…"
}
}
+15 -2
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Odkaz",
"export_ics_short": ".ics",
"nothing_planned": "Nic naplánováno. Klepnutím vytvoříte.",
"tap_to_create": "Událost vytvoříte klepnutím na +"
},
"fab": {
"event": "Událost",
"task": "Úkol"
},
"form": {
"description_placeholder": "Poznámky, program…",
"location_placeholder": "Místo nebo adresa",
@@ -127,6 +132,10 @@
"not_found": "Tento úkol už není k dispozici.",
"save_error": "Úkol se nepodařilo uložit",
"update_error": "Úkol se nepodařilo aktualizovat"
},
"views": {
"schedule": "Program",
"three_days": "3 dny"
}
},
"common": {
@@ -398,7 +407,10 @@
"preview": {
"open_external": "Otevřít v aplikaci",
"pdf_external": "Soubory PDF se otevírají v prohlížeči PDF na tomto zařízení.",
"unsupported": "Pro tento typ souboru není náhled k dispozici."
"unsupported": "Pro tento typ souboru není náhled k dispozici.",
"media_unsupported": "Tento soubor zde nelze přehrát.",
"pdf_protected": "Toto PDF je chráněné heslem. Otevřete ho v jiné aplikaci a zadejte heslo.",
"pdf_unreadable": "Toto PDF zde nelze zobrazit."
},
"received_via": "pro {name}",
"remove_color": "Odebrat štítek",
@@ -1168,6 +1180,7 @@
"release_page_pending_desc": "Sledujte průběh sestavení nebo si stáhněte APK ručně.",
"severity_deprecated": "Povinná",
"severity_security": "Zabezpečení",
"title": "Aktualizace aplikace"
"title": "Aktualizace aplikace",
"verifying": "Ověřování…"
}
}
+15 -2
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Link",
"export_ics_short": ".ics",
"nothing_planned": "Intet planlagt. Tryk for at oprette.",
"tap_to_create": "Tryk på + for at oprette en"
},
"fab": {
"event": "Begivenhed",
"task": "Opgave"
},
"form": {
"description_placeholder": "Noter, dagsorden…",
"location_placeholder": "Sted eller adresse",
@@ -127,6 +132,10 @@
"not_found": "Denne opgave er ikke længere tilgængelig.",
"save_error": "Kunne ikke gemme opgaven",
"update_error": "Kunne ikke opdatere opgaven"
},
"views": {
"schedule": "Tidsplan",
"three_days": "3 dage"
}
},
"common": {
@@ -398,7 +407,10 @@
"preview": {
"open_external": "Åbn i app",
"pdf_external": "PDF'er åbnes i din PDF-fremviser på denne enhed.",
"unsupported": "Ingen forhåndsvisning for denne filtype."
"unsupported": "Ingen forhåndsvisning for denne filtype.",
"media_unsupported": "Denne fil kan ikke afspilles her.",
"pdf_protected": "Denne PDF er beskyttet med adgangskode. Åbn den i en anden app for at indtaste adgangskoden.",
"pdf_unreadable": "Denne PDF kan ikke vises her."
},
"received_via": "til {name}",
"remove_color": "Fjern tag",
@@ -1168,6 +1180,7 @@
"release_page_pending_desc": "Følg buildets fremskridt, eller download APK'en manuelt.",
"severity_deprecated": "Påkrævet",
"severity_security": "Sikkerhed",
"title": "Appopdateringer"
"title": "Appopdateringer",
"verifying": "Verificerer…"
}
}
+125 -6
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Link",
"export_ics_short": ".ics",
"nothing_planned": "Nichts geplant. Zum Erstellen tippen.",
"tap_to_create": "Tippen Sie auf +, um einen zu erstellen"
},
"fab": {
"event": "Termin",
"task": "Aufgabe"
},
"form": {
"description_placeholder": "Notizen, Tagesordnung…",
"location_placeholder": "Ort oder Adresse",
@@ -123,10 +128,16 @@
"add_placeholder": "Aufgabe hinzufügen…",
"add_time": "Uhrzeit hinzufügen",
"clear_due_date": "Fälligkeitsdatum entfernen",
"delete_confirm": "Diese Aufgabe löschen?",
"delete_error": "Aufgabe konnte nicht gelöscht werden",
"not_found": "Diese Aufgabe ist nicht mehr verfügbar.",
"save_error": "Aufgabe konnte nicht gespeichert werden",
"update_error": "Aufgabe konnte nicht aktualisiert werden"
},
"views": {
"n_days": "{count} Tage",
"schedule": "Terminübersicht",
"three_days": "3 Tage"
}
},
"common": {
@@ -301,6 +312,10 @@
"send_now": "Jetzt senden",
"send_timeout_body": "Die Nachricht wurde möglicherweise bereits versendet. Prüfen Sie Ihren Gesendet-Ordner, bevor Sie sie erneut senden.",
"send_timeout_title": "Keine Antwort vom Server",
"toolbar": {
"insert_image": "Bild einfügen",
"remove_link": "Link entfernen"
},
"undo_send_label": "Senden in {seconds} s…",
"upload_failed_short": "Upload fehlgeschlagen",
"uploading": "Wird hochgeladen...",
@@ -398,7 +413,10 @@
"preview": {
"open_external": "In App öffnen",
"pdf_external": "PDFs werden in Ihrem PDF-Viewer auf diesem Gerät geöffnet.",
"unsupported": "Keine Vorschau für diesen Dateityp."
"unsupported": "Keine Vorschau für diesen Dateityp.",
"media_unsupported": "Diese Datei kann hier nicht abgespielt werden.",
"pdf_protected": "Diese PDF ist passwortgeschützt. Öffne sie in einer anderen App, um das Passwort einzugeben.",
"pdf_unreadable": "Diese PDF kann hier nicht angezeigt werden."
},
"received_via": "an {name}",
"remove_color": "Label entfernen",
@@ -438,6 +456,7 @@
"share_no_matches": "Keine Treffer",
"share_no_users": "Keine anderen Benutzer gefunden",
"share_remove": "Zugriff entfernen",
"share_remove_confirm": "Zugriff entfernen?",
"share_role_custom": "Benutzerdefiniert",
"share_search_placeholder": "Nach Name oder E-Mail suchen",
"share_unavailable": "Teilen ist auf diesem Gerät nicht verfügbar",
@@ -632,12 +651,14 @@
},
"toolbar_labels": {
"description_mobile": "Textbeschriftungen neben den Symbolleistensymbolen anzeigen."
}
},
"description_mobile": "Passe an, wie die App aussieht"
},
"badges": {
"experimental": "Experimentell",
"not_implemented": "Nicht implementiert",
"unavailable": "Nicht verfügbar"
"unavailable": "Nicht verfügbar",
"not_on_server": "Nicht auf diesem Server"
},
"contacts": {
"book_count": "{count, plural, one {# Kontakt} other {# Kontakte}}",
@@ -857,6 +878,7 @@
"text_signature": "Klartext-Signatur"
},
"keywords": {
"delete_confirm_title": "Tag „{label}“ löschen?",
"description_mobile": "Farbige Labels zum Organisieren Ihrer E-Mails.",
"discover": {
"add_all": "Alle hinzufügen",
@@ -979,7 +1001,15 @@
"window": "Zeitraum",
"window_24h": "Letzte 24 Stunden",
"window_days": "Letzte {count} Tage",
"window_desc": "Wie weit zurück zwischengespeichert wird, gemessen am Empfangsdatum der Nachricht."
"window_desc": "Wie weit zurück zwischengespeichert wird, gemessen am Empfangsdatum der Nachricht.",
"description_short": "Neue Nachrichten auf dem Telefon behalten, damit sie auch ohne Verbindung aufgehen.",
"keep_24h": "24 Stunden",
"keep_days": "{count} Tage",
"keep_from": "Mails der letzten",
"messages_offline": "{count, plural, one {# Nachricht offline} other {# Nachrichten offline}}",
"synced": "Synchronisiert {when}",
"use_at_most": "Höchstens belegen",
"used_of": "{used} von {limit} MB"
},
"plugins": {
"mobile_description": "Plugins laufen im Webmail und erweitern es im Browser.",
@@ -1087,7 +1117,10 @@
"tabs": {
"plugins": "Plugins",
"themes": "Designs",
"updates": "Updates"
"updates": "Updates",
"storage": "Speicher & Sicherung",
"about": "Über",
"troubleshooting": "Fehlerbehebung"
},
"templates": {
"delete_confirm": "\"{name}\" endgültig löschen?",
@@ -1111,6 +1144,86 @@
"warnings": {
"date_format": "Datumsangaben müssen das Format \"YYYY-MM-DD\" oder \"YYYY-MM-DD HH:MM\" haben"
}
},
"about": {
"check_now": "Jetzt prüfen",
"checked_at": "Geprüft {when}",
"licences": "Open-Source-Lizenzen",
"licences_desc": "AGPL-3.0 für die App und die Bibliotheken, die sie nutzt",
"never_checked": "Noch nicht geprüft",
"privacy": "Datenschutz",
"privacy_desc": "Was auf diesem Telefon bleibt und was an deinen Server geht",
"report_problem": "Problem melden",
"report_problem_desc": "Öffnet ein Issue auf GitHub mit eingetragener Version",
"server": "Server",
"server_value": "{host} · angemeldet als {user}",
"source_code": "Quellcode",
"this_device": "Dieses Gerät",
"this_device_desc": "Womit die App verbunden ist und wo du Hilfe bekommst.",
"up_to_date": "Aktuell",
"updates": "Updates"
},
"backup": {
"description": "Übertrage deine Einstellungen auf ein anderes Telefon oder ins Webmail.",
"export_import": "Exportieren oder importieren",
"export_import_desc": "Eine JSON-Datei im selben Format wie im Webmail",
"reset": "Zurücksetzen",
"reset_desc": "Zurück auf die Standardwerte; Mails bleiben",
"title": "Einstellungen sichern"
},
"licences": {
"app_desc": "Die App ist freie Software unter der {licence}. Du darfst sie nutzen, untersuchen, weitergeben und ändern.",
"libraries": "Bibliotheken",
"libraries_desc": "Open-Source-Code, mit dem die App gebaut ist.",
"read_licence": "Den vollständigen Lizenztext lesen"
},
"privacy": {
"favicons": "Absendersymbole für geschäftliche Mails kommen vom Symboldienst von DuckDuckGo, der dabei die Domain des Absenders sieht. Ausschalten kannst du sie unter Darstellung → Absender-Favicons.",
"no_tracking": "Die App hat keine Analyse und keine Werbung.",
"other_title": "Weitere Verbindungen",
"phone_backup": "Nichts davon landet in der Cloud-Sicherung von Android.",
"phone_secure": "Deine Anmeldedaten liegen im sicheren Speicher des Systems. Einstellungen, der Offline-Cache und noch nicht synchronisierte Änderungen liegen im Speicher der App.",
"phone_title": "Auf diesem Telefon",
"push": "Wenn Benachrichtigungen an sind, meldet dein Server dem Push-Relay ({relay}), dass sich etwas geändert hat, und das Relay weckt die App über {service}. Das Relay sieht ein Gerätetoken und eine Änderungsmarke, nie deine Mails. Unter Benachrichtigungen kannst du ein eigenes Relay eintragen.",
"push_title": "Push-Benachrichtigungen",
"server": "Mails, Kalender, Kontakte und Dateien werden von deinem Server geladen und dort gespeichert. Außer den Verbindungen unten spricht die App nur mit deinem Server.",
"server_title": "Dein Mailserver",
"service_apple": "den Push-Dienst von Apple",
"service_google": "den Push-Dienst von Google",
"updates": "Die App fragt bei GitHub nach der neuesten Version, um dir Updates anzuzeigen.",
"updates_android": "Die App fragt bei GitHub nach der neuesten Version, um dir Updates anzuzeigen. Ausschalten kannst du das unter Updates."
},
"summary": {
"events_off": "Termine aus",
"events_on": "Termine an",
"mail_off": "Mail aus",
"mail_on": "Mail an",
"offline_off": "Offline-Mails aus",
"offline_on": "Offline-Mails an · {days, plural, one {# Tag} other {# Tage}}, {mb} MB",
"version": "Version {version}",
"week_starts": "Woche beginnt {day}",
"layout": "Schnellaktionen, Wischen, Reihenfolge",
"reading": "Gelesen-Status, Löschen, Anhänge",
"composing": "Signatur, Sendeverzögerung, Entwürfe",
"identities": "Adressen, von denen du sendest",
"vacation": "Automatische Antwort bei Abwesenheit",
"filters": "Regeln für eingehende Mails",
"templates": "Wiederverwendbare Nachrichten",
"folders": "Anlegen, umbenennen, Rollen",
"keywords": "Namen und Farben der Tags",
"downloads": "Dateinamen für gespeicherte Mails",
"security": "Passwort, Zwei-Faktor, App-Passwörter",
"content_senders": "Externe Bilder, vertrauenswürdige Absender",
"contacts": "Adressbücher, Sortierung",
"files": "Ansicht, Sortierung, Vorschaubilder",
"sidebar_apps": "Web-Apps im Seitenmenü",
"updates": "Neue Versionen installieren"
},
"troubleshooting": {
"cache_desc": "Wenn eine Liste veraltet oder falsch aussieht.",
"cache_title": "Zwischengespeicherte Daten",
"row_desc": "Daten neu laden, Debug-Protokoll",
"title": "Fehlerbehebung"
}
},
"sidebar": {
@@ -1119,6 +1232,11 @@
"remove_account_message": "Von {account} auf diesem Gerät abmelden. Ihre E-Mails bleiben auf dem Server.",
"remove_account_title": "Konto entfernen?",
"show_unread": "Ungelesene anzeigen",
"sign_out_all_confirm_message": "Alle Konten werden von diesem Gerät entfernt. Sie können sich jederzeit wieder anmelden.",
"sign_out_all_confirm_title": "Von allen Konten abmelden?",
"sign_out_confirm_message": "Das Konto wird von diesem Gerät entfernt. Sie können sich jederzeit wieder anmelden.",
"sign_out_confirm_title": "Abmelden?",
"sign_out_confirm_title_account": "Von {account} abmelden?",
"tab_unavailable": "{name} (nicht verfügbar)"
},
"unified_mailbox": {
@@ -1168,7 +1286,8 @@
"release_page_pending_desc": "Den Build-Fortschritt verfolgen oder die APK manuell herunterladen.",
"severity_deprecated": "Erforderlich",
"severity_security": "Sicherheit",
"title": "App-Updates"
"title": "App-Updates",
"verifying": "Wird überprüft…"
},
"widgets": {
"attachments": {
+124 -4
View File
@@ -13,6 +13,9 @@
"indigo": "Indigo",
"teal": "Teal"
},
"detail": {
"location_hint": "Opens the location; long-press to copy it"
},
"drawer": {
"my_calendars": "My calendars",
"no_calendars": "No calendars yet.",
@@ -25,8 +28,13 @@
"events": {
"copy_link_short": "Link",
"export_ics_short": ".ics",
"nothing_planned": "Nothing planned. Tap to create.",
"tap_to_create": "Tap + to create one"
},
"fab": {
"event": "Event",
"task": "Task"
},
"form": {
"description_placeholder": "Notes, agenda…",
"location_placeholder": "Place or address",
@@ -123,10 +131,16 @@
"add_placeholder": "Add a task…",
"add_time": "Add time",
"clear_due_date": "Clear due date",
"delete_confirm": "Delete this task?",
"delete_error": "Failed to delete task",
"not_found": "This task is no longer available.",
"save_error": "Failed to save task",
"update_error": "Failed to update task"
},
"views": {
"n_days": "{count} days",
"schedule": "Schedule",
"three_days": "3 days"
}
},
"common": {
@@ -309,7 +323,9 @@
"send_timeout_title": "No answer from the server",
"toolbar": {
"background_color": "Background color",
"remove_background_color": "Remove background color"
"insert_image": "Insert image",
"remove_background_color": "Remove background color",
"remove_link": "Remove link"
},
"undo_send_label": "Sending in {seconds}s…",
"upload_failed_short": "Upload failed",
@@ -408,7 +424,10 @@
"preview": {
"open_external": "Open in app",
"pdf_external": "PDFs open in your PDF viewer on this device.",
"unsupported": "No preview for this file type."
"unsupported": "No preview for this file type.",
"media_unsupported": "This file can't be played here.",
"pdf_protected": "This PDF is password-protected. Open it in another app to enter the password.",
"pdf_unreadable": "This PDF can't be shown here."
},
"received_via": "to {name}",
"remove_color": "Remove tag",
@@ -448,6 +467,7 @@
"share_no_matches": "No matches",
"share_no_users": "No other users found",
"share_remove": "Remove access",
"share_remove_confirm": "Remove access?",
"share_role_custom": "Custom",
"share_search_placeholder": "Search by name or email",
"share_unavailable": "Sharing is not available on this device",
@@ -610,6 +630,24 @@
"send_now_failed": "Could not send now"
},
"settings": {
"about": {
"check_now": "Check now",
"checked_at": "Checked {when}",
"licences": "Open-source licences",
"licences_desc": "AGPL-3.0 for the app, and the libraries it uses",
"never_checked": "Not checked yet",
"privacy": "Privacy",
"privacy_desc": "What stays on this phone and what goes to your server",
"report_problem": "Report a problem",
"report_problem_desc": "Opens an issue on GitHub with the version filled in",
"server": "Server",
"server_value": "{host} · signed in as {user}",
"source_code": "Source code",
"this_device": "This device",
"this_device_desc": "What the app is connected to and how to get help.",
"up_to_date": "Up to date",
"updates": "Updates"
},
"account": {
"accounts": {
"description_mobile": "Reorder how accounts appear in the account switcher, pick the default, or sign one out.",
@@ -636,6 +674,7 @@
}
},
"appearance": {
"description_mobile": "Customize how the app looks",
"language": {
"restart_for_direction": "Restart the app to apply the new text direction.",
"system_default": "System default"
@@ -644,9 +683,18 @@
"description_mobile": "Display text labels next to toolbar icons."
}
},
"backup": {
"description": "Move your settings to another phone or the webmail.",
"export_import": "Export or import",
"export_import_desc": "A JSON file, same format as the webmail",
"reset": "Reset",
"reset_desc": "Back to the defaults; mail stays",
"title": "Settings backup"
},
"badges": {
"experimental": "Experimental",
"not_implemented": "Not implemented",
"not_on_server": "Not on this server",
"unavailable": "Unavailable"
},
"contacts": {
@@ -873,6 +921,7 @@
"text_signature": "Plain-text signature"
},
"keywords": {
"delete_confirm_title": "Delete tag \"{label}\"?",
"description_mobile": "Colored tags to organize your mail.",
"discover": {
"add_all": "Add all",
@@ -910,6 +959,12 @@
"unified_cross_account": "Unified views across accounts",
"unified_cross_account_description": "Combine every signed-in account in \"All inboxes\". Off keeps the unified views inside the active account and its shared folders."
},
"licences": {
"app_desc": "The app is free software under the {licence}. You can use, study, share and change it.",
"libraries": "Libraries",
"libraries_desc": "Open-source code the app is built with.",
"read_licence": "Read the full licence text"
},
"message_list_order": {
"description": "Which messages come first in the list. Newest first is the tie-breaker within each group; the header toggle still flips oldest/newest.",
"preset_chronological": "Newest first",
@@ -975,23 +1030,31 @@
"clear_title": "Clear offline mail",
"days_ago": "{count} d ago",
"description": "Download recent messages so they open instantly and remain readable without a connection. Bodies only — attachments are still fetched on demand.",
"description_short": "Keep recent messages on this phone so they open without a connection.",
"downloading": "Downloading {completed}/{total}…",
"enabled": "Cache recent mail",
"enabled_desc": "When on, the app keeps message bodies for the selected window on this device.",
"hours_ago": "{count} h ago",
"just_now": "just now",
"keep_24h": "24 hours",
"keep_days": "{count} days",
"keep_from": "Keep mail from the last",
"last_sync": "Last sync {when}",
"last_sync_failed": "Last sync failed: {message}",
"max_size": "Maximum size",
"max_size_desc": "Oldest messages are removed when the cache grows past this.",
"messages_offline": "{count, plural, one {# message offline} other {# messages offline}}",
"minutes_ago": "{count} min ago",
"never": "never",
"nothing_cached": "Nothing cached yet",
"queued_changes": "{count, plural, one {# change} other {# changes}} waiting to sync",
"scanning": "Scanning recent mail…",
"sync_now": "Sync now",
"synced": "Synced {when}",
"syncing": "Syncing…",
"title": "Offline mail",
"use_at_most": "Use at most",
"used_of": "{used} of {limit} MB",
"window": "Window",
"window_24h": "Last 24 hours",
"window_days": "Last {count} days",
@@ -1001,6 +1064,22 @@
"mobile_description": "Plugins run inside the webmail and extend it in the browser.",
"mobile_explainer": "The mobile app has no plugin runtime. Install and manage plugins from the webmail under Settings → Plugins; themes and filters they provide are not applied here."
},
"privacy": {
"favicons": "Sender icons for business mail come from DuckDuckGo's icon service, which sees the sender's domain. Turn them off under Appearance → Sender favicons.",
"no_tracking": "The app has no analytics and no ads.",
"other_title": "Other connections",
"phone_backup": "None of it goes into Android's cloud backup.",
"phone_secure": "Your sign-in details are kept in the system's secure storage. Settings, the offline mail cache and changes waiting to sync are kept in the app's own storage.",
"phone_title": "On this phone",
"push": "When notifications are on, your server tells the push relay ({relay}) that something changed, and the relay wakes the app through {service}. The relay sees a device token and a change marker, never your mail. You can use your own relay under Notifications.",
"push_title": "Push notifications",
"server": "Mail, calendars, contacts and files are loaded from your server and saved back to it. Apart from the connections below, the app talks only to your server.",
"server_title": "Your mail server",
"service_apple": "Apple's push service",
"service_google": "Google's push service",
"updates": "The app asks GitHub for the latest release to tell you about updates.",
"updates_android": "The app asks GitHub for the latest release to tell you about updates. Turn that off under Updates."
},
"security": {
"client_cert": {
"active": "Active: {alias}",
@@ -1100,10 +1179,39 @@
"mobile_description": "Sign and encrypt mail with S/MIME certificates.",
"mobile_explainer": "S/MIME is not available in the mobile app yet. Manage keys and certificates in the webmail; signed messages are shown without verification here."
},
"summary": {
"events_off": "events off",
"events_on": "events on",
"mail_off": "Mail off",
"mail_on": "Mail on",
"offline_off": "Offline mail off",
"offline_on": "Offline mail on · {days, plural, one {# day} other {# days}}, {mb} MB",
"version": "Version {version}",
"week_starts": "week starts {day}",
"layout": "Quick actions, swipes, list order",
"reading": "Read status, delete, attachments",
"composing": "Signature, send delay, drafts",
"identities": "Addresses you send from",
"vacation": "Automatic reply while away",
"filters": "Rules for incoming mail",
"templates": "Reusable messages",
"folders": "Create, rename, roles",
"keywords": "Tag names and colours",
"downloads": "File names for saved mail",
"security": "Password, two-factor, app passwords",
"content_senders": "Remote images, trusted senders",
"contacts": "Address books, sorting",
"files": "View, sorting, thumbnails",
"sidebar_apps": "Web apps in the side menu",
"updates": "Install new versions"
},
"tabs": {
"plugins": "Plugins",
"themes": "Themes",
"updates": "Updates"
"updates": "Updates",
"storage": "Storage & backup",
"about": "About",
"troubleshooting": "Troubleshooting"
},
"templates": {
"delete_confirm": "Permanently delete \"{name}\"?",
@@ -1123,6 +1231,12 @@
"mobile_description": "Pick a colour theme. Themes follow the light/dark setting from Appearance; custom theme packages are managed in the webmail.",
"title": "Themes"
},
"troubleshooting": {
"cache_desc": "When a list looks stale or wrong.",
"cache_title": "Cached data",
"row_desc": "Refresh cached data, debug logging",
"title": "Troubleshooting"
},
"vacation": {
"warnings": {
"date_format": "Dates must be \"YYYY-MM-DD\" or \"YYYY-MM-DD HH:MM\""
@@ -1135,6 +1249,11 @@
"remove_account_message": "Sign out of {account} on this device. Your mail stays on the server.",
"remove_account_title": "Remove account?",
"show_unread": "Show unread",
"sign_out_all_confirm_message": "Every account is removed from this device. You can sign in again at any time.",
"sign_out_all_confirm_title": "Sign out of all accounts?",
"sign_out_confirm_message": "The account is removed from this device. You can sign in again at any time.",
"sign_out_confirm_title": "Sign out?",
"sign_out_confirm_title_account": "Sign out of {account}?",
"tab_unavailable": "{name} (unavailable)"
},
"unified_mailbox": {
@@ -1184,7 +1303,8 @@
"release_page_pending_desc": "Watch the build progress or download the APK manually.",
"severity_deprecated": "Required",
"severity_security": "Security",
"title": "App updates"
"title": "App updates",
"verifying": "Verifying…"
},
"widgets": {
"attachments": {
+15 -2
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Enlace",
"export_ics_short": ".ics",
"nothing_planned": "Nada planificado. Toca para crear.",
"tap_to_create": "Toca + para crear uno"
},
"fab": {
"event": "Evento",
"task": "Tarea"
},
"form": {
"description_placeholder": "Notas, orden del día…",
"location_placeholder": "Lugar o dirección",
@@ -127,6 +132,10 @@
"not_found": "Esta tarea ya no está disponible.",
"save_error": "No se pudo guardar la tarea",
"update_error": "No se pudo actualizar la tarea"
},
"views": {
"schedule": "Agenda",
"three_days": "3 días"
}
},
"common": {
@@ -398,7 +407,10 @@
"preview": {
"open_external": "Abrir en una app",
"pdf_external": "Los PDF se abren en el visor de PDF de este dispositivo.",
"unsupported": "No hay vista previa para este tipo de archivo."
"unsupported": "No hay vista previa para este tipo de archivo.",
"media_unsupported": "Este archivo no se puede reproducir aquí.",
"pdf_protected": "Este PDF está protegido con contraseña. Ábrelo en otra aplicación para introducirla.",
"pdf_unreadable": "Este PDF no se puede mostrar aquí."
},
"received_via": "para {name}",
"remove_color": "Eliminar etiqueta",
@@ -1168,6 +1180,7 @@
"release_page_pending_desc": "Sigue el progreso de la compilación o descarga el APK manualmente.",
"severity_deprecated": "Obligatoria",
"severity_security": "Seguridad",
"title": "Actualizaciones de la app"
"title": "Actualizaciones de la app",
"verifying": "Verificando…"
}
}
+15 -2
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "لینک",
"export_ics_short": ".ics",
"nothing_planned": "برنامه‌ای نیست. برای ایجاد ضربه بزنید.",
"tap_to_create": "برای ایجاد، روی + ضربه بزنید"
},
"fab": {
"event": "رویداد",
"task": "کار"
},
"form": {
"description_placeholder": "یادداشت‌ها، دستور جلسه…",
"location_placeholder": "مکان یا نشانی",
@@ -127,6 +132,10 @@
"not_found": "این وظیفه دیگر در دسترس نیست.",
"save_error": "ذخیره وظیفه ناموفق بود",
"update_error": "به‌روزرسانی وظیفه ناموفق بود"
},
"views": {
"schedule": "برنامه",
"three_days": "۳ روز"
}
},
"common": {
@@ -398,7 +407,10 @@
"preview": {
"open_external": "باز کردن در برنامه",
"pdf_external": "فایل‌های PDF در نمایشگر PDF این دستگاه باز می‌شوند.",
"unsupported": "برای این نوع فایل پیش‌نمایشی وجود ندارد."
"unsupported": "برای این نوع فایل پیش‌نمایشی وجود ندارد.",
"media_unsupported": "این فایل اینجا پخش نمی‌شود.",
"pdf_protected": "این PDF با رمز عبور محافظت شده است. برای وارد کردن رمز، آن را در برنامهٔ دیگری باز کنید.",
"pdf_unreadable": "این PDF اینجا نمایش داده نمی‌شود."
},
"received_via": "به {name}",
"remove_color": "حذف برچسب",
@@ -1168,6 +1180,7 @@
"release_page_pending_desc": "روند ساخت را دنبال کنید یا APK را دستی دانلود کنید.",
"severity_deprecated": "الزامی",
"severity_security": "امنیتی",
"title": "به‌روزرسانی‌های برنامه"
"title": "به‌روزرسانی‌های برنامه",
"verifying": "در حال بررسی…"
}
}
+15 -2
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Lien",
"export_ics_short": ".ics",
"nothing_planned": "Rien de prévu. Touchez pour créer.",
"tap_to_create": "Appuyez sur + pour en créer un"
},
"fab": {
"event": "Événement",
"task": "Tâche"
},
"form": {
"description_placeholder": "Notes, ordre du jour…",
"location_placeholder": "Lieu ou adresse",
@@ -127,6 +132,10 @@
"not_found": "Cette tâche n'est plus disponible.",
"save_error": "Échec de l'enregistrement de la tâche",
"update_error": "Échec de la mise à jour de la tâche"
},
"views": {
"schedule": "Planning",
"three_days": "3 jours"
}
},
"common": {
@@ -398,7 +407,10 @@
"preview": {
"open_external": "Ouvrir dans une app",
"pdf_external": "Les PDF s'ouvrent dans votre lecteur PDF sur cet appareil.",
"unsupported": "Aucun aperçu pour ce type de fichier."
"unsupported": "Aucun aperçu pour ce type de fichier.",
"media_unsupported": "Ce fichier ne peut pas être lu ici.",
"pdf_protected": "Ce PDF est protégé par un mot de passe. Ouvrez-le dans une autre application pour le saisir.",
"pdf_unreadable": "Ce PDF ne peut pas être affiché ici."
},
"received_via": "à {name}",
"remove_color": "Retirer l'étiquette",
@@ -1168,6 +1180,7 @@
"release_page_pending_desc": "Suivez l'avancement de la compilation ou téléchargez l'APK manuellement.",
"severity_deprecated": "Requise",
"severity_security": "Sécurité",
"title": "Mises à jour de l'application"
"title": "Mises à jour de l'application",
"verifying": "Vérification…"
}
}
+15 -2
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "קישור",
"export_ics_short": ".ics",
"nothing_planned": "אין תוכניות. הקש כדי ליצור.",
"tap_to_create": "הקש על + כדי ליצור"
},
"fab": {
"event": "אירוע",
"task": "משימה"
},
"form": {
"description_placeholder": "הערות, סדר יום…",
"location_placeholder": "מקום או כתובת",
@@ -127,6 +132,10 @@
"not_found": "המשימה הזו אינה זמינה עוד.",
"save_error": "שמירת המשימה נכשלה",
"update_error": "עדכון המשימה נכשל"
},
"views": {
"schedule": "לוח זמנים",
"three_days": "3 ימים"
}
},
"common": {
@@ -398,7 +407,10 @@
"preview": {
"open_external": "פתח באפליקציה",
"pdf_external": "קובצי PDF נפתחים במציג ה-PDF במכשיר זה.",
"unsupported": "אין תצוגה מקדימה לסוג קובץ זה."
"unsupported": "אין תצוגה מקדימה לסוג קובץ זה.",
"media_unsupported": "לא ניתן להפעיל את הקובץ הזה כאן.",
"pdf_protected": "קובץ ה-PDF הזה מוגן בסיסמה. פתחו אותו באפליקציה אחרת כדי להזין את הסיסמה.",
"pdf_unreadable": "לא ניתן להציג את קובץ ה-PDF הזה כאן."
},
"received_via": "אל {name}",
"remove_color": "הסר תג",
@@ -1168,6 +1180,7 @@
"release_page_pending_desc": "עקוב אחר התקדמות הבנייה או הורד את ה-APK ידנית.",
"severity_deprecated": "חובה",
"severity_security": "אבטחה",
"title": "עדכוני אפליקציה"
"title": "עדכוני אפליקציה",
"verifying": "מאמת…"
}
}
+15 -2
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Hivatkozás",
"export_ics_short": ".ics",
"nothing_planned": "Nincs terv. Koppintson a létrehozáshoz.",
"tap_to_create": "Esemény létrehozásához koppints a + gombra"
},
"fab": {
"event": "Esemény",
"task": "Feladat"
},
"form": {
"description_placeholder": "Jegyzetek, napirend…",
"location_placeholder": "Hely vagy cím",
@@ -127,6 +132,10 @@
"not_found": "Ez a feladat már nem érhető el.",
"save_error": "Nem sikerült menteni a feladatot",
"update_error": "Nem sikerült frissíteni a feladatot"
},
"views": {
"schedule": "Ütemezés",
"three_days": "3 nap"
}
},
"common": {
@@ -398,7 +407,10 @@
"preview": {
"open_external": "Megnyitás alkalmazásban",
"pdf_external": "A PDF-fájlok az eszköz PDF-megjelenítőjében nyílnak meg.",
"unsupported": "Ehhez a fájltípushoz nincs előnézet."
"unsupported": "Ehhez a fájltípushoz nincs előnézet.",
"media_unsupported": "Ez a fájl itt nem játszható le.",
"pdf_protected": "Ez a PDF jelszóval védett. Nyisd meg egy másik alkalmazásban a jelszó megadásához.",
"pdf_unreadable": "Ez a PDF itt nem jeleníthető meg."
},
"received_via": "címzett: {name}",
"remove_color": "Címke eltávolítása",
@@ -1168,6 +1180,7 @@
"release_page_pending_desc": "Kövesd a build folyamatát, vagy töltsd le kézzel az APK-t.",
"severity_deprecated": "Kötelező",
"severity_security": "Biztonsági",
"title": "Alkalmazásfrissítések"
"title": "Alkalmazásfrissítések",
"verifying": "Ellenőrzés…"
}
}
+15 -2
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Link",
"export_ics_short": ".ics",
"nothing_planned": "Niente in programma. Tocca per creare.",
"tap_to_create": "Tocca + per crearne uno"
},
"fab": {
"event": "Evento",
"task": "Attività"
},
"form": {
"description_placeholder": "Note, ordine del giorno…",
"location_placeholder": "Luogo o indirizzo",
@@ -127,6 +132,10 @@
"not_found": "Questa attività non è più disponibile.",
"save_error": "Impossibile salvare l'attività",
"update_error": "Impossibile aggiornare l'attività"
},
"views": {
"schedule": "Programma",
"three_days": "3 giorni"
}
},
"common": {
@@ -398,7 +407,10 @@
"preview": {
"open_external": "Apri in un'app",
"pdf_external": "I PDF si aprono nel lettore PDF di questo dispositivo.",
"unsupported": "Nessuna anteprima per questo tipo di file."
"unsupported": "Nessuna anteprima per questo tipo di file.",
"media_unsupported": "Questo file non può essere riprodotto qui.",
"pdf_protected": "Questo PDF è protetto da password. Aprilo in un'altra app per inserirla.",
"pdf_unreadable": "Questo PDF non può essere visualizzato qui."
},
"received_via": "a {name}",
"remove_color": "Rimuovi etichetta",
@@ -1168,6 +1180,7 @@
"release_page_pending_desc": "Segui l'avanzamento della build o scarica l'APK manualmente.",
"severity_deprecated": "Obbligatorio",
"severity_security": "Sicurezza",
"title": "Aggiornamenti dell'app"
"title": "Aggiornamenti dell'app",
"verifying": "Verifica in corso…"
}
}
+15 -2
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "リンク",
"export_ics_short": ".ics",
"nothing_planned": "予定はありません。タップして作成",
"tap_to_create": "+ をタップして作成"
},
"fab": {
"event": "予定",
"task": "タスク"
},
"form": {
"description_placeholder": "メモ、議題…",
"location_placeholder": "場所または住所",
@@ -127,6 +132,10 @@
"not_found": "このタスクは利用できなくなりました。",
"save_error": "タスクの保存に失敗しました",
"update_error": "タスクの更新に失敗しました"
},
"views": {
"schedule": "スケジュール",
"three_days": "3 日"
}
},
"common": {
@@ -398,7 +407,10 @@
"preview": {
"open_external": "アプリで開く",
"pdf_external": "PDF はこのデバイスの PDF ビューアーで開きます。",
"unsupported": "このファイル形式はプレビューできません。"
"unsupported": "このファイル形式はプレビューできません。",
"media_unsupported": "このファイルはここでは再生できません。",
"pdf_protected": "このPDFはパスワードで保護されています。パスワードを入力するには別のアプリで開いてください。",
"pdf_unreadable": "このPDFはここでは表示できません。"
},
"received_via": "{name}宛",
"remove_color": "ラベルを削除",
@@ -1168,6 +1180,7 @@
"release_page_pending_desc": "ビルドの進行状況を確認するか、APK を手動でダウンロードします。",
"severity_deprecated": "必須",
"severity_security": "セキュリティ",
"title": "アプリのアップデート"
"title": "アプリのアップデート",
"verifying": "検証中…"
}
}
+15 -2
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "링크",
"export_ics_short": ".ics",
"nothing_planned": "계획된 일정이 없습니다. 탭하여 만드세요.",
"tap_to_create": "+를 눌러 만들어 보세요"
},
"fab": {
"event": "일정",
"task": "할 일"
},
"form": {
"description_placeholder": "메모, 안건…",
"location_placeholder": "장소 또는 주소",
@@ -127,6 +132,10 @@
"not_found": "이 할 일을 더 이상 찾을 수 없어요.",
"save_error": "할 일을 저장하지 못했어요",
"update_error": "할 일을 업데이트하지 못했어요"
},
"views": {
"schedule": "일정",
"three_days": "3일"
}
},
"common": {
@@ -398,7 +407,10 @@
"preview": {
"open_external": "앱에서 열기",
"pdf_external": "PDF는 이 기기의 PDF 뷰어에서 열려요.",
"unsupported": "이 파일 형식은 미리보기를 지원하지 않아요."
"unsupported": "이 파일 형식은 미리보기를 지원하지 않아요.",
"media_unsupported": "이 파일은 여기서 재생할 수 없습니다.",
"pdf_protected": "이 PDF는 암호로 보호되어 있습니다. 암호를 입력하려면 다른 앱에서 여세요.",
"pdf_unreadable": "이 PDF는 여기서 표시할 수 없습니다."
},
"received_via": "받는 사람: {name}",
"remove_color": "태그 제거",
@@ -1168,6 +1180,7 @@
"release_page_pending_desc": "빌드 진행 상황을 확인하거나 APK를 직접 다운로드해요.",
"severity_deprecated": "필수",
"severity_security": "보안",
"title": "앱 업데이트"
"title": "앱 업데이트",
"verifying": "확인 중…"
}
}
+15 -2
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Saite",
"export_ics_short": ".ics",
"nothing_planned": "Nekas nav ieplānots. Pieskarieties, lai izveidotu.",
"tap_to_create": "Pieskarieties +, lai izveidotu notikumu"
},
"fab": {
"event": "Notikums",
"task": "Uzdevums"
},
"form": {
"description_placeholder": "Piezīmes, darba kārtība…",
"location_placeholder": "Vieta vai adrese",
@@ -127,6 +132,10 @@
"not_found": "Šis uzdevums vairs nav pieejams.",
"save_error": "Neizdevās saglabāt uzdevumu",
"update_error": "Neizdevās atjaunināt uzdevumu"
},
"views": {
"schedule": "Grafiks",
"three_days": "3 dienas"
}
},
"common": {
@@ -398,7 +407,10 @@
"preview": {
"open_external": "Atvērt lietotnē",
"pdf_external": "PDF faili tiek atvērti šīs ierīces PDF skatītājā.",
"unsupported": "Šim faila tipam nav priekšskatījuma."
"unsupported": "Šim faila tipam nav priekšskatījuma.",
"media_unsupported": "Šo failu šeit nevar atskaņot.",
"pdf_protected": "Šis PDF ir aizsargāts ar paroli. Atveriet to citā lietotnē, lai ievadītu paroli.",
"pdf_unreadable": "Šo PDF šeit nevar parādīt."
},
"received_via": "kam: {name}",
"remove_color": "Noņemt tagu",
@@ -1168,6 +1180,7 @@
"release_page_pending_desc": "Sekojiet būvēšanas gaitai vai lejupielādējiet APK manuāli.",
"severity_deprecated": "Obligāts",
"severity_security": "Drošība",
"title": "Lietotnes atjauninājumi"
"title": "Lietotnes atjauninājumi",
"verifying": "Pārbauda…"
}
}
+15 -2
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Холбоос",
"export_ics_short": ".ics",
"nothing_planned": "Төлөвлөсөн зүйл алга. Үүсгэхийн тулд товшино уу.",
"tap_to_create": "Үүсгэхийн тулд + товшино уу"
},
"fab": {
"event": "Үйл явдал",
"task": "Даалгавар"
},
"form": {
"description_placeholder": "Тэмдэглэл, хэлэлцэх асуудал…",
"location_placeholder": "Газар эсвэл хаяг",
@@ -127,6 +132,10 @@
"not_found": "Энэ даалгавар байхгүй болсон.",
"save_error": "Даалгаврыг хадгалж чадсангүй",
"update_error": "Даалгаврыг шинэчилж чадсангүй"
},
"views": {
"schedule": "Хуваарь",
"three_days": "3 өдөр"
}
},
"common": {
@@ -398,7 +407,10 @@
"preview": {
"open_external": "Апп-д нээх",
"pdf_external": "PDF файлууд энэ төхөөрөмжийн PDF үзэгчид нээгдэнэ.",
"unsupported": "Энэ төрлийн файлыг урьдчилан харах боломжгүй."
"unsupported": "Энэ төрлийн файлыг урьдчилан харах боломжгүй.",
"media_unsupported": "Энэ файлыг энд тоглуулах боломжгүй.",
"pdf_protected": "Энэ PDF нууц үгээр хамгаалагдсан. Нууц үг оруулахын тулд өөр апп-аар нээнэ үү.",
"pdf_unreadable": "Энэ PDF-ийг энд харуулах боломжгүй."
},
"received_via": "Хэнд: {name}",
"remove_color": "Тагийг хасах",
@@ -1168,6 +1180,7 @@
"release_page_pending_desc": "Бүтээлтийн явцыг хянах эсвэл APK-г гараар татаж авах.",
"severity_deprecated": "Заавал",
"severity_security": "Аюулгүй байдал",
"title": "Аппын шинэчлэл"
"title": "Аппын шинэчлэл",
"verifying": "Шалгаж байна…"
}
}
+15 -2
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Lenke",
"export_ics_short": ".ics",
"nothing_planned": "Ingenting planlagt. Trykk for å opprette.",
"tap_to_create": "Trykk på + for å opprette en"
},
"fab": {
"event": "Hendelse",
"task": "Oppgave"
},
"form": {
"description_placeholder": "Notater, agenda…",
"location_placeholder": "Sted eller adresse",
@@ -127,6 +132,10 @@
"not_found": "Denne oppgaven er ikke lenger tilgjengelig.",
"save_error": "Kunne ikke lagre oppgaven",
"update_error": "Kunne ikke oppdatere oppgaven"
},
"views": {
"schedule": "Timeplan",
"three_days": "3 dager"
}
},
"common": {
@@ -398,7 +407,10 @@
"preview": {
"open_external": "Åpne i app",
"pdf_external": "PDF-filer åpnes i PDF-leseren på denne enheten.",
"unsupported": "Ingen forhåndsvisning for denne filtypen."
"unsupported": "Ingen forhåndsvisning for denne filtypen.",
"media_unsupported": "Denne filen kan ikke spilles av her.",
"pdf_protected": "Denne PDF-en er passordbeskyttet. Åpne den i en annen app for å skrive inn passordet.",
"pdf_unreadable": "Denne PDF-en kan ikke vises her."
},
"received_via": "til {name}",
"remove_color": "Fjern etiketten",
@@ -1168,6 +1180,7 @@
"release_page_pending_desc": "Følg fremdriften for byggingen, eller last ned APK-filen manuelt.",
"severity_deprecated": "Påkrevd",
"severity_security": "Sikkerhet",
"title": "Appoppdateringer"
"title": "Appoppdateringer",
"verifying": "Verifiserer…"
}
}
+15 -2
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Link",
"export_ics_short": ".ics",
"nothing_planned": "Niets gepland. Tik om aan te maken.",
"tap_to_create": "Tik op + om er een te maken"
},
"fab": {
"event": "Afspraak",
"task": "Taak"
},
"form": {
"description_placeholder": "Notities, agendapunten…",
"location_placeholder": "Plaats of adres",
@@ -127,6 +132,10 @@
"not_found": "Deze taak is niet meer beschikbaar.",
"save_error": "Taak opslaan mislukt",
"update_error": "Taak bijwerken mislukt"
},
"views": {
"schedule": "Planning",
"three_days": "3 dagen"
}
},
"common": {
@@ -398,7 +407,10 @@
"preview": {
"open_external": "Openen in app",
"pdf_external": "PDF's worden geopend in de PDF-viewer op dit apparaat.",
"unsupported": "Geen voorbeeld voor dit bestandstype."
"unsupported": "Geen voorbeeld voor dit bestandstype.",
"media_unsupported": "Dit bestand kan hier niet worden afgespeeld.",
"pdf_protected": "Deze pdf is beveiligd met een wachtwoord. Open hem in een andere app om het wachtwoord in te voeren.",
"pdf_unreadable": "Deze pdf kan hier niet worden weergegeven."
},
"received_via": "aan {name}",
"remove_color": "Label verwijderen",
@@ -1168,6 +1180,7 @@
"release_page_pending_desc": "Volg de voortgang van de build of download de APK handmatig.",
"severity_deprecated": "Vereist",
"severity_security": "Beveiliging",
"title": "App-updates"
"title": "App-updates",
"verifying": "Controleren…"
}
}
+15 -2
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Link",
"export_ics_short": ".ics",
"nothing_planned": "Nic nie zaplanowano. Dotknij, aby utworzyć.",
"tap_to_create": "Stuknij +, aby utworzyć wydarzenie"
},
"fab": {
"event": "Wydarzenie",
"task": "Zadanie"
},
"form": {
"description_placeholder": "Notatki, agenda…",
"location_placeholder": "Miejsce lub adres",
@@ -127,6 +132,10 @@
"not_found": "To zadanie nie jest już dostępne.",
"save_error": "Nie udało się zapisać zadania",
"update_error": "Nie udało się zaktualizować zadania"
},
"views": {
"schedule": "Harmonogram",
"three_days": "3 dni"
}
},
"common": {
@@ -398,7 +407,10 @@
"preview": {
"open_external": "Otwórz w aplikacji",
"pdf_external": "Pliki PDF otwierają się w przeglądarce PDF na tym urządzeniu.",
"unsupported": "Brak podglądu dla tego typu pliku."
"unsupported": "Brak podglądu dla tego typu pliku.",
"media_unsupported": "Tego pliku nie można tutaj odtworzyć.",
"pdf_protected": "Ten plik PDF jest chroniony hasłem. Otwórz go w innej aplikacji, aby wpisać hasło.",
"pdf_unreadable": "Tego pliku PDF nie można tutaj wyświetlić."
},
"received_via": "do {name}",
"remove_color": "Usuń etykietę",
@@ -1168,6 +1180,7 @@
"release_page_pending_desc": "Śledź postęp budowania lub pobierz plik APK ręcznie.",
"severity_deprecated": "Wymagana",
"severity_security": "Bezpieczeństwo",
"title": "Aktualizacje aplikacji"
"title": "Aktualizacje aplikacji",
"verifying": "Weryfikowanie…"
}
}
+15 -2
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Link",
"export_ics_short": ".ics",
"nothing_planned": "Nada planejado. Toque para criar.",
"tap_to_create": "Toque em + para criar um"
},
"fab": {
"event": "Evento",
"task": "Tarefa"
},
"form": {
"description_placeholder": "Notas, pauta…",
"location_placeholder": "Local ou endereço",
@@ -127,6 +132,10 @@
"not_found": "Esta tarefa não está mais disponível.",
"save_error": "Falha ao salvar a tarefa",
"update_error": "Falha ao atualizar a tarefa"
},
"views": {
"schedule": "Programação",
"three_days": "3 dias"
}
},
"common": {
@@ -398,7 +407,10 @@
"preview": {
"open_external": "Abrir em outro app",
"pdf_external": "PDFs são abertos no leitor de PDF deste dispositivo.",
"unsupported": "Sem pré-visualização para este tipo de arquivo."
"unsupported": "Sem pré-visualização para este tipo de arquivo.",
"media_unsupported": "Este ficheiro não pode ser reproduzido aqui.",
"pdf_protected": "Este PDF está protegido por palavra-passe. Abra-o noutra aplicação para a introduzir.",
"pdf_unreadable": "Este PDF não pode ser mostrado aqui."
},
"received_via": "para {name}",
"remove_color": "Remover etiqueta",
@@ -1168,6 +1180,7 @@
"release_page_pending_desc": "Acompanhe o progresso da compilação ou baixe o APK manualmente.",
"severity_deprecated": "Obrigatória",
"severity_security": "Segurança",
"title": "Atualizações do app"
"title": "Atualizações do app",
"verifying": "Verificando…"
}
}
+15 -2
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Link",
"export_ics_short": ".ics",
"nothing_planned": "Nimic planificat. Atinge pentru a crea.",
"tap_to_create": "Atingeți + pentru a crea unul"
},
"fab": {
"event": "Eveniment",
"task": "Sarcină"
},
"form": {
"description_placeholder": "Note, agendă…",
"location_placeholder": "Loc sau adresă",
@@ -127,6 +132,10 @@
"not_found": "Această sarcină nu mai este disponibilă.",
"save_error": "Sarcina nu a putut fi salvată",
"update_error": "Sarcina nu a putut fi actualizată"
},
"views": {
"schedule": "Program",
"three_days": "3 zile"
}
},
"common": {
@@ -398,7 +407,10 @@
"preview": {
"open_external": "Deschideți într-o aplicație",
"pdf_external": "Fișierele PDF se deschid în vizualizatorul PDF de pe acest dispozitiv.",
"unsupported": "Nu există previzualizare pentru acest tip de fișier."
"unsupported": "Nu există previzualizare pentru acest tip de fișier.",
"media_unsupported": "Acest fișier nu poate fi redat aici.",
"pdf_protected": "Acest PDF este protejat cu parolă. Deschide-l în altă aplicație pentru a introduce parola.",
"pdf_unreadable": "Acest PDF nu poate fi afișat aici."
},
"received_via": "către {name}",
"remove_color": "Eliminați eticheta",
@@ -1168,6 +1180,7 @@
"release_page_pending_desc": "Urmăriți progresul compilării sau descărcați manual APK-ul.",
"severity_deprecated": "Obligatorie",
"severity_security": "Securitate",
"title": "Actualizări aplicație"
"title": "Actualizări aplicație",
"verifying": "Se verifică…"
}
}
+15 -2
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Ссылка",
"export_ics_short": ".ics",
"nothing_planned": "Ничего не запланировано. Нажмите, чтобы создать.",
"tap_to_create": "Нажмите +, чтобы создать"
},
"fab": {
"event": "Мероприятие",
"task": "Задача"
},
"form": {
"description_placeholder": "Заметки, повестка…",
"location_placeholder": "Место или адрес",
@@ -127,6 +132,10 @@
"not_found": "Эта задача больше недоступна.",
"save_error": "Не удалось сохранить задачу",
"update_error": "Не удалось обновить задачу"
},
"views": {
"schedule": "Расписание",
"three_days": "3 дня"
}
},
"common": {
@@ -398,7 +407,10 @@
"preview": {
"open_external": "Открыть в приложении",
"pdf_external": "PDF-файлы открываются в программе просмотра PDF на этом устройстве.",
"unsupported": "Для этого типа файлов нет предпросмотра."
"unsupported": "Для этого типа файлов нет предпросмотра.",
"media_unsupported": "Этот файл нельзя воспроизвести здесь.",
"pdf_protected": "Этот PDF защищён паролем. Откройте его в другом приложении, чтобы ввести пароль.",
"pdf_unreadable": "Этот PDF нельзя показать здесь."
},
"received_via": "на {name}",
"remove_color": "Убрать тег",
@@ -1168,6 +1180,7 @@
"release_page_pending_desc": "Следите за ходом сборки или скачайте APK вручную.",
"severity_deprecated": "Обязательное",
"severity_security": "Безопасность",
"title": "Обновления приложения"
"title": "Обновления приложения",
"verifying": "Проверка…"
}
}
+15 -2
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Odkaz",
"export_ics_short": ".ics",
"nothing_planned": "Nič nie je naplánované. Ťuknutím vytvoríte.",
"tap_to_create": "Udalosť vytvoríte klepnutím na +"
},
"fab": {
"event": "Udalosť",
"task": "Úloha"
},
"form": {
"description_placeholder": "Poznámky, program…",
"location_placeholder": "Miesto alebo adresa",
@@ -127,6 +132,10 @@
"not_found": "Táto úloha už nie je k dispozícii.",
"save_error": "Úlohu sa nepodarilo uložiť",
"update_error": "Úlohu sa nepodarilo aktualizovať"
},
"views": {
"schedule": "Program",
"three_days": "3 dni"
}
},
"common": {
@@ -398,7 +407,10 @@
"preview": {
"open_external": "Otvoriť v aplikácii",
"pdf_external": "Súbory PDF sa otvárajú v prehliadači PDF na tomto zariadení.",
"unsupported": "Pre tento typ súboru nie je k dispozícii náhľad."
"unsupported": "Pre tento typ súboru nie je k dispozícii náhľad.",
"media_unsupported": "Tento súbor tu nie je možné prehrať.",
"pdf_protected": "Toto PDF je chránené heslom. Otvorte ho v inej aplikácii a zadajte heslo.",
"pdf_unreadable": "Toto PDF tu nie je možné zobraziť."
},
"received_via": "pre {name}",
"remove_color": "Odstrániť štítok",
@@ -1168,6 +1180,7 @@
"release_page_pending_desc": "Sledujte priebeh zostavenia alebo si stiahnite APK ručne.",
"severity_deprecated": "Povinná",
"severity_security": "Zabezpečenie",
"title": "Aktualizácie aplikácie"
"title": "Aktualizácie aplikácie",
"verifying": "Overovanie…"
}
}
+15 -2
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Bağlantı",
"export_ics_short": ".ics",
"nothing_planned": "Planlanmış bir şey yok. Oluşturmak için dokunun.",
"tap_to_create": "Etkinlik oluşturmak için + simgesine dokunun"
},
"fab": {
"event": "Etkinlik",
"task": "Görev"
},
"form": {
"description_placeholder": "Notlar, gündem…",
"location_placeholder": "Yer veya adres",
@@ -127,6 +132,10 @@
"not_found": "Bu görev artık kullanılamıyor.",
"save_error": "Görev kaydedilemedi",
"update_error": "Görev güncellenemedi"
},
"views": {
"schedule": "Program",
"three_days": "3 gün"
}
},
"common": {
@@ -398,7 +407,10 @@
"preview": {
"open_external": "Uygulamada aç",
"pdf_external": "PDF dosyaları bu cihazdaki PDF görüntüleyicide açılır.",
"unsupported": "Bu dosya türü için önizleme yok."
"unsupported": "Bu dosya türü için önizleme yok.",
"media_unsupported": "Bu dosya burada oynatılamıyor.",
"pdf_protected": "Bu PDF parola korumalı. Parolayı girmek için başka bir uygulamada açın.",
"pdf_unreadable": "Bu PDF burada gösterilemiyor."
},
"received_via": "alıcı: {name}",
"remove_color": "Etiketi kaldır",
@@ -1168,6 +1180,7 @@
"release_page_pending_desc": "Derleme ilerlemesini izleyin veya APK'yı elle indirin.",
"severity_deprecated": "Zorunlu",
"severity_security": "Güvenlik",
"title": "Uygulama güncellemeleri"
"title": "Uygulama güncellemeleri",
"verifying": "Doğrulanıyor…"
}
}
+15 -2
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "Посилання",
"export_ics_short": ".ics",
"nothing_planned": "Нічого не заплановано. Торкніться, щоб створити.",
"tap_to_create": "Натисніть +, щоб створити"
},
"fab": {
"event": "Подія",
"task": "Завдання"
},
"form": {
"description_placeholder": "Нотатки, порядок денний…",
"location_placeholder": "Місце або адреса",
@@ -127,6 +132,10 @@
"not_found": "Це завдання більше недоступне.",
"save_error": "Не вдалося зберегти завдання",
"update_error": "Не вдалося оновити завдання"
},
"views": {
"schedule": "Розклад",
"three_days": "3 дні"
}
},
"common": {
@@ -398,7 +407,10 @@
"preview": {
"open_external": "Відкрити в застосунку",
"pdf_external": "PDF-файли відкриваються в переглядачі PDF на цьому пристрої.",
"unsupported": "Для цього типу файлів немає попереднього перегляду."
"unsupported": "Для цього типу файлів немає попереднього перегляду.",
"media_unsupported": "Цей файл не можна відтворити тут.",
"pdf_protected": "Цей PDF захищено паролем. Відкрийте його в іншому застосунку, щоб ввести пароль.",
"pdf_unreadable": "Цей PDF не можна показати тут."
},
"received_via": "на {name}",
"remove_color": "Прибрати тег",
@@ -1168,6 +1180,7 @@
"release_page_pending_desc": "Стежте за перебігом збирання або завантажте APK вручну.",
"severity_deprecated": "Обов'язкове",
"severity_security": "Безпека",
"title": "Оновлення застосунку"
"title": "Оновлення застосунку",
"verifying": "Перевірка…"
}
}
+15 -2
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "連結",
"export_ics_short": ".ics",
"nothing_planned": "沒有安排。輕觸即可建立。",
"tap_to_create": "點選 + 即可建立"
},
"fab": {
"event": "活動",
"task": "工作"
},
"form": {
"description_placeholder": "備註、議程…",
"location_placeholder": "地點或地址",
@@ -127,6 +132,10 @@
"not_found": "此任務已不存在。",
"save_error": "儲存任務失敗",
"update_error": "更新任務失敗"
},
"views": {
"schedule": "行程",
"three_days": "3 天"
}
},
"common": {
@@ -398,7 +407,10 @@
"preview": {
"open_external": "在應用程式中開啟",
"pdf_external": "PDF 會在此裝置的 PDF 檢視器中開啟。",
"unsupported": "無法預覽此檔案類型。"
"unsupported": "無法預覽此檔案類型。",
"media_unsupported": "無法在此播放這個檔案。",
"pdf_protected": "此 PDF 受密碼保護。請在其他應用程式中開啟以輸入密碼。",
"pdf_unreadable": "無法在此顯示這個 PDF。"
},
"received_via": "寄給 {name}",
"remove_color": "移除標籤",
@@ -1168,6 +1180,7 @@
"release_page_pending_desc": "查看建置進度或手動下載 APK。",
"severity_deprecated": "必要",
"severity_security": "安全性",
"title": "應用程式更新"
"title": "應用程式更新",
"verifying": "正在驗證…"
}
}
+15 -2
View File
@@ -25,8 +25,13 @@
"events": {
"copy_link_short": "链接",
"export_ics_short": ".ics",
"nothing_planned": "暂无安排。点按即可创建。",
"tap_to_create": "点按 + 创建"
},
"fab": {
"event": "活动",
"task": "任务"
},
"form": {
"description_placeholder": "备注、议程…",
"location_placeholder": "地点或地址",
@@ -127,6 +132,10 @@
"not_found": "此任务已不存在。",
"save_error": "保存任务失败",
"update_error": "更新任务失败"
},
"views": {
"schedule": "日程",
"three_days": "3 天"
}
},
"common": {
@@ -398,7 +407,10 @@
"preview": {
"open_external": "在应用中打开",
"pdf_external": "PDF 将在此设备的 PDF 查看器中打开。",
"unsupported": "无法预览此类文件。"
"unsupported": "无法预览此类文件。",
"media_unsupported": "无法在此播放该文件。",
"pdf_protected": "此 PDF 受密码保护。请在其他应用中打开以输入密码。",
"pdf_unreadable": "无法在此显示该 PDF。"
},
"received_via": "发给 {name}",
"remove_color": "删除标签",
@@ -1168,6 +1180,7 @@
"release_page_pending_desc": "查看构建进度或手动下载 APK。",
"severity_deprecated": "必需",
"severity_security": "安全",
"title": "应用更新"
"title": "应用更新",
"verifying": "正在验证…"
}
}
-15
View File
@@ -2,19 +2,4 @@ const { getDefaultConfig } = require('expo/metro-config');
const config = getDefaultConfig(__dirname);
// babel.config.js rewrites lucide-react-native imports to per-icon modules
// under dist/ (scripts/babel-plugin-lucide-imports.js). The package's
// `exports` map only lists its barrel, so resolve those paths as plain files
// instead of letting Metro warn about every one of them.
const LUCIDE_DEEP_IMPORT = 'lucide-react-native/dist/';
const upstreamResolveRequest = config.resolver.resolveRequest;
config.resolver.resolveRequest = (context, moduleName, platform) => {
const resolve = upstreamResolveRequest ?? context.resolveRequest;
if (moduleName.startsWith(LUCIDE_DEEP_IMPORT)) {
return resolve({ ...context, unstable_enablePackageExports: false }, moduleName, platform);
}
return resolve(context, moduleName, platform);
};
module.exports = config;
+3 -14
View File
@@ -1,12 +1,13 @@
{
"name": "bulwark-mobile",
"version": "0.1.71",
"version": "0.1.81",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.71",
"version": "0.1.81",
"hasInstallScript": true,
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
@@ -33,7 +34,6 @@
"expo-status-bar": "~3.0.9",
"expo-web-browser": "~15.0.8",
"jszip": "^3.10.1",
"lucide-react-native": "^1.7.0",
"postal-mime": "^3.0.0",
"react": "19.1.0",
"react-native": "0.81.5",
@@ -7790,17 +7790,6 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react-native": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-1.7.0.tgz",
"integrity": "sha512-wGJY5nosSawh028jg8r1ZKqnGPDIVfIL9xvKOs4wPYFQHeJMHsADYm/lmuFYXMXXatSkHhpsCjeqIRgeFGzf8g==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-native": "*",
"react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.71",
"version": "0.1.81",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
@@ -43,7 +43,6 @@
"expo-status-bar": "~3.0.9",
"expo-web-browser": "~15.0.8",
"jszip": "^3.10.1",
"lucide-react-native": "^1.7.0",
"postal-mime": "^3.0.0",
"react": "19.1.0",
"react-native": "0.81.5",
+82
View File
@@ -0,0 +1,82 @@
diff --git a/node_modules/postal-mime/src/base64-decoder.js b/node_modules/postal-mime/src/base64-decoder.js
index 93c2aea..ba6e3f1 100644
--- a/node_modules/postal-mime/src/base64-decoder.js
+++ b/node_modules/postal-mime/src/base64-decoder.js
@@ -1,4 +1,4 @@
-import { decodeBase64, blobToArrayBuffer } from './decode-strings.js';
+import { decodeBase64, concatChunks } from './decode-strings.js';
export default class Base64Decoder {
constructor(opts) {
@@ -49,6 +49,6 @@ export default class Base64Decoder {
finalize() {
this.flushRemainder();
- return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
+ return concatChunks(this.chunks);
}
}
diff --git a/node_modules/postal-mime/src/decode-strings.js b/node_modules/postal-mime/src/decode-strings.js
index a5e058e..f255e8d 100644
--- a/node_modules/postal-mime/src/decode-strings.js
+++ b/node_modules/postal-mime/src/decode-strings.js
@@ -145,6 +145,23 @@ export function getDecoder(charset) {
* @param {Blob} blob Blob to convert
* @returns {ArrayBuffer} Converted value
*/
+// React Native's Blob cannot be built from ArrayBuffers or typed arrays, so
+// the decoders join their chunks here instead of through a Blob.
+export function concatChunks(chunks) {
+ const parts = chunks.map(chunk => {
+ if (typeof chunk === 'string') return textEncoder.encode(chunk);
+ if (chunk instanceof ArrayBuffer) return new Uint8Array(chunk);
+ return new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
+ });
+ const out = new Uint8Array(parts.reduce((n, part) => n + part.byteLength, 0));
+ let pos = 0;
+ for (const part of parts) {
+ out.set(part, pos);
+ pos += part.byteLength;
+ }
+ return out.buffer;
+}
+
export async function blobToArrayBuffer(blob) {
if ('arrayBuffer' in blob) {
return await blob.arrayBuffer();
diff --git a/node_modules/postal-mime/src/pass-through-decoder.js b/node_modules/postal-mime/src/pass-through-decoder.js
index ad1c222..1e1d012 100644
--- a/node_modules/postal-mime/src/pass-through-decoder.js
+++ b/node_modules/postal-mime/src/pass-through-decoder.js
@@ -1,4 +1,4 @@
-import { blobToArrayBuffer } from './decode-strings.js';
+import { concatChunks } from './decode-strings.js';
export default class PassThroughDecoder {
constructor() {
@@ -12,6 +12,6 @@ export default class PassThroughDecoder {
finalize() {
// convert an array of arraybuffers into a blob and then back into a single arraybuffer
- return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
+ return concatChunks(this.chunks);
}
}
diff --git a/node_modules/postal-mime/src/qp-decoder.js b/node_modules/postal-mime/src/qp-decoder.js
index adee72c..3973673 100644
--- a/node_modules/postal-mime/src/qp-decoder.js
+++ b/node_modules/postal-mime/src/qp-decoder.js
@@ -1,4 +1,4 @@
-import { blobToArrayBuffer, hexNibble } from './decode-strings.js';
+import { concatChunks, hexNibble } from './decode-strings.js';
const CHR_EQUALS = 0x3d;
const CHR_LF = 0x0a;
@@ -83,6 +83,6 @@ export default class QPDecoder {
this.flushBuffer();
// convert an array of arraybuffers into a blob and then back into a single arraybuffer
- return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
+ return concatChunks(this.chunks);
}
}
-91
View File
@@ -1,91 +0,0 @@
// Babel plugin: rewrite named imports from the `lucide-react-native` barrel to
// the per-icon modules they re-export, so the bundle only contains (and the
// app only evaluates) the icons it uses. The barrel re-exports all ~1,700
// icons, and Metro has no tree shaking, so one `import { Mail }` used to pull
// in and run every icon at startup.
//
// import { Mail, Trash2 as Bin } from 'lucide-react-native';
// becomes
// import Mail from 'lucide-react-native/dist/esm/icons/mail.js';
// import Bin from 'lucide-react-native/dist/esm/icons/trash-2.js';
//
// The name -> file table is read from the package's own ESM barrel, so icon
// aliases (`MailIcon`, `LucideMail`, renamed icons) resolve exactly as they do
// through the barrel. metro.config.js resolves these deep paths, which the
// package's `exports` map does not list.
const fs = require('node:fs');
const path = require('node:path');
const PACKAGE = 'lucide-react-native';
const ESM_DIR = 'dist/esm';
/** Map every name the barrel exports to `{ source, imported }`. */
function loadExportMap() {
// `main` is dist/cjs/lucide-react-native.js; the ESM tree sits next to it.
const pkgDir = path.resolve(path.dirname(require.resolve(PACKAGE)), '..', '..');
const barrel = fs.readFileSync(path.join(pkgDir, ESM_DIR, `${PACKAGE}.js`), 'utf8');
const map = new Map();
for (const m of barrel.matchAll(/export\s*\{([^}]*)\}\s*from\s*'\.\/([^']+)'/g)) {
const source = `${PACKAGE}/${ESM_DIR}/${m[2]}`;
for (const spec of m[1].split(',')) {
const [imported, exported = imported] = spec.trim().split(/\s+as\s+/);
if (imported) map.set(exported, { source, imported });
}
}
if (map.size === 0) {
throw new Error(`${PACKAGE}: could not read the export list from its ESM barrel`);
}
return map;
}
let exportMap = null;
function getExportMap() {
if (!exportMap) exportMap = loadExportMap();
return exportMap;
}
// A value-less binding (only used as a type) is dropped by the TypeScript
// transform anyway; treat it the same way instead of failing the build.
function isOnlyUsedAsType(importPath, localName) {
const binding = importPath.scope.getBinding(localName);
if (!binding) return true;
return binding.referencePaths.every((ref) => Boolean(ref.findParent((p) => p.isTSType())));
}
function lucideImportsPlugin({ types: t }) {
return {
name: 'lucide-direct-imports',
visitor: {
ImportDeclaration(importPath) {
const { node } = importPath;
if (node.source.value !== PACKAGE || node.importKind === 'type') return;
// Side-effect, default and namespace imports keep using the barrel.
if (node.specifiers.length === 0) return;
if (!node.specifiers.every((s) => t.isImportSpecifier(s))) return;
const map = getExportMap();
const replacements = [];
for (const spec of node.specifiers) {
if (spec.importKind === 'type') continue;
const name = t.isIdentifier(spec.imported) ? spec.imported.name : spec.imported.value;
const target = map.get(name);
if (!target) {
if (isOnlyUsedAsType(importPath, spec.local.name)) continue;
throw importPath.buildCodeFrameError(`${PACKAGE} has no export named "${name}"`);
}
const local = t.identifier(spec.local.name);
const specifier = target.imported === 'default'
? t.importDefaultSpecifier(local)
: t.importSpecifier(local, t.identifier(target.imported));
replacements.push(t.importDeclaration([specifier], t.stringLiteral(target.source)));
}
if (replacements.length === 0) importPath.remove();
else importPath.replaceWithMultiple(replacements);
},
},
};
}
module.exports = lucideImportsPlugin;
module.exports.loadExportMap = loadExportMap;
module.exports.PACKAGE = PACKAGE;
+241
View File
@@ -0,0 +1,241 @@
// Writes src/components/icons/index.ts from Tabler's outline path data.
//
// The app draws the same icon set as the webmail (Tabler Icons, MIT) under the
// names it used when it was on Lucide, so screens import `{ Mail, Trash2 }`
// from components/icons. Only the glyphs listed in MAP ship in the bundle.
//
// node scripts/generate-icons.mjs [path/to/tabler-nodes-outline.json]
//
// The default path is the webmail checkout this repo usually sits in
// (repos/react-native inside jmap-webmail), whose node_modules has
// @tabler/icons. Add a line to MAP to make a new icon importable.
import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const SOURCE = process.argv[2]
?? join(ROOT, '..', '..', 'node_modules', '@tabler', 'icons', 'tabler-nodes-outline.json');
// App name -> Tabler outline name. Where the webmail (components/icons) maps
// the same name, this uses its choice.
const MAP = {
AlarmClock: 'alarm',
AlertCircle: 'alert-circle',
AlertTriangle: 'alert-triangle',
AlignCenter: 'align-center',
AlignLeft: 'align-left',
AlignRight: 'align-right',
Archive: 'archive',
ArrowDown: 'arrow-down',
ArrowDownWideNarrow: 'sort-descending',
ArrowLeft: 'arrow-left',
ArrowRight: 'arrow-right',
ArrowUp: 'arrow-up',
ArrowUpDown: 'arrows-sort',
ArrowUpNarrowWide: 'sort-ascending',
Ban: 'ban',
Bell: 'bell',
BellRing: 'bell-ringing',
Bold: 'bold',
Book: 'book-2',
BookOpen: 'book',
BookUser: 'address-book',
Bug: 'bug',
Building: 'building',
Cake: 'cake',
Calendar: 'calendar',
CalendarDays: 'calendar-month',
CalendarPlus: 'calendar-plus',
Camera: 'camera',
Check: 'check',
CheckCheck: 'checks',
CheckCircle: 'circle-check',
CheckCircle2: 'circle-check',
CheckSquare: 'square-check',
ChevronDown: 'chevron-down',
ChevronLeft: 'chevron-left',
ChevronRight: 'chevron-right',
ChevronUp: 'chevron-up',
CircleCheck: 'circle-check',
ClipboardPaste: 'clipboard-text',
Clock: 'clock',
CloudDownload: 'cloud-download',
CloudOff: 'cloud-off',
Code: 'code',
Columns3: 'columns-3',
Columns4: 'layout-columns',
Copy: 'copy',
Database: 'database',
Download: 'download',
Edit2: 'pencil',
Eraser: 'eraser',
ExternalLink: 'external-link',
Eye: 'eye',
EyeOff: 'eye-off',
File: 'file',
FileArchive: 'file-zip',
FileAudio: 'file-music',
FileCode: 'file-code',
FileCode2: 'file-code-2',
FileImage: 'photo',
FileInput: 'file-import',
FileSpreadsheet: 'file-spreadsheet',
FileText: 'file-text',
FileVideo: 'video',
Filter: 'filter',
Flag: 'flag',
FlaskConical: 'flask',
Folder: 'folder',
FolderArchive: 'archive',
FolderInput: 'folder-down',
FolderOpen: 'folder-open',
FolderPlus: 'folder-plus',
FolderSync: 'folders',
Forward: 'arrow-forward-up',
Globe: 'world',
Grid3x3: 'grid-dots',
GripVertical: 'grip-vertical',
HardDrive: 'server-2',
Heading1: 'h-1',
Heading2: 'h-2',
Heart: 'heart',
HelpCircle: 'help-circle',
Highlighter: 'highlight',
Image: 'photo',
Import: 'file-import',
Inbox: 'inbox',
Info: 'info-circle',
Italic: 'italic',
Key: 'key',
KeyRound: 'key',
Languages: 'language',
Layers: 'stack',
LayoutGrid: 'layout-grid',
LayoutTemplate: 'template',
Link2: 'link',
Link2Off: 'unlink',
List: 'list',
ListChecks: 'list-check',
ListOrdered: 'list-numbers',
Lock: 'lock',
LogOut: 'logout',
Mail: 'mail',
MailCheck: 'mail-check',
MailMinus: 'mail-minus',
MailOpen: 'mail-opened',
Mails: 'inbox',
MapPin: 'map-pin',
Maximize2: 'maximize',
Menu: 'menu-2',
MessageSquare: 'message',
Minus: 'minus',
Monitor: 'device-desktop',
Moon: 'moon',
MoreHorizontal: 'dots',
MoreVertical: 'dots-vertical',
MoveVertical: 'arrows-vertical',
Palette: 'palette',
Palmtree: 'beach',
PanelLeftClose: 'layout-sidebar-left-collapse',
PanelRight: 'layout-sidebar-right',
Paperclip: 'paperclip',
PenLine: 'pencil',
PenSquare: 'edit',
Pencil: 'pencil',
Phone: 'phone',
Pin: 'pinned',
PinOff: 'pinned-off',
Plus: 'plus',
Puzzle: 'puzzle',
QrCode: 'qrcode',
Quote: 'quote',
Redo2: 'arrow-forward',
RefreshCw: 'refresh',
RemoveFormatting: 'clear-formatting',
Repeat: 'repeat',
Reply: 'arrow-back-up',
ReplyAll: 'arrow-back-up-double',
RotateCcw: 'rotate',
Rows2: 'layout-rows',
Rss: 'rss',
Save: 'device-floppy',
ScanSearch: 'zoom-scan',
Scale: 'scale',
Search: 'search',
Send: 'send',
Server: 'server',
Settings: 'settings',
Share2: 'share-2',
Shield: 'shield',
ShieldAlert: 'shield-exclamation',
ShieldCheck: 'shield-check',
ShieldQuestion: 'shield-question',
Shuffle: 'arrows-shuffle',
Smartphone: 'device-mobile',
Square: 'square',
SquareCheck: 'square-check',
SquarePen: 'edit',
Star: 'star',
StickyNote: 'note',
Strikethrough: 'strikethrough',
Sun: 'sun',
Table: 'table',
Tag: 'tag',
Tags: 'tags',
Terminal: 'terminal',
Trash: 'trash',
Trash2: 'trash',
Type: 'typography',
Underline: 'underline',
Undo2: 'arrow-back',
Upload: 'upload',
User: 'user',
UserCircle: 'user-circle',
UserMinus: 'user-minus',
UserPen: 'user-edit',
UserPlus: 'user-plus',
UserRound: 'user',
UserRoundPlus: 'user-plus',
Users: 'users',
Video: 'video',
X: 'x',
XCircle: 'circle-x',
};
function main() {
const nodes = JSON.parse(readFileSync(SOURCE, 'utf8'));
const glyphs = new Map();
const missing = [];
for (const [appName, tabler] of Object.entries(MAP)) {
const icon = nodes[tabler];
if (!icon) { missing.push(`${appName} -> ${tabler}`); continue; }
const paths = icon.map(([tag, attrs]) => {
if (tag !== 'path') throw new Error(`${tabler}: unexpected <${tag}>`);
return attrs.d;
});
glyphs.set(tabler, paths);
}
if (missing.length) throw new Error(`Not in Tabler: ${missing.join(', ')}`);
const lines = [
'// Generated by scripts/generate-icons.mjs from @tabler/icons (MIT).',
'// Do not edit by hand: add the icon to MAP in the script and run it.',
'',
"import { makeIcon } from './icon';",
'',
"export type { AppIcon, IconProps } from './icon';",
'',
'const G = {',
...[...glyphs.keys()].sort().map((k) => ` '${k}': ${JSON.stringify(glyphs.get(k))},`),
'} as const;',
'',
...Object.entries(MAP).map(([a, t]) => `export const ${a} = makeIcon('${a}', G['${t}']);`),
'',
];
writeFileSync(join(ROOT, 'src', 'components', 'icons', 'index.ts'), lines.join('\n'));
console.log(`${Object.keys(MAP).length} names, ${glyphs.size} glyphs`);
}
main();
+46
View File
@@ -0,0 +1,46 @@
// Writes src/lib/licences.json: the libraries the app ships (the runtime
// dependencies in package.json) with their licence, for Settings → About →
// Open-source licences. Run after adding or upgrading a dependency:
//
// node scripts/generate-licences.mjs
//
// The test in src/lib/__tests__/licences.test.ts fails when a dependency is
// missing from the list.
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
// Code that isn't an npm dependency but ships in the app.
const VENDORED = [
{ name: 'Tabler Icons', version: '', license: 'MIT', url: 'https://tabler.io/icons' },
];
function repoUrl(meta) {
const repo = typeof meta.repository === 'string' ? meta.repository : meta.repository?.url;
const url = meta.homepage || repo || '';
return url
.replace(/^git\+/, '')
.replace(/^git:\/\//, 'https://')
.replace(/^github:/, 'https://github.com/')
.replace(/\.git$/, '')
.replace(/#.*$/, '');
}
const libraries = Object.keys(pkg.dependencies).sort().map((name) => {
const file = join(ROOT, 'node_modules', name, 'package.json');
if (!existsSync(file)) throw new Error(`${name} is not installed`);
const meta = JSON.parse(readFileSync(file, 'utf8'));
const license = typeof meta.license === 'string' ? meta.license : meta.license?.type ?? 'See package';
return { name, version: meta.version, license, url: repoUrl(meta) };
});
const out = {
app: { name: pkg.name, license: pkg.license },
libraries: [...libraries, ...VENDORED],
};
writeFileSync(join(ROOT, 'src', 'lib', 'licences.json'), `${JSON.stringify(out, null, 2)}\n`);
console.log(`${out.libraries.length} libraries`);
+171
View File
@@ -0,0 +1,171 @@
// Put an uploaded build on the public TestFlight link: wait until App Store
// Connect has processed it, add it to the external beta group, set its
// "What to Test" text and submit it for beta review. Builds only reach
// external testers (and so the public link) once Apple approves them.
//
// node scripts/testflight-public.mjs <marketing version> <build number>
//
// Environment:
// ASC_KEY_ID, ASC_ISSUER_ID App Store Connect API key
// ASC_KEY_PATH the .p8 file (default ~/private_keys/AuthKey_<id>.p8)
// BUNDLE_ID default org.bulwarkmail.mobile
// TESTFLIGHT_GROUP external group name (default "external")
// WHATS_NEW "What to Test" text (default: link to the GitHub release)
//
// Every step can be repeated: a build already in the group, already described
// or already submitted is left as it is, so a re-run picks up where the last
// one stopped.
import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const [version, buildNumber] = process.argv.slice(2);
if (!version || !buildNumber) {
console.error('usage: node scripts/testflight-public.mjs <marketing version> <build number>');
process.exit(2);
}
const keyId = process.env.ASC_KEY_ID;
const issuer = process.env.ASC_ISSUER_ID;
if (!keyId || !issuer) {
console.error('ASC_KEY_ID and ASC_ISSUER_ID are required.');
process.exit(2);
}
const keyPath = process.env.ASC_KEY_PATH || path.join(os.homedir(), 'private_keys', `AuthKey_${keyId}.p8`);
const privateKey = fs.readFileSync(keyPath, 'utf8');
const bundleId = process.env.BUNDLE_ID || 'org.bulwarkmail.mobile';
const groupName = process.env.TESTFLIGHT_GROUP || 'external';
const repo = process.env.GITHUB_REPOSITORY || 'bulwarkmail/native';
const whatsNew = process.env.WHATS_NEW || `Release notes: https://github.com/${repo}/releases/tag/${version}`;
// Processing usually takes 5-30 minutes; give up well inside the job timeout.
const PROCESSING_TIMEOUT_MS = 60 * 60 * 1000;
const POLL_MS = 30 * 1000;
function token() {
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
const now = Math.floor(Date.now() / 1000);
const head = b64({ alg: 'ES256', kid: keyId, typ: 'JWT' });
const body = b64({ iss: issuer, iat: now, exp: now + 15 * 60, aud: 'appstoreconnect-v1' });
const sig = crypto
.sign('sha256', Buffer.from(`${head}.${body}`), { key: privateKey, dsaEncoding: 'ieee-p1363' })
.toString('base64url');
return `${head}.${body}.${sig}`;
}
async function api(method, p, body) {
const res = await fetch(`https://api.appstoreconnect.apple.com${p}`, {
method,
headers: { Authorization: `Bearer ${token()}`, 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
const json = text ? JSON.parse(text) : null;
return { status: res.status, ok: res.ok, json };
}
function fail(step, r) {
const e = r.json?.errors?.[0];
console.error(`${step} failed: ${r.status} ${e?.code ?? ''} ${e?.detail ?? e?.title ?? ''}`);
if (e?.code === 'FORBIDDEN.REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED') {
console.error('An Apple agreement needs accepting in App Store Connect > Business.');
}
process.exit(1);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ── The app and the group ──────────────────────────────────────
const apps = await api('GET', `/v1/apps?filter[bundleId]=${encodeURIComponent(bundleId)}&fields[apps]=name`);
if (!apps.ok) fail('Looking up the app', apps);
const app = apps.json.data[0];
if (!app) {
console.error(`No app with bundle ID ${bundleId} in App Store Connect.`);
process.exit(1);
}
const groups = await api(
'GET',
`/v1/betaGroups?filter[app]=${app.id}&filter[name]=${encodeURIComponent(groupName)}&fields[betaGroups]=name,isInternalGroup,publicLink,publicLinkEnabled`,
);
if (!groups.ok) fail('Looking up the beta group', groups);
const group = groups.json.data.find((g) => !g.attributes.isInternalGroup && g.attributes.name === groupName);
if (!group) {
console.error(`No external beta group named "${groupName}".`);
process.exit(1);
}
// ── Wait for the build ─────────────────────────────────────────
const query =
`/v1/builds?filter[app]=${app.id}&filter[version]=${encodeURIComponent(buildNumber)}` +
`&filter[preReleaseVersion.version]=${encodeURIComponent(version)}&fields[builds]=version,processingState`;
const started = Date.now();
let build;
for (;;) {
const r = await api('GET', query);
if (!r.ok) fail('Looking up the build', r);
build = r.json.data[0];
const state = build?.attributes.processingState;
if (state === 'VALID') break;
if (state === 'FAILED' || state === 'INVALID') {
console.error(`Build ${version} (${buildNumber}) is ${state} in App Store Connect.`);
process.exit(1);
}
if (Date.now() - started > PROCESSING_TIMEOUT_MS) {
console.error(`Build ${version} (${buildNumber}) was not processed within an hour (state: ${state ?? 'not found'}).`);
process.exit(1);
}
console.log(`Build ${version} (${buildNumber}): ${state ?? 'not visible yet'}, waiting…`);
await sleep(POLL_MS);
}
console.log(`Build ${version} (${buildNumber}) is processed.`);
// ── Add it to the group ────────────────────────────────────────
const add = await api('POST', `/v1/betaGroups/${group.id}/relationships/builds`, {
data: [{ type: 'builds', id: build.id }],
});
if (!add.ok) fail(`Adding the build to "${groupName}"`, add);
console.log(`Added to the "${groupName}" group.`);
// ── "What to Test" ─────────────────────────────────────────────
const locs = await api('GET', `/v1/builds/${build.id}/betaBuildLocalizations`);
if (!locs.ok) fail('Reading the build notes', locs);
const en = locs.json.data.find((l) => l.attributes.locale === 'en-US');
const notes = en
? await api('PATCH', `/v1/betaBuildLocalizations/${en.id}`, {
data: { type: 'betaBuildLocalizations', id: en.id, attributes: { whatsNew } },
})
: await api('POST', '/v1/betaBuildLocalizations', {
data: {
type: 'betaBuildLocalizations',
attributes: { locale: 'en-US', whatsNew },
relationships: { build: { data: { type: 'builds', id: build.id } } },
},
});
if (!notes.ok) fail('Setting "What to Test"', notes);
// ── Beta review ────────────────────────────────────────────────
const detail = await api('GET', `/v1/builds/${build.id}/buildBetaDetail`);
if (!detail.ok) fail('Reading the build state', detail);
const external = detail.json.data.attributes.externalBuildState;
if (external === 'MISSING_EXPORT_COMPLIANCE') {
console.error('The build has no export compliance answer; set usesNonExemptEncryption in app.config.js.');
process.exit(1);
}
if (external === 'READY_FOR_BETA_SUBMISSION') {
const submit = await api('POST', '/v1/betaAppReviewSubmissions', {
data: { type: 'betaAppReviewSubmissions', relationships: { build: { data: { type: 'builds', id: build.id } } } },
});
if (!submit.ok) fail('Submitting for beta review', submit);
console.log('Submitted for beta review.');
} else {
console.log(`Not submitted again: the build is ${external}.`);
}
const link = group.attributes.publicLinkEnabled ? group.attributes.publicLink : null;
console.log(
link
? `Testers with ${link} get it once Apple approves the build.`
: `The "${groupName}" group has no public link enabled.`,
);
+138
View File
@@ -0,0 +1,138 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
request: vi.fn(),
getMaxCallsInRequest: vi.fn(() => 16),
getMaxObjectsInGet: vi.fn(() => 500),
getMaxObjectsInSet: vi.fn(() => 500),
},
}));
import { jmapClient } from '../jmap-client';
import { saveDraftVersion, type OutgoingAttachment, type OutgoingEmail } from '../email';
import { initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs } from '../../lib/draft-part-blobs';
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
/**
* A tiny server with Stalwart's blob semantics, as measured on 0.16.22 (2026-10-14):
* an email's part blobIds are derived from that email and stop resolving the
* moment it is destroyed ("blobId … does not exist on this server"); creating
* an email copies the referenced bytes into new parts with new blobIds.
*/
function fakeStalwart() {
let seq = 0;
const emails = new Map<string, Array<{ blobId: string; name: string; size: number; cid?: string; type: string }>>();
const live = (blobId: string) => [...emails.values()].some((parts) => parts.some((p) => p.blobId === blobId));
const seed = (id: string, parts: OutgoingAttachment[]) => {
emails.set(id, parts.map((p) => ({ blobId: p.blobId, name: p.name, size: p.size ?? 0, cid: p.cid, type: p.type })));
};
mockRequest.mockImplementation(async (calls: Array<[string, Record<string, any>, string]>) => {
const out: Array<[string, unknown, string]> = [];
let createdId: string | undefined;
for (const [method, args, callId] of calls) {
if (method === 'Email/set' && args.create) {
const create = args.create.draft;
const dead = (create.attachments ?? []).find((a: { blobId: string }) => !live(a.blobId));
if (dead) {
out.push(['Email/set', { notCreated: { draft: {
type: 'blobNotFound', description: `blobId ${dead.blobId} does not exist on this server.`,
} } }, callId]);
continue;
}
createdId = `e${++seq}`;
emails.set(createdId, (create.attachments ?? []).map((a: OutgoingAttachment, i: number) => ({
blobId: `${createdId}-part${i}`, name: a.name, size: a.size ?? 0, cid: a.cid ? `<${a.cid}>` : undefined, type: a.type,
})));
out.push(['Email/set', { created: { draft: { id: createdId } } }, callId]);
} else if (method === 'Email/set' && args.destroy) {
for (const id of args.destroy) emails.delete(id);
out.push(['Email/set', { destroyed: args.destroy }, callId]);
} else if (method === 'Email/get') {
if (!createdId || args.ids?.[0] !== '#draft') {
out.push(['error', { type: 'invalidResultReference' }, callId]);
continue;
}
out.push(['Email/get', { list: [{ id: createdId, attachments: emails.get(createdId) }] }, callId]);
}
}
return { methodResponses: out };
});
return { seed, emails };
}
const IMG_A: OutgoingAttachment = { blobId: 'd0-part-a', name: 'schema-a.png', size: 136574, type: 'image/png', disposition: 'inline', cid: 'a@undust.co' };
const IMG_B: OutgoingAttachment = { blobId: 'd0-part-b', name: 'schema-b.png', size: 175155, type: 'image/png', disposition: 'inline', cid: 'b@undust.co' };
const draftWith = (attachments: OutgoingAttachment[]): OutgoingEmail => ({
from: [{ email: 'me@undust.co' }],
to: [{ email: 'test@example.invalid' }],
subject: 'RE: draft with two inline images',
htmlBody: '<p>x <img src="cid:a@undust.co"> <img src="cid:b@undust.co"></p>',
textBody: 'x',
attachments,
});
beforeEach(() => {
vi.clearAllMocks();
});
describe('saveDraftVersion', () => {
it('reads the new version\'s parts back in the same request, by creation id', async () => {
fakeStalwart().seed('d0', [IMG_A]);
const saved = await saveDraftVersion(draftWith([IMG_A]), 'drafts', 'd0');
const calls = mockRequest.mock.calls[0][0];
expect(calls.map((c: [string]) => c[0])).toEqual(['Email/set', 'Email/get']);
expect(calls[1][1].ids).toEqual(['#draft']);
expect(saved.id).toBe('e1');
expect(saved.attachments.map((a) => a.blobId)).toEqual(['e1-part0']);
});
it('destroys the previous version only after the new one exists', async () => {
fakeStalwart().seed('d0', [IMG_A]);
await saveDraftVersion(draftWith([IMG_A]), 'drafts', 'd0');
const destroyCall = mockRequest.mock.calls[1][0][0];
expect(destroyCall[0]).toBe('Email/set');
expect(destroyCall[1].destroy).toEqual(['d0']);
});
it('still returns the id when the read-back fails', async () => {
mockRequest.mockResolvedValueOnce({ methodResponses: [
['Email/set', { created: { draft: { id: 'e9' } } }, '0'],
['error', { type: 'serverFail' }, '1'],
] });
const saved = await saveDraftVersion(draftWith([]), 'drafts');
expect(saved).toEqual({ id: 'e9', attachments: [] });
});
});
describe('re-opened draft with inline images: save, save again, send', () => {
// What ComposeScreen does: buildOutgoing maps each held blobId through the
// state, saveDraftOnce rebases it on the parts read back.
async function editSession(rebase: boolean) {
const server = fakeStalwart();
server.seed('d0', [IMG_A, IMG_B]); // the draft as deposited (IMAP APPEND or an earlier save)
let state = initialDraftPartBlobState();
let draftId = 'd0';
const outgoing = () => draftWith([IMG_A, IMG_B].map((a) => ({ ...a, blobId: liveBlobId(state, a.blobId) })));
for (let i = 0; i < 2; i++) {
const sent = outgoing();
const saved = await saveDraftVersion(sent, 'drafts', draftId);
draftId = saved.id;
if (rebase) state = rebaseDraftPartBlobs(state, sent.attachments ?? [], saved.attachments);
}
return { server, draftId };
}
it('reproduces the bug without the rebase: the second save references the destroyed draft\'s parts', async () => {
await expect(editSession(false)).rejects.toThrow('blobId d0-part-a does not exist on this server.');
});
it('keeps every save (and so the send, which builds the same attachments) on live blobs', async () => {
const { server, draftId } = await editSession(true);
expect([...server.emails.keys()]).toEqual([draftId]);
expect(server.emails.get(draftId)!.map((p) => p.name)).toEqual(['schema-a.png', 'schema-b.png']);
});
});
+43
View File
@@ -22,6 +22,8 @@ import {
getFullEmails,
getThreadsHeaders,
createDraft,
saveDraftVersion,
destroyEmails,
sendEmail,
markAsSpam,
undoSpam,
@@ -33,6 +35,9 @@ import {
EMAIL_FULL_PROPERTIES,
} from '../email';
import { getIdentities } from '../identity';
import { uploadBytes } from '../blob';
import { initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs } from '../../lib/draft-part-blobs';
import type { OutgoingAttachment } from '../email';
import { getCalendars, queryEvents, createEvent, deleteEvents } from '../calendar';
import { getAddressBooks, createContact, deleteContacts } from '../contacts';
@@ -192,6 +197,44 @@ live('live Stalwart', () => {
await destroyEmails([id]);
}, 60_000);
it('keeps a re-opened draft with an inline image saveable across saves (#849 part blobs)', async () => {
// A 1x1 PNG uploaded once; D0 stands for the draft as it sits on the server.
const png = Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='), (c) => c.charCodeAt(0));
const up = await uploadBytes(png, 'image/png');
const base = {
from: [{ email: USER }],
to: [{ email: 'test@example.invalid' }],
subject: '[TEST bulwark-blob — supprimable]',
htmlBody: '<p>live <img src="cid:img1@live.test"></p>',
textBody: 'live',
};
const img = (blobId: string): OutgoingAttachment => ({
blobId, type: 'image/png', name: 'img1.png', size: png.byteLength, disposition: 'inline', cid: 'img1@live.test',
});
const d0 = await saveDraftVersion({ ...base, attachments: [img(up.blobId)] }, drafts);
const created = [d0.id];
try {
// Re-open D0: the composer holds D0's part blobId.
const seeded = d0.attachments[0].blobId;
let state = initialDraftPartBlobState();
let draftId = d0.id;
for (let i = 0; i < 2; i++) {
const sent = [img(liveBlobId(state, seeded))];
const saved = await saveDraftVersion({ ...base, attachments: sent }, drafts, draftId);
created.push(saved.id);
draftId = saved.id;
state = rebaseDraftPartBlobs(state, sent, saved.attachments);
}
// Without the rebase the seeded part blob is gone with D0.
await expect(saveDraftVersion({ ...base, attachments: [img(seeded)] }, drafts))
.rejects.toThrow(/does not exist|blobNotFound/);
const [last] = await getFullEmails([draftId]);
expect(last.attachments?.[0]?.cid).toContain('img1@live.test');
} finally {
await destroyEmails(created).catch(() => undefined);
}
}, 90_000);
it('sends through Drafts and files into Sent only after submission (#188)', async () => {
const identities = await getIdentities();
expect(identities.length).toBeGreaterThan(0);
+48 -5
View File
@@ -1679,31 +1679,64 @@ export async function sendEmail(
};
}
/** A saved draft version and the attachment parts the server built for it. */
export interface SavedDraftVersion {
id: string;
/**
* Attachment and inline parts of the new version, with their blobIds. A
* part's blobId only lives as long as this version: re-opened drafts must
* send these (not the previous version's) on their next save or send.
* Empty when the server did not answer the read-back.
*/
attachments: Attachment[];
}
const DRAFT_PART_BODY_PROPERTIES = ['partId', 'blobId', 'size', 'name', 'type', 'cid', 'disposition'];
/**
* Save a draft into the Drafts folder (`$draft` + `$seen`). When `previousDraftId`
* is given it is destroyed only AFTER the replacement was created (#849): a
* combined create+destroy would delete the last good copy when the create
* fails (e.g. blobNotFound on a re-opened draft's attachments). Returns the
* new draft id.
* fails (e.g. blobNotFound on a re-opened draft's attachments). The new
* version's attachment parts are read back in the same request, so the
* composer can re-point its draft-part blobIds before the old version (and
* its part blobs) is destroyed.
*/
export async function createDraft(
export async function saveDraftVersion(
email: OutgoingEmail,
draftsMailboxId: string,
previousDraftId?: string,
accountIdOverride?: string,
): Promise<string> {
): Promise<SavedDraftVersion> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const emailCreate = buildEmailCreate(email);
emailCreate.mailboxIds = { [draftsMailboxId]: true };
emailCreate.keywords = { $seen: true, $draft: true };
const res = await jmapClient.request([
['Email/set', { accountId, create: { draft: emailCreate } }, '0'],
// `#draft` is the creation id (RFC 8620 §5.3). Stalwart 0.16 does not
// resolve a result reference into an Email/set `created` map
// (invalidResultReference), but it does resolve the creation id.
['Email/get', {
accountId,
ids: ['#draft'],
properties: ['id', 'attachments'],
bodyProperties: DRAFT_PART_BODY_PROPERTIES,
}, '1'],
]);
const body = requireMethodResult(res, '0', 'Email/set');
const notCreated = body.notCreated?.draft as { description?: string; type?: string } | undefined;
if (notCreated) throw new Error(notCreated.description || notCreated.type || 'Failed to save draft');
const id = body.created?.draft?.id as string | undefined;
if (!id) throw new Error('Draft save returned no id');
let attachments: Attachment[] = [];
try {
const got = requireMethodResult(res, '1', 'Email/get');
const saved = (got.list as Array<{ id: string; attachments?: Attachment[] }> | undefined)?.find((e) => e.id === id);
attachments = (saved?.attachments ?? []).filter((a) => !!a.blobId);
} catch (err) {
console.warn('[email] could not read back the saved draft parts:', err);
}
if (previousDraftId && previousDraftId !== id) {
try {
await destroyEmails([previousDraftId], accountId);
@@ -1711,7 +1744,17 @@ export async function createDraft(
console.warn('[email] failed to destroy previous draft version:', err);
}
}
return id;
return { id, attachments };
}
/** {@link saveDraftVersion} for callers that only need the new draft id. */
export async function createDraft(
email: OutgoingEmail,
draftsMailboxId: string,
previousDraftId?: string,
accountIdOverride?: string,
): Promise<string> {
return (await saveDraftVersion(email, draftsMailboxId, previousDraftId, accountIdOverride)).id;
}
export interface ScheduledEmail {
+17 -11
View File
@@ -1,20 +1,25 @@
import React from 'react';
import { View, Text, StyleSheet, ViewStyle } from 'react-native';
import { radius, typography, type ThemePalette } from '../theme/tokens';
import { radius, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
interface BadgeProps {
/** Sentence case ("Not on this server"); badges are never upper case. */
children: string;
variant?: 'default' | 'primary' | 'destructive' | 'outline' | 'muted';
style?: ViewStyle;
}
/**
* A small status pill. The default is the webmail's outline badge: hairline
* border, muted 12px text. The tinted variants keep the same size.
*/
export default function Badge({ children, variant = 'default', style }: BadgeProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={[styles.base, styles[`bg_${variant}` as const], style]}>
<Text style={[styles.text, styles[`text_${variant}` as const]]}>
<Text style={[styles.text, styles[`text_${variant}` as const]]} numberOfLines={1}>
{children}
</Text>
</View>
@@ -25,23 +30,24 @@ function makeStyles(c: ThemePalette) {
return StyleSheet.create({
base: {
borderRadius: radius.full,
borderWidth: 1,
borderColor: 'transparent',
paddingHorizontal: 8,
paddingVertical: 2,
paddingVertical: 1,
alignSelf: 'flex-start',
},
text: { ...typography.caption, fontWeight: '500' },
text: { fontSize: 12, lineHeight: 16 },
bg_default: { backgroundColor: c.surface },
bg_primary: { backgroundColor: c.primaryBg },
bg_default: { borderColor: c.border },
bg_outline: { borderColor: c.border },
bg_primary: { backgroundColor: c.accent },
bg_destructive: { backgroundColor: c.errorBg },
bg_outline: { backgroundColor: 'transparent', borderWidth: 1, borderColor: c.border },
bg_muted: { backgroundColor: c.muted },
text_default: { color: c.text },
text_primary: { color: c.primary },
text_default: { color: c.mutedForeground },
text_outline: { color: c.mutedForeground },
text_primary: { color: c.accentForeground },
text_destructive: { color: c.error },
text_outline: { color: c.text },
text_muted: { color: c.mutedForeground },
});
}
+51 -25
View File
@@ -1,9 +1,19 @@
import React from 'react';
import { Pressable, Text, StyleSheet, ViewStyle, ActivityIndicator } from 'react-native';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { spacing, radius, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
type ButtonVariant = 'default' | 'ghost' | 'outline' | 'destructive';
/**
* The webmail's standard-theme buttons (components/ui/button.tsx):
* - default: bg-primary text-primary-foreground
* - secondary: bg-muted text-foreground
* - outline: border, transparent
* - ghost: no border or fill until pressed
* - destructive: red fill, white text
* - destructiveOutline: red border and text, for a reset in a settings row
* All 36 dp tall (lg 44 for a page's main action), radius 6, 14px medium.
*/
type ButtonVariant = 'default' | 'secondary' | 'ghost' | 'outline' | 'destructive' | 'destructiveOutline';
type ButtonSize = 'sm' | 'md' | 'lg' | 'icon';
interface ButtonProps {
@@ -15,8 +25,13 @@ interface ButtonProps {
loading?: boolean;
icon?: React.ReactNode;
style?: ViewStyle;
/** Needed for an icon-only button; a text button reads its text. */
accessibilityLabel?: string;
}
// Extra touch area so every size reaches a 48 dp target.
const HIT_SLOP: Record<ButtonSize, number> = { sm: 6, md: 6, lg: 2, icon: 4 };
export default function Button({
variant = 'default',
size = 'md',
@@ -26,10 +41,14 @@ export default function Button({
loading = false,
icon,
style,
accessibilityLabel,
}: ButtonProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const isDisabled = disabled || loading;
const spinnerColor = variant === 'default'
? c.primaryForeground
: variant === 'destructive' ? '#ffffff' : c.text;
return (
<Pressable
@@ -43,17 +62,18 @@ export default function Button({
]}
onPress={onPress}
disabled={isDisabled}
hitSlop={HIT_SLOP[size]}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityState={{ disabled: isDisabled, busy: loading }}
>
{loading ? (
<ActivityIndicator
size="small"
color={variant === 'default' ? c.primaryForeground : c.primary}
/>
<ActivityIndicator size="small" color={spinnerColor} />
) : (
<>
{icon}
{typeof children === 'string' ? (
<Text style={[styles.text, styles[`text_${variant}` as const], isDisabled && styles.textDisabled]}>
<Text style={[styles.text, styles[`text_${variant}` as const]]} numberOfLines={1}>
{children}
</Text>
) : (
@@ -69,30 +89,36 @@ function makeStyles(c: ThemePalette) {
return StyleSheet.create({
base: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
borderRadius: radius.md, gap: spacing.sm,
borderRadius: radius.sm, gap: spacing.sm,
},
text: { ...typography.bodyMedium },
text: { fontSize: 14, fontWeight: '500', lineHeight: 20 },
disabled: { opacity: 0.5 },
textDisabled: { opacity: 0.5 },
size_sm: { height: componentSizes.buttonSm, paddingHorizontal: spacing.md },
size_md: { height: componentSizes.buttonMd, paddingHorizontal: spacing.lg, paddingVertical: spacing.sm },
size_lg: { height: componentSizes.buttonLg, paddingHorizontal: spacing.xxxl },
// minHeight, so a larger system font grows the button instead of clipping.
size_sm: { minHeight: componentSizes.buttonSm, paddingHorizontal: spacing.md },
size_md: { minHeight: componentSizes.buttonSm, paddingHorizontal: 14 },
size_lg: { minHeight: componentSizes.buttonLg, paddingHorizontal: spacing.xxl },
size_icon: { height: componentSizes.buttonMd, width: componentSizes.buttonMd, paddingHorizontal: 0 },
bg_default: { backgroundColor: c.primary, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 1, elevation: 1 },
bg_ghost: { backgroundColor: 'transparent' },
bg_outline: { backgroundColor: c.background, borderWidth: 1, borderColor: c.border },
bg_destructive: { backgroundColor: c.error, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 1, elevation: 1 },
bg_default: { backgroundColor: c.primary },
bg_secondary: { backgroundColor: c.muted },
bg_ghost: { backgroundColor: 'transparent' },
bg_outline: { backgroundColor: 'transparent', borderWidth: 1, borderColor: c.border },
bg_destructive: { backgroundColor: c.error },
bg_destructiveOutline: { backgroundColor: 'transparent', borderWidth: 1, borderColor: c.error },
pressed_default: { backgroundColor: c.primaryDark, shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 2, elevation: 2 },
pressed_ghost: { backgroundColor: c.accent },
pressed_outline: { backgroundColor: c.accent },
pressed_destructive: { opacity: 0.9 },
pressed_default: { opacity: 0.9 },
pressed_secondary: { opacity: 0.8 },
pressed_ghost: { backgroundColor: c.muted },
pressed_outline: { backgroundColor: c.muted },
pressed_destructive: { opacity: 0.9 },
pressed_destructiveOutline: { backgroundColor: c.errorBg },
text_default: { color: c.primaryForeground },
text_ghost: { color: c.text },
text_outline: { color: c.text },
text_destructive: { color: c.errorForeground },
text_default: { color: c.primaryForeground },
text_secondary: { color: c.text },
text_ghost: { color: c.text },
text_outline: { color: c.text },
text_destructive: { color: '#ffffff' },
text_destructiveOutline: { color: c.error },
});
}
+8 -5
View File
@@ -1,10 +1,11 @@
import React from 'react';
import { View, Text, StyleSheet, Modal, TextInput, type TextInputProps } from 'react-native';
import { AlertTriangle } from 'lucide-react-native';
import { AlertTriangle } from './icons';
import Button from './Button';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useLocaleStore } from '../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
interface DialogInput {
value: string;
@@ -55,7 +56,7 @@ export default function Dialog({
const confirmLabel = confirmText ?? t('common.confirm', 'Confirm');
const cancelLabel = cancelText ?? t('common.cancel', 'Cancel');
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
<View style={styles.backdrop}>
<View style={styles.dialog}>
<View style={styles.content}>
@@ -71,7 +72,7 @@ export default function Dialog({
value={input.value}
onChangeText={input.onChangeText}
placeholder={input.placeholder}
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
autoFocus
onSubmitEditing={confirmDisabled ? undefined : onConfirm}
accessibilityLabel={input.placeholder ?? title}
@@ -142,15 +143,17 @@ function makeStyles(c: ThemePalette) {
color: c.mutedForeground, // text-muted-foreground
marginTop: spacing.sm,
},
// The shared Input look: 40 dp, transparent, radius 6.
input: {
...typography.body,
color: c.text,
backgroundColor: c.surface,
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: 10,
paddingVertical: 0,
minHeight: 40,
marginTop: spacing.md,
},
footer: {
+1 -1
View File
@@ -3,7 +3,7 @@ import { View, StyleSheet, Linking, Platform, Text, Pressable, useWindowDimensio
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Image as ImageIcon, ShieldCheck } from 'lucide-react-native';
import { Image as ImageIcon, ShieldCheck } from './icons';
import type { Email } from '../api/types';
import { extractCidRefs } from '../lib/email-html';
import { hasTruncatedDisplayedBody, pickEmailBody, selectRenderableHtml } from '../lib/email-body';
+8 -4
View File
@@ -3,12 +3,14 @@ import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { X, Check, Mail } from 'lucide-react-native';
import { X, Check, Mail } from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useSheetDrag } from '../lib/use-sheet-drag';
import type { Identity } from '../api/types';
import { useLocaleStore } from '../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { useSheetInsets } from '../lib/use-layout';
interface IdentitySheetProps {
visible: boolean;
@@ -25,6 +27,8 @@ export function IdentitySheet({
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(500)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
const { t } = useLocaleStore();
@@ -44,14 +48,14 @@ export function IdentitySheet({
}, [visible, slideY, overlayOpacity]);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
]}
>
<View {...dragHandlers}>
@@ -60,7 +64,7 @@ export function IdentitySheet({
</View>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>{t('email_composer.from', 'From')}</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}
<Pressable onPress={onClose} hitSlop={10} style={styles.sheetClose}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
+17 -14
View File
@@ -9,16 +9,17 @@ interface InputProps extends TextInputProps {
containerStyle?: ViewStyle;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
/** React 19 passes `ref` as a prop; it reaches the TextInput (focus chaining). */
ref?: React.Ref<RNTextInput>;
}
/**
* Matches webmail input.tsx:
* - h-10 w-full rounded-md border border-input bg-background
* - px-3 py-2 text-sm text-foreground
* - placeholder:text-muted-foreground
* - hover:border-muted-foreground
* - focus:ring-2 ring-ring border-ring
* - disabled:opacity-50
* Matches webmail input.tsx in the standard theme:
* - h-10 rounded-md border border-input, transparent background
* - px-3 text-sm text-foreground, placeholder in muted-foreground
* - focus: border-ring
* - disabled: opacity-50
* - label above in text-sm font-medium text-foreground
*/
export default function Input({
label,
@@ -27,6 +28,7 @@ export default function Input({
leftIcon,
rightIcon,
style,
ref,
...props
}: InputProps) {
const c = useColors();
@@ -44,9 +46,10 @@ export default function Input({
]}>
{leftIcon && <View style={styles.iconLeft}>{leftIcon}</View>}
<RNTextInput
ref={ref}
{...props}
style={[styles.input, style]}
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
onFocus={(e) => { setFocused(true); props.onFocus?.(e); }}
onBlur={(e) => { setFocused(false); props.onBlur?.(e); }}
/>
@@ -61,22 +64,22 @@ function makeStyles(c: ThemePalette) {
return StyleSheet.create({
label: {
...typography.bodyMedium,
color: c.textSecondary,
marginBottom: spacing.sm,
color: c.text,
marginBottom: 6,
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
height: componentSizes.inputHeight, // h-10 = 40px
minHeight: componentSizes.inputHeight, // h-10 = 40px; grows with a larger system font
borderWidth: 1,
borderColor: c.border, // border-input
borderRadius: radius.sm, // rounded-md = 6px
backgroundColor: c.background, // bg-background
backgroundColor: 'transparent',
paddingHorizontal: spacing.md, // px-3 = 12px
},
// Colour only, so focusing doesn't shift the text by a pixel.
inputFocused: {
borderColor: c.borderFocus, // border-ring (blue-500)
borderWidth: 2,
borderColor: c.borderFocus,
},
inputError: {
borderColor: c.error,
+10 -6
View File
@@ -5,8 +5,8 @@ import {
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import {
Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Folder,
X, Check, type LucideIcon,
} from 'lucide-react-native';
X, Check, type AppIcon,
} from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree';
@@ -14,8 +14,10 @@ import { useSheetDrag } from '../lib/use-sheet-drag';
import { useLocaleStore } from '../stores/locale-store';
import { localizeMailboxName } from '../lib/mailbox-label';
import type { Mailbox } from '../api/types';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { useSheetInsets } from '../lib/use-layout';
function moveTargetIcon(role: string | null | undefined, name: string): LucideIcon {
function moveTargetIcon(role: string | null | undefined, name: string): AppIcon {
const lower = name.toLowerCase();
if (role === 'inbox' || lower.includes('inbox')) return Inbox;
if (role === 'sent' || lower.includes('sent')) return Send;
@@ -43,6 +45,8 @@ export function MoveSheet({
const t = useLocaleStore((s) => s.t);
const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(500)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
@@ -76,14 +80,14 @@ export function MoveSheet({
}, [mailboxes]);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
]}
>
<View {...dragHandlers}>
@@ -92,7 +96,7 @@ export function MoveSheet({
</View>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>{t('context_menu.move_to', 'Move to folder')}</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}
<Pressable onPress={onClose} hitSlop={10} style={styles.sheetClose}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
+1 -1
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { CloudOff } from 'lucide-react-native';
import { CloudOff } from './icons';
import { useNetworkStore } from '../stores/network-store';
import { useOutboxStore } from '../stores/outbox-store';
import { useLocaleStore } from '../stores/locale-store';
+1 -1
View File
@@ -4,7 +4,7 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { CloudDownload, X } from 'lucide-react-native';
import { CloudDownload, X } from './icons';
import { useOfflineCacheStore } from '../stores/offline-cache-store';
import { useUpdatesStore } from '../stores/updates-store';
import { formatBytes } from '../lib/offline-sync';
+3 -2
View File
@@ -12,13 +12,14 @@ import {
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import * as Clipboard from 'expo-clipboard';
import { ClipboardPaste, X } from 'lucide-react-native';
import { ClipboardPaste, X } from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import Button from './Button';
import { useLocaleStore } from '../stores/locale-store';
import { insecurePairingLinkError, parsePastedSignInLink, type QrLoginPayload } from '../lib/oauth';
import { describeLoginError } from '../lib/login-errors';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
interface PasteSignInLinkModalProps {
visible: boolean;
@@ -80,7 +81,7 @@ export function PasteSignInLinkModal({ visible, onClose, onSubmit }: PasteSignIn
const title = t('login.mobile.paste_title', 'Paste sign-in link');
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaView style={styles.container}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.flex}>
<View style={styles.header}>
+6 -7
View File
@@ -1,7 +1,8 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { BellRing, X } from 'lucide-react-native';
import { useFloatingCardFrame } from '../lib/use-layout';
import { BellRing, X } from './icons';
import Button from './Button';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
@@ -24,8 +25,6 @@ import {
// Mirrors the webmail's push-notification-prompt: a short delay after login
// so the inbox renders first, and never again for an account once dismissed.
const PROMPT_DELAY_MS = 1500;
// Clears the tab bar, like the toasts (ToastHost).
const TAB_BAR_CLEARANCE = 64;
/**
* Once-per-account invitation to turn on background notifications. Shown
@@ -51,6 +50,8 @@ export function PushOnboardingPrompt(): React.ReactElement | null {
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const insets = useSafeAreaInsets();
// Full width above the tab bar on a phone; bottom right on a tablet.
const frame = useFloatingCardFrame();
const duration = useAnimDuration(200);
const appear = React.useRef(new Animated.Value(0)).current;
@@ -117,7 +118,7 @@ export function PushOnboardingPrompt(): React.ReactElement | null {
style={[
styles.wrap,
{
bottom: insets.bottom + TAB_BAR_CLEARANCE,
left: frame.left, right: frame.right, bottom: insets.bottom + frame.bottom,
opacity: appear,
transform: [{ translateY: appear.interpolate({ inputRange: [0, 1], outputRange: [24, 0] }) }],
},
@@ -149,7 +150,7 @@ export function PushOnboardingPrompt(): React.ReactElement | null {
</View>
<Pressable
onPress={handleDismiss}
hitSlop={8}
hitSlop={14}
accessibilityRole="button"
accessibilityLabel={t('settings.notifications.push.dismiss_aria', 'Dismiss notification prompt')}
style={styles.close}
@@ -165,8 +166,6 @@ function makeStyles(c: ThemePalette) {
return StyleSheet.create({
wrap: {
position: 'absolute',
left: spacing.md,
right: spacing.md,
zIndex: 50,
},
card: {
+3 -2
View File
@@ -2,11 +2,12 @@ import React from 'react';
import { View, Text, Pressable, StyleSheet, Modal, Platform, Linking } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
import { X } from 'lucide-react-native';
import { X } from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors, useResolvedTheme } from '../theme/colors';
import Button from './Button';
import { useLocaleStore } from '../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
interface QrScanModalProps {
visible: boolean;
@@ -42,7 +43,7 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
);
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<View style={styles.container}>
{permission?.granted ? (
<CameraView
+22 -2
View File
@@ -65,6 +65,8 @@ export interface RichTextEditorHandle {
*/
getHtml(timeoutMs?: number): Promise<string>;
focus(): void;
/** Drop the caret, which closes the keyboard. */
blur(): void;
}
interface Props {
@@ -76,10 +78,16 @@ interface Props {
onSelectionChange?: (state: RichTextSelectionState) => void;
onFocus?: () => void;
onBlur?: () => void;
/**
* Android: the page undid a caret-reveal pan of `dy` px (dp) inside the
* WebView. The host should scroll its ScrollView down by `dy` so the caret
* stays visible (see `pinVisualViewport` in editor-html).
*/
onViewportShift?: (dy: number) => void;
}
const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function RichTextEditor(
{ initialHtml = '', placeholder = '', blockRemoteImages = false, onChange, onSelectionChange, onFocus, onBlur },
{ initialHtml = '', placeholder = '', blockRemoteImages = false, onChange, onSelectionChange, onFocus, onBlur, onViewportShift },
ref,
) {
const c = useColors();
@@ -90,6 +98,8 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
const onSelectionRef = React.useRef(onSelectionChange);
const onFocusRef = React.useRef(onFocus);
const onBlurRef = React.useRef(onBlur);
const onViewportShiftRef = React.useRef(onViewportShift);
onViewportShiftRef.current = onViewportShift;
onChangeRef.current = onChange;
onSelectionRef.current = onSelectionChange;
onFocusRef.current = onFocus;
@@ -98,7 +108,9 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
// Build the source HTML once. Theming changes won't auto-rebuild the page,
// which is fine: themes change rarely and the user can recompose if needed.
const html = React.useMemo(
() => buildEditorHtml({ initialHtml, placeholder, c, blockRemoteImages }),
() => buildEditorHtml({
initialHtml, placeholder, c, blockRemoteImages, pinVisualViewport: Platform.OS === 'android',
}),
// c reference changes per theme switch but we don't want to lose user
// edits on a theme change. Bake in the initial colors only.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -176,6 +188,9 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
focus: () => {
call(`window.__rne && window.__rne.focus()`);
},
blur: () => {
call(`document.activeElement && document.activeElement.blur()`);
},
}), [call]);
const onMessage = (event: WebViewMessageEvent) => {
@@ -219,6 +234,11 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
case 'blur':
onBlurRef.current?.();
break;
case 'viewportShift': {
const dy = typeof data.payload === 'number' ? data.payload : Number(data.payload);
if (Number.isFinite(dy) && dy !== 0) onViewportShiftRef.current?.(dy);
break;
}
}
};
+447 -393
View File
@@ -10,8 +10,8 @@ import {
Folder, FolderOpen, ChevronDown, ChevronRight, X, Settings, LogOut, Check, Plus,
Clock, Layers, Users, Tag, Mails, MailOpen, StickyNote, AlarmClock, Flag,
CheckCheck, Eraser, FolderPlus, Pencil, AlertTriangle, UserMinus,
type LucideIcon,
} from 'lucide-react-native';
type AppIcon,
} from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useAnimDuration } from '../theme/dynamic';
@@ -37,6 +37,9 @@ import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RootStackParamList } from '../navigation/types';
import type { Mailbox } from '../api/types';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { confirmSignOut } from '../lib/sign-out-confirm';
import { setPendingSettingsTab } from '../navigation/pending-settings-tab';
const CHEVRON_SLOT = 20;
const INDENT_STEP = 12;
@@ -62,7 +65,7 @@ function iconFor(
name: string | undefined,
hasChildren: boolean,
isExpanded: boolean,
): LucideIcon {
): AppIcon {
switch (role) {
case 'inbox': return Inbox;
case 'sent': return Send;
@@ -183,7 +186,9 @@ function SidebarRow({
{hasChildren ? (
<Pressable
onPress={onToggleExpand}
hitSlop={6}
// The 20 dp slot keeps the tree indent; the touch area reaches
// 44 dp so a near miss doesn't open the folder instead.
hitSlop={12}
style={styles.chevron}
accessibilityRole="button"
accessibilityLabel={isExpanded ? t('sidebar.collapse', 'Collapse') : t('sidebar.expand', 'Expand')}
@@ -219,7 +224,7 @@ function SidebarRow({
interface SheetAction {
key: string;
label: string;
icon: LucideIcon;
icon: AppIcon;
destructive?: boolean;
onPress: () => void;
}
@@ -228,7 +233,7 @@ function ActionSheet({ title, actions, onClose }: { title: string; actions: Shee
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<Modal visible transparent animationType="fade" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="fade" onRequestClose={onClose} statusBarTranslucent>
<Pressable style={styles.sheetOverlay} onPress={onClose} />
<View style={styles.sheet}>
<Text style={styles.sheetTitle} numberOfLines={1}>{title}</Text>
@@ -270,7 +275,7 @@ function NamePrompt({ title, message, initial, confirmLabel, onSubmit, onClose }
onSubmit(name);
};
return (
<Modal visible transparent animationType="fade" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="fade" onRequestClose={onClose} statusBarTranslucent>
<Pressable style={styles.sheetOverlay} onPress={onClose} />
<View style={styles.promptCard}>
<Text style={styles.sheetTitle}>{title}</Text>
@@ -301,9 +306,11 @@ function NamePrompt({ title, message, initial, confirmLabel, onSubmit, onClose }
interface SidebarDrawerProps {
visible: boolean;
onClose: () => void;
/** Render as a permanent sidebar (wide screens) instead of a modal drawer. */
docked?: boolean;
}
export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) {
export default function SidebarDrawer({ visible, onClose, docked = false }: SidebarDrawerProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
@@ -678,7 +685,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
};
}, [mailboxes, accountSnapshots, activeAccountId, unifiedCrossAccount, includeGroupInUnified]);
const unifiedIcon = (role: UnifiedRole): LucideIcon => iconFor(role, undefined, false, false);
const unifiedIcon = (role: UnifiedRole): AppIcon => iconFor(role, undefined, false, false);
const slideX = React.useRef(new Animated.Value(-Dimensions.get('window').width)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
@@ -745,8 +752,424 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
const tagViewActive = !!filters.keyword;
const panel = (
<SafeAreaView style={styles.drawerSafe} edges={['top', 'bottom', 'left']}>
{/* Header: close + account switcher */}
<View style={styles.header}>
{!docked && (
<Pressable
onPress={onClose}
style={styles.headerClose}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
<X size={20} color={c.text} />
</Pressable>
)}
<Pressable
onPress={() => setAccountMenuOpen((v) => !v)}
style={({ pressed }) => [styles.account, pressed && styles.accountPressed]}
>
<View style={[styles.accountAvatar, { backgroundColor: avatarBg }]}>
<Text style={styles.accountAvatarText}>{initials}</Text>
</View>
<View style={styles.accountInfo}>
<Text style={styles.accountName} numberOfLines={1}>{username || t('settings.account.title', 'Account')}</Text>
{hostname ? (
<Text style={styles.accountEmail} numberOfLines={1}>{hostname}</Text>
) : null}
</View>
{busy ? (
<ActivityIndicator size="small" color={c.textMuted} />
) : (
<ChevronDown
size={16}
color={c.textMuted}
style={accountMenuOpen ? styles.accountChevronOpen : undefined}
/>
)}
</Pressable>
</View>
{accountMenuOpen && (
<View style={styles.accountMenu}>
<ScrollView style={styles.accountMenuList}>
{accounts.map((acc) => {
const isActive = acc.id === activeAccountId;
let accHost = '';
try { accHost = new URL(acc.serverUrl).hostname; } catch { accHost = acc.serverUrl; }
const accEmail = acc.email || (acc.username.includes('@')
? acc.username
: accHost ? `${acc.username}@${accHost}` : acc.username);
return (
<Pressable
key={acc.id}
onPress={() => {
if (isActive) return;
setAccountMenuOpen(false);
onClose();
void switchAccount(acc.id);
}}
onLongPress={isActive ? undefined : () => confirmRemoveAccount(acc)}
delayLongPress={350}
accessibilityHint={isActive ? undefined : t('sidebar.remove_account_hint', 'Long-press to remove this account')}
style={({ pressed }) => [
styles.accountMenuRow,
isActive && styles.accountMenuRowActive,
pressed && !isActive && styles.accountMenuActionPressed,
]}
>
<View style={styles.accountMenuAvatarWrap}>
<View style={[styles.accountMenuAvatar, { backgroundColor: acc.avatarColor }]}>
<Text style={styles.accountMenuAvatarText}>
{getAccountInitials(acc.displayName, acc.email || acc.username)}
</Text>
</View>
{isActive && (
<View style={styles.accountMenuCheckBadge}>
<Check size={10} color={c.primaryForeground} strokeWidth={3} />
</View>
)}
</View>
<View style={styles.accountMenuInfo}>
<View style={styles.accountMenuNameRow}>
<Text style={styles.accountMenuName} numberOfLines={1}>
{acc.displayName || acc.username}
</Text>
{acc.isDefault && (
<Star size={12} color="#f59e0b" fill="#f59e0b" />
)}
</View>
<Text style={styles.accountMenuHost} numberOfLines={1}>{accEmail}</Text>
<View style={styles.accountMenuStatusRow}>
{acc.hasError ? (
<>
<AlertTriangle size={10} color={c.error} />
<Text style={[styles.accountMenuStatusText, { color: c.error }]} numberOfLines={1}>
{acc.errorMessage || t('sidebar.account_error', 'Needs attention')}
</Text>
</>
) : (
<>
{/* The connection dot only means something for the
account the live session serves. */}
{isActive && (
<View style={[
styles.accountMenuStatusDot,
!acc.isConnected && styles.accountMenuStatusDotOffline,
]} />
)}
<Text style={styles.accountMenuStatusText} numberOfLines={1}>{accHost}</Text>
</>
)}
</View>
</View>
{!isActive && (
<Pressable
onPress={() => confirmRemoveAccount(acc)}
hitSlop={8}
style={styles.accountMenuRemove}
accessibilityRole="button"
accessibilityLabel={t('sidebar.remove_account', 'Remove account')}
>
<UserMinus size={14} color={c.textMuted} />
</Pressable>
)}
</Pressable>
);
})}
</ScrollView>
{accounts.length < MAX_ACCOUNTS && (
<>
<View style={styles.accountMenuDivider} />
<Pressable
style={({ pressed }) => [
styles.accountMenuAction,
pressed && styles.accountMenuActionPressed,
]}
onPress={() => {
setAccountMenuOpen(false);
onClose();
navigation.navigate('AddAccount');
}}
>
<Plus size={16} color={c.textSecondary} />
<Text style={styles.accountMenuActionText}>{t('sidebar.add_account', 'Add account')}</Text>
</Pressable>
</>
)}
<View style={styles.accountMenuDivider} />
{accounts.length > 1 && activeAccountId && (() => {
const active = accounts.find((a) => a.id === activeAccountId);
if (!active || active.isDefault) return null;
return (
<Pressable
style={({ pressed }) => [
styles.accountMenuAction,
pressed && styles.accountMenuActionPressed,
]}
onPress={() => setDefaultAccount(active.id)}
>
<Star size={16} color={c.textSecondary} />
<Text style={styles.accountMenuActionText}>{t('sidebar.set_as_default', 'Set as default')}</Text>
</Pressable>
);
})()}
<Pressable
style={({ pressed }) => [
styles.accountMenuAction,
pressed && styles.accountMenuActionPressed,
]}
onPress={() => {
setAccountMenuOpen(false);
confirmSignOut(t, { account: accountEmailFull || undefined }, () => { onClose(); void logout(); });
}}
>
<LogOut size={16} color={c.textSecondary} />
<Text style={styles.accountMenuActionText} numberOfLines={1}>
{t('sidebar.sign_out_of', `Sign out of ${accountEmailFull || 'account'}`, { account: accountEmailFull || 'account' })}
</Text>
</Pressable>
{accounts.length > 1 && (
<Pressable
style={({ pressed }) => [
styles.accountMenuAction,
pressed && styles.accountMenuActionPressed,
]}
onPress={() => {
setAccountMenuOpen(false);
confirmSignOut(t, { all: true }, () => { onClose(); void logoutAll(); });
}}
>
<LogOut size={16} color={c.error} />
<Text style={[styles.accountMenuActionText, { color: c.error }]}>
{t('sidebar.sign_out_all', 'Sign out of all accounts')}
</Text>
</Pressable>
)}
</View>
)}
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
{/* Quick views */}
{showUnified && (
<Pressable
style={({ pressed }) => [styles.quickRow, pressed && styles.rowPressed]}
onPress={() => { onClose(); navigation.navigate('UnifiedInbox', { role: 'inbox' }); }}
>
<Layers size={16} color={c.textSecondary} />
<Text style={styles.quickRowLabel}>
{unifiedCrossAccount && accounts.length > 1
? t('sidebar.all_accounts', 'All accounts')
: t('sidebar.unified_inbox', 'All inboxes')}
</Text>
<View style={{ flex: 1 }} />
<RowCounts unread={unifiedCounts.inbox.unread} total={unifiedCounts.inbox.total} showTotal={showFolderTotalCount} />
</Pressable>
)}
<Pressable
style={({ pressed }) => [styles.quickRow, pressed && styles.rowPressed]}
onPress={() => { onClose(); navigation.navigate('Scheduled'); }}
>
<Clock size={16} color={c.textSecondary} />
<Text style={styles.quickRowLabel}>{t('sidebar.scheduled', 'Scheduled')}</Text>
</Pressable>
{/* Unified per-role and cross views */}
{showUnified && (
<>
<Pressable style={styles.sectionHeader} onPress={() => toggleSection('unifiedExpanded', setUnifiedExpanded)}>
{unifiedExpanded ? (
<ChevronDown size={14} color={c.textMuted} />
) : (
<ChevronRight size={14} color={c.textMuted} />
)}
<Text style={styles.sectionHeaderText}>{t('sidebar.unified_mailbox', 'Unified mailbox')}</Text>
</Pressable>
{unifiedExpanded && (
<>
{([
{ view: 'unread' as const, Icon: MailOpen, label: t('sidebar.unified_all_unread', 'All unread'), unread: unifiedCounts.crossUnread },
{ view: 'starred' as const, Icon: Star, label: t('sidebar.unified_all_starred', 'All starred'), unread: 0 },
{ view: 'all' as const, Icon: Mails, label: t('sidebar.unified_all_mail', 'All mail'), unread: unifiedCounts.crossUnread },
]).map((row) => (
<SidebarRow
key={row.view}
icon={<row.Icon size={16} color={c.textSecondary} />}
label={row.label}
depth={0}
isSelected={false}
unread={row.unread}
total={0}
showTotal={false}
hasChildren={false}
isExpanded={false}
onPress={() => { onClose(); navigation.navigate('UnifiedInbox', { view: row.view }); }}
onToggleExpand={() => {}}
/>
))}
{unifiedCounts.roles.map((r) => {
const Icon = unifiedIcon(r.role);
return (
<SidebarRow
key={r.role}
icon={<Icon size={16} color={iconColor(c, r.role, false)} />}
label={t(`sidebar.unified_${r.role}`, `All ${r.role}`)}
depth={0}
isSelected={false}
unread={r.unread}
total={r.total}
showTotal={showFolderTotalCount}
hasChildren={false}
isExpanded={false}
onPress={() => { onClose(); navigation.navigate('UnifiedInbox', { role: r.role }); }}
onToggleExpand={() => {}}
/>
);
})}
</>
)}
</>
)}
{/* Folders section */}
<Pressable style={styles.sectionHeader} onPress={() => toggleSection('foldersExpanded', setFoldersExpanded)}>
{foldersExpanded ? (
<ChevronDown size={14} color={c.textMuted} />
) : (
<ChevronRight size={14} color={c.textMuted} />
)}
<Text style={styles.sectionHeaderText}>{t('sidebar.folders', 'Folders')}</Text>
<View style={{ flex: 1 }} />
<Pressable
style={styles.sectionSettings}
hitSlop={8}
// Opens the folder settings; it used to navigate to the screen
// already shown, which only closed the drawer.
onPress={() => {
onClose();
setPendingSettingsTab('folders');
navigation.navigate('MainTabs', { screen: 'Settings' } as never);
}}
accessibilityRole="button"
accessibilityLabel={t('settings.tabs.folders', 'Folders')}
>
<Settings size={14} color={c.textMuted} />
</Pressable>
</Pressable>
{foldersExpanded && (
mailboxes.length === 0 ? (
<Text style={styles.empty}>{t('sidebar.loading_mailboxes', 'Loading mailboxes...')}</Text>
) : (
visibleNodes.map((node) => {
const hasChildren = node.children.length > 0;
const isExpanded = expandedFolders.has(node.id);
// A shared/group account's header has no mailbox behind it —
// tapping it only opens or closes that account's folders.
const Icon = node.isAccountNode
? Users
: iconFor(node.role, node.name, hasChildren, isExpanded);
const isSelected = !node.isAccountNode && !tagViewActive && node.id === currentMailboxId;
return (
<SidebarRow
key={node.id}
icon={
<Icon
size={16}
color={node.isAccountNode ? c.textMuted : iconColor(c, node.role, isSelected)}
/>
}
label={node.isAccountNode ? node.name : localizeMailboxName(node.role, node.name, t)}
depth={node.depth}
isSelected={isSelected}
isAccountHeader={node.isAccountNode}
unread={node.unreadEmails}
total={node.totalEmails}
showTotal={showFolderTotalCount && !node.isAccountNode}
hasChildren={hasChildren}
isExpanded={isExpanded}
onPress={
node.isAccountNode
? () => toggleExpand(node.id)
: () => handleSelect(node.id)
}
onLongPress={
node.isAccountNode
? () => openSharedAccountSheet(node)
: () => openFolderSheet(node)
}
onToggleExpand={() => toggleExpand(node.id)}
onPressUnread={node.isAccountNode ? undefined : () => handleSelectUnread(node.id)}
/>
);
})
)
)}
{/* Tags section (#175) */}
{keywordDefs.length > 0 && (
<>
<Pressable style={styles.sectionHeader} onPress={() => toggleSection('tagsExpanded', setTagsExpanded)}>
{tagsExpanded ? (
<ChevronDown size={14} color={c.textMuted} />
) : (
<ChevronRight size={14} color={c.textMuted} />
)}
<Text style={styles.sectionHeaderText}>{t('sidebar.tags', 'Tags')}</Text>
</Pressable>
{tagsExpanded && keywordDefs.map((kw) => {
const counts = tagCounts[kw.id];
const isSelected = filters.keyword === keywordToken(kw.id);
const dot = c.tags[kw.color]?.dot ?? c.textMuted;
return (
<SidebarRow
key={kw.id}
icon={<Tag size={16} color={dot} fill={dot} />}
label={kw.label}
depth={0}
isSelected={isSelected}
unread={counts?.unread ?? 0}
total={counts?.total ?? 0}
showTotal={showFolderTotalCount}
hasChildren={false}
isExpanded={false}
onPress={() => selectTag(kw.id)}
onToggleExpand={() => {}}
/>
);
})}
</>
)}
</ScrollView>
</SafeAreaView>
);
// On a wide screen the drawer is a permanent sidebar next to the list.
if (docked) {
return (
<View style={styles.docked}>
{panel}
{sheet && <ActionSheet title={sheet.title} actions={sheet.actions} onClose={() => setSheet(null)} />}
{prompt && (
<NamePrompt
title={prompt.title}
message={prompt.message}
initial={prompt.initial}
confirmLabel={prompt.confirmLabel}
onSubmit={prompt.onSubmit}
onClose={() => setPrompt(null)}
/>
)}
</View>
);
}
return (
<Modal
<Modal supportedOrientations={MODAL_ORIENTATIONS}
visible={visible}
transparent
animationType="none"
@@ -759,384 +1182,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
</Animated.View>
<Animated.View style={[styles.drawer, { transform: [{ translateX: slideX }] }]}>
<SafeAreaView style={styles.drawerSafe} edges={['top', 'bottom', 'left']}>
{/* Header: close + account switcher */}
<View style={styles.header}>
<Pressable
onPress={onClose}
style={styles.headerClose}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
<X size={20} color={c.text} />
</Pressable>
<Pressable
onPress={() => setAccountMenuOpen((v) => !v)}
style={({ pressed }) => [styles.account, pressed && styles.accountPressed]}
>
<View style={[styles.accountAvatar, { backgroundColor: avatarBg }]}>
<Text style={styles.accountAvatarText}>{initials}</Text>
</View>
<View style={styles.accountInfo}>
<Text style={styles.accountName} numberOfLines={1}>{username || t('settings.account.title', 'Account')}</Text>
{hostname ? (
<Text style={styles.accountEmail} numberOfLines={1}>{hostname}</Text>
) : null}
</View>
{busy ? (
<ActivityIndicator size="small" color={c.textMuted} />
) : (
<ChevronDown
size={16}
color={c.textMuted}
style={accountMenuOpen ? styles.accountChevronOpen : undefined}
/>
)}
</Pressable>
</View>
{accountMenuOpen && (
<View style={styles.accountMenu}>
<ScrollView style={styles.accountMenuList}>
{accounts.map((acc) => {
const isActive = acc.id === activeAccountId;
let accHost = '';
try { accHost = new URL(acc.serverUrl).hostname; } catch { accHost = acc.serverUrl; }
const accEmail = acc.email || (acc.username.includes('@')
? acc.username
: accHost ? `${acc.username}@${accHost}` : acc.username);
return (
<Pressable
key={acc.id}
onPress={() => {
if (isActive) return;
setAccountMenuOpen(false);
onClose();
void switchAccount(acc.id);
}}
onLongPress={isActive ? undefined : () => confirmRemoveAccount(acc)}
delayLongPress={350}
accessibilityHint={isActive ? undefined : t('sidebar.remove_account_hint', 'Long-press to remove this account')}
style={({ pressed }) => [
styles.accountMenuRow,
isActive && styles.accountMenuRowActive,
pressed && !isActive && styles.accountMenuActionPressed,
]}
>
<View style={styles.accountMenuAvatarWrap}>
<View style={[styles.accountMenuAvatar, { backgroundColor: acc.avatarColor }]}>
<Text style={styles.accountMenuAvatarText}>
{getAccountInitials(acc.displayName, acc.email || acc.username)}
</Text>
</View>
{isActive && (
<View style={styles.accountMenuCheckBadge}>
<Check size={10} color={c.primaryForeground} strokeWidth={3} />
</View>
)}
</View>
<View style={styles.accountMenuInfo}>
<View style={styles.accountMenuNameRow}>
<Text style={styles.accountMenuName} numberOfLines={1}>
{acc.displayName || acc.username}
</Text>
{acc.isDefault && (
<Star size={12} color="#f59e0b" fill="#f59e0b" />
)}
</View>
<Text style={styles.accountMenuHost} numberOfLines={1}>{accEmail}</Text>
<View style={styles.accountMenuStatusRow}>
{acc.hasError ? (
<>
<AlertTriangle size={10} color={c.error} />
<Text style={[styles.accountMenuStatusText, { color: c.error }]} numberOfLines={1}>
{acc.errorMessage || t('sidebar.account_error', 'Needs attention')}
</Text>
</>
) : (
<>
{/* The connection dot only means something for the
account the live session serves. */}
{isActive && (
<View style={[
styles.accountMenuStatusDot,
!acc.isConnected && styles.accountMenuStatusDotOffline,
]} />
)}
<Text style={styles.accountMenuStatusText} numberOfLines={1}>{accHost}</Text>
</>
)}
</View>
</View>
{!isActive && (
<Pressable
onPress={() => confirmRemoveAccount(acc)}
hitSlop={8}
style={styles.accountMenuRemove}
accessibilityRole="button"
accessibilityLabel={t('sidebar.remove_account', 'Remove account')}
>
<UserMinus size={14} color={c.textMuted} />
</Pressable>
)}
</Pressable>
);
})}
</ScrollView>
{accounts.length < MAX_ACCOUNTS && (
<>
<View style={styles.accountMenuDivider} />
<Pressable
style={({ pressed }) => [
styles.accountMenuAction,
pressed && styles.accountMenuActionPressed,
]}
onPress={() => {
setAccountMenuOpen(false);
onClose();
navigation.navigate('AddAccount');
}}
>
<Plus size={16} color={c.textSecondary} />
<Text style={styles.accountMenuActionText}>{t('sidebar.add_account', 'Add account')}</Text>
</Pressable>
</>
)}
<View style={styles.accountMenuDivider} />
{accounts.length > 1 && activeAccountId && (() => {
const active = accounts.find((a) => a.id === activeAccountId);
if (!active || active.isDefault) return null;
return (
<Pressable
style={({ pressed }) => [
styles.accountMenuAction,
pressed && styles.accountMenuActionPressed,
]}
onPress={() => setDefaultAccount(active.id)}
>
<Star size={16} color={c.textSecondary} />
<Text style={styles.accountMenuActionText}>{t('sidebar.set_as_default', 'Set as default')}</Text>
</Pressable>
);
})()}
<Pressable
style={({ pressed }) => [
styles.accountMenuAction,
pressed && styles.accountMenuActionPressed,
]}
onPress={() => { setAccountMenuOpen(false); onClose(); void logout(); }}
>
<LogOut size={16} color={c.textSecondary} />
<Text style={styles.accountMenuActionText} numberOfLines={1}>
{t('sidebar.sign_out_of', `Sign out of ${accountEmailFull || 'account'}`, { account: accountEmailFull || 'account' })}
</Text>
</Pressable>
{accounts.length > 1 && (
<Pressable
style={({ pressed }) => [
styles.accountMenuAction,
pressed && styles.accountMenuActionPressed,
]}
onPress={() => { setAccountMenuOpen(false); onClose(); void logoutAll(); }}
>
<LogOut size={16} color={c.error} />
<Text style={[styles.accountMenuActionText, { color: c.error }]}>
{t('sidebar.sign_out_all', 'Sign out of all accounts')}
</Text>
</Pressable>
)}
</View>
)}
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
{/* Quick views */}
{showUnified && (
<Pressable
style={({ pressed }) => [styles.quickRow, pressed && styles.rowPressed]}
onPress={() => { onClose(); navigation.navigate('UnifiedInbox', { role: 'inbox' }); }}
>
<Layers size={16} color={c.textSecondary} />
<Text style={styles.quickRowLabel}>
{unifiedCrossAccount && accounts.length > 1
? t('sidebar.all_accounts', 'All accounts')
: t('sidebar.unified_inbox', 'All inboxes')}
</Text>
<View style={{ flex: 1 }} />
<RowCounts unread={unifiedCounts.inbox.unread} total={unifiedCounts.inbox.total} showTotal={showFolderTotalCount} />
</Pressable>
)}
<Pressable
style={({ pressed }) => [styles.quickRow, pressed && styles.rowPressed]}
onPress={() => { onClose(); navigation.navigate('Scheduled'); }}
>
<Clock size={16} color={c.textSecondary} />
<Text style={styles.quickRowLabel}>{t('sidebar.scheduled', 'Scheduled')}</Text>
</Pressable>
{/* Unified per-role and cross views */}
{showUnified && (
<>
<Pressable style={styles.sectionHeader} onPress={() => toggleSection('unifiedExpanded', setUnifiedExpanded)}>
{unifiedExpanded ? (
<ChevronDown size={14} color={c.textMuted} />
) : (
<ChevronRight size={14} color={c.textMuted} />
)}
<Text style={styles.sectionHeaderText}>{t('sidebar.unified_mailbox', 'Unified mailbox')}</Text>
</Pressable>
{unifiedExpanded && (
<>
{([
{ view: 'unread' as const, Icon: MailOpen, label: t('sidebar.unified_all_unread', 'All unread'), unread: unifiedCounts.crossUnread },
{ view: 'starred' as const, Icon: Star, label: t('sidebar.unified_all_starred', 'All starred'), unread: 0 },
{ view: 'all' as const, Icon: Mails, label: t('sidebar.unified_all_mail', 'All mail'), unread: unifiedCounts.crossUnread },
]).map((row) => (
<SidebarRow
key={row.view}
icon={<row.Icon size={16} color={c.textSecondary} />}
label={row.label}
depth={0}
isSelected={false}
unread={row.unread}
total={0}
showTotal={false}
hasChildren={false}
isExpanded={false}
onPress={() => { onClose(); navigation.navigate('UnifiedInbox', { view: row.view }); }}
onToggleExpand={() => {}}
/>
))}
{unifiedCounts.roles.map((r) => {
const Icon = unifiedIcon(r.role);
return (
<SidebarRow
key={r.role}
icon={<Icon size={16} color={iconColor(c, r.role, false)} />}
label={t(`sidebar.unified_${r.role}`, `All ${r.role}`)}
depth={0}
isSelected={false}
unread={r.unread}
total={r.total}
showTotal={showFolderTotalCount}
hasChildren={false}
isExpanded={false}
onPress={() => { onClose(); navigation.navigate('UnifiedInbox', { role: r.role }); }}
onToggleExpand={() => {}}
/>
);
})}
</>
)}
</>
)}
{/* Folders section */}
<Pressable style={styles.sectionHeader} onPress={() => toggleSection('foldersExpanded', setFoldersExpanded)}>
{foldersExpanded ? (
<ChevronDown size={14} color={c.textMuted} />
) : (
<ChevronRight size={14} color={c.textMuted} />
)}
<Text style={styles.sectionHeaderText}>{t('sidebar.folders', 'Folders')}</Text>
<View style={{ flex: 1 }} />
<Pressable
style={styles.sectionSettings}
hitSlop={8}
onPress={() => { onClose(); navigation.navigate('MainTabs'); }}
accessibilityLabel={t('sidebar.settings', 'Settings')}
>
<Settings size={14} color={c.textMuted} />
</Pressable>
</Pressable>
{foldersExpanded && (
mailboxes.length === 0 ? (
<Text style={styles.empty}>{t('sidebar.loading_mailboxes', 'Loading mailboxes...')}</Text>
) : (
visibleNodes.map((node) => {
const hasChildren = node.children.length > 0;
const isExpanded = expandedFolders.has(node.id);
// A shared/group account's header has no mailbox behind it —
// tapping it only opens or closes that account's folders.
const Icon = node.isAccountNode
? Users
: iconFor(node.role, node.name, hasChildren, isExpanded);
const isSelected = !node.isAccountNode && !tagViewActive && node.id === currentMailboxId;
return (
<SidebarRow
key={node.id}
icon={
<Icon
size={16}
color={node.isAccountNode ? c.textMuted : iconColor(c, node.role, isSelected)}
/>
}
label={node.isAccountNode ? node.name : localizeMailboxName(node.role, node.name, t)}
depth={node.depth}
isSelected={isSelected}
isAccountHeader={node.isAccountNode}
unread={node.unreadEmails}
total={node.totalEmails}
showTotal={showFolderTotalCount && !node.isAccountNode}
hasChildren={hasChildren}
isExpanded={isExpanded}
onPress={
node.isAccountNode
? () => toggleExpand(node.id)
: () => handleSelect(node.id)
}
onLongPress={
node.isAccountNode
? () => openSharedAccountSheet(node)
: () => openFolderSheet(node)
}
onToggleExpand={() => toggleExpand(node.id)}
onPressUnread={node.isAccountNode ? undefined : () => handleSelectUnread(node.id)}
/>
);
})
)
)}
{/* Tags section (#175) */}
{keywordDefs.length > 0 && (
<>
<Pressable style={styles.sectionHeader} onPress={() => toggleSection('tagsExpanded', setTagsExpanded)}>
{tagsExpanded ? (
<ChevronDown size={14} color={c.textMuted} />
) : (
<ChevronRight size={14} color={c.textMuted} />
)}
<Text style={styles.sectionHeaderText}>{t('sidebar.tags', 'Tags')}</Text>
</Pressable>
{tagsExpanded && keywordDefs.map((kw) => {
const counts = tagCounts[kw.id];
const isSelected = filters.keyword === keywordToken(kw.id);
const dot = c.tags[kw.color]?.dot ?? c.textMuted;
return (
<SidebarRow
key={kw.id}
icon={<Tag size={16} color={dot} fill={dot} />}
label={kw.label}
depth={0}
isSelected={isSelected}
unread={counts?.unread ?? 0}
total={counts?.total ?? 0}
showTotal={showFolderTotalCount}
hasChildren={false}
isExpanded={false}
onPress={() => selectTag(kw.id)}
onToggleExpand={() => {}}
/>
);
})}
</>
)}
</ScrollView>
</SafeAreaView>
{panel}
</Animated.View>
{sheet && <ActionSheet title={sheet.title} actions={sheet.actions} onClose={() => setSheet(null)} />}
@@ -1154,6 +1200,9 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
);
}
/** Width of the permanent sidebar (the webmail's default sidebar is 256 px). */
export const DOCKED_WIDTH = 272;
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
overlay: {
@@ -1173,6 +1222,12 @@ function makeStyles(c: ThemePalette) {
borderRightColor: c.border,
},
drawerSafe: { flex: 1 },
docked: {
width: DOCKED_WIDTH,
backgroundColor: c.secondary,
borderRightWidth: 1,
borderRightColor: c.border,
},
// Header
header: {
@@ -1346,7 +1401,7 @@ function makeStyles(c: ThemePalette) {
...typography.bodySemibold,
color: c.text,
},
sectionSettings: { padding: 4 },
sectionSettings: { padding: 10 },
quickRow: {
flexDirection: 'row',
@@ -1404,10 +1459,9 @@ function makeStyles(c: ThemePalette) {
color: c.text,
},
rowLabelAccount: {
...typography.caption,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.5,
fontSize: 13,
lineHeight: 18,
fontWeight: '500',
color: c.textSecondary,
},
counts: {
+4 -4
View File
@@ -4,8 +4,8 @@ import {
} from 'react-native';
import {
Archive, Trash2, ShieldAlert, ShieldCheck, MailOpen, Mail, Star, Pin, PinOff, FolderInput,
type LucideIcon,
} from 'lucide-react-native';
type AppIcon,
} from './icons';
import { typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useLocaleStore } from '../stores/locale-store';
@@ -43,7 +43,7 @@ export interface SwipeContext {
// Distance the row flies off-screen by before the action callback fires.
const EXIT_DISTANCE = 600;
const ACTION_META: Record<Exclude<SwipeAction, 'none'>, { icon: LucideIcon; bg: string; defaultLabel: string }> = {
const ACTION_META: Record<Exclude<SwipeAction, 'none'>, { icon: AppIcon; bg: string; defaultLabel: string }> = {
archive: { icon: Archive, bg: '#1d4ed8', defaultLabel: 'Archive' },
delete: { icon: Trash2, bg: '#b91c1c', defaultLabel: 'Delete' },
spam: { icon: ShieldAlert, bg: '#a16207', defaultLabel: 'Spam' },
@@ -68,7 +68,7 @@ function actionLabel(action: SwipeAction, context: SwipeContext, t: Translate):
}
}
function actionIcon(action: Exclude<SwipeAction, 'none'>, context: SwipeContext): LucideIcon {
function actionIcon(action: Exclude<SwipeAction, 'none'>, context: SwipeContext): AppIcon {
if (action === 'spam' && context.inJunk) return ShieldCheck;
if (action === 'read' && !context.unread) return Mail;
if (action === 'pin' && context.pinned) return PinOff;
+11 -7
View File
@@ -3,14 +3,16 @@ import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { X, Check, Tag } from 'lucide-react-native';
import { spacing, radius, typography, colors as tokenColors, type ThemePalette } from '../theme/tokens';
import { X, Check, Tag } from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useSheetDrag } from '../lib/use-sheet-drag';
import { keywordToken, type KeywordDef } from '../stores/keywords-store';
import { useLocaleStore } from '../stores/locale-store';
import { getEmailTagIds } from '../lib/thread-utils';
import type { Email } from '../api/types';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { useSheetInsets } from '../lib/use-layout';
interface TagSheetProps {
visible: boolean;
@@ -28,6 +30,8 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
const t = useLocaleStore((s) => s.t);
const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(500)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
@@ -65,14 +69,14 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
}, [keywords, selectedEmails]);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
]}
>
<View {...dragHandlers}>
@@ -83,7 +87,7 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
<Text style={styles.title}>
{t('context_menu.items_selected', `${selectedEmails.length} emails selected`, { count: selectedEmails.length })}
</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.close}
<Pressable onPress={onClose} hitSlop={10} style={styles.close}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
@@ -98,7 +102,7 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
keywords.map((kw) => {
const token = keywordToken(kw.id);
const applied = allHaveToken(token);
const dot = tokenColors.tags[kw.color]?.dot ?? c.primary;
const dot = c.tags[kw.color]?.dot ?? c.primary;
return (
<Pressable
key={kw.id}
@@ -121,7 +125,7 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
onPress={() => onToggle(token, !applied)}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
<Tag size={16} color={tokenColors.tags.gray.dot} fill={tokenColors.tags.gray.dot} />
<Tag size={16} color={c.tags.gray.dot} fill={c.tags.gray.dot} />
<Text style={styles.rowLabel} numberOfLines={1}>{id}</Text>
<Text style={styles.rowHint}>{t('email_list.unknown_tag', 'not defined here')}</Text>
{applied && <Check size={16} color={c.primary} />}
+8 -4
View File
@@ -3,13 +3,15 @@ import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { X, Star, FileText } from 'lucide-react-native';
import { X, Star, FileText } from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useSheetDrag } from '../lib/use-sheet-drag';
import { useLocaleStore } from '../stores/locale-store';
import { useTemplatesStore, type EmailTemplate } from '../stores/templates-store';
import { filterTemplates } from '../lib/template-utils';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { useSheetInsets } from '../lib/use-layout';
interface TemplateSheetProps {
visible: boolean;
@@ -32,6 +34,8 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps)
const hydrate = useTemplatesStore((s) => s.hydrate);
const [query, setQuery] = React.useState('');
const slideY = React.useRef(new Animated.Value(500)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
@@ -63,14 +67,14 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps)
}, [templates, query]);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
]}
>
<View {...dragHandlers}>
@@ -79,7 +83,7 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps)
</View>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>{t('templates.picker_title', 'Choose a Template')}</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}
<Pressable onPress={onClose} hitSlop={10} style={styles.sheetClose}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
+8 -4
View File
@@ -4,7 +4,8 @@ import {
type AccessibilityActionEvent, type LayoutChangeEvent,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { AlertCircle, AlertTriangle, CheckCircle2, Info } from 'lucide-react-native';
import { useFloatingCardFrame } from '../lib/use-layout';
import { AlertCircle, AlertTriangle, CheckCircle2, Info } from './icons';
import { spacing, radius, type ThemePalette } from '../theme/tokens';
import { useColors, useResolvedTheme } from '../theme/colors';
import { useShouldAnimate } from '../theme/dynamic';
@@ -21,9 +22,14 @@ import { shouldClaimToastSwipe, toastSwipeRelease } from './toast-swipe';
export function ToastHost(): React.ReactElement | null {
const toasts = useToastStore((s) => s.toasts);
const insets = useSafeAreaInsets();
// Full width above the tab bar on a phone; bottom right on a tablet.
const frame = useFloatingCardFrame();
if (toasts.length === 0) return null;
return (
<View pointerEvents="box-none" style={[styles.wrap, { bottom: insets.bottom + 64 }]}>
<View
pointerEvents="box-none"
style={[styles.wrap, { left: frame.left, right: frame.right, bottom: insets.bottom + frame.bottom }]}
>
{toasts.map((t) => <ToastCard key={t.id} toast={t} />)}
</View>
);
@@ -226,8 +232,6 @@ function ToastCard({ toast }: { toast: Toast }) {
const styles = StyleSheet.create({
wrap: {
position: 'absolute',
left: spacing.md,
right: spacing.md,
gap: spacing.sm,
zIndex: 60,
},
+13 -7
View File
@@ -12,11 +12,9 @@ interface ToggleSwitchProps {
}
/**
* Matches webmail ToggleSwitch:
* - h-6 w-11 rounded-full
* - checked: bg-primary, unchecked: bg-muted
* - thumb: h-4 w-4 rounded-full bg-background
* - translate-x-6 (checked) / translate-x-1 (unchecked)
* The webmail switch in the standard theme: a 44 x 24 track, bg-primary when
* on (knob in primary-foreground), bg-muted when off (knob in background).
* The knob is 20 dp with 2 dp of track around it.
*/
export default function ToggleSwitch({ value, onValueChange, disabled = false, accessibilityLabel }: ToggleSwitchProps) {
const c = useColors();
@@ -29,6 +27,8 @@ export default function ToggleSwitch({ value, onValueChange, disabled = false, a
disabled && styles.disabled,
]}
onPress={() => !disabled && onValueChange(!value)}
// The track is 44 x 24; the touch area reaches 48 dp high.
hitSlop={{ top: 12, bottom: 12, left: 4, right: 4 }}
accessibilityRole="switch"
accessibilityLabel={accessibilityLabel}
accessibilityState={{ checked: value, disabled }}
@@ -36,7 +36,8 @@ export default function ToggleSwitch({ value, onValueChange, disabled = false, a
<View
style={[
styles.thumb,
{ transform: [{ translateX: value ? 24 : 4 }] },
value ? styles.thumbOn : styles.thumbOff,
{ transform: [{ translateX: value ? 22 : 2 }] },
]}
/>
</Pressable>
@@ -58,9 +59,14 @@ function makeStyles(c: ThemePalette) {
backgroundColor: c.muted,
},
thumb: {
width: componentSizes.toggleThumb, // h-4 w-4 = 16
width: componentSizes.toggleThumb,
height: componentSizes.toggleThumb,
borderRadius: radius.full,
},
thumbOn: {
backgroundColor: c.primaryForeground,
},
thumbOff: {
backgroundColor: c.background,
},
disabled: {
+4 -5
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useFloatingCardFrame } from '../lib/use-layout';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
@@ -11,8 +12,6 @@ import { useLocaleStore } from '../stores/locale-store';
import type { RootStackParamList } from '../navigation/types';
const VISIBLE_MS = 5000;
// Clears the tab bar and the viewer's action bar, like the toasts (ToastHost).
const BOTTOM_CLEARANCE = 64;
interface Shown {
label: string;
@@ -32,6 +31,8 @@ export function UndoSnackbar() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
// Full width above the tab bar on a phone; bottom right on a tablet.
const frame = useFloatingCardFrame();
const t = useLocaleStore((s) => s.t);
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const entry = useEmailStore((s) => s.pendingUndo);
@@ -128,7 +129,7 @@ export function UndoSnackbar() {
pointerEvents="box-none"
style={[
styles.wrap,
{ bottom: insets.bottom + BOTTOM_CLEARANCE, opacity, transform: [{ translateY: slideY }] },
{ left: frame.left, right: frame.right, bottom: insets.bottom + frame.bottom, opacity, transform: [{ translateY: slideY }] },
]}
>
<View style={styles.bar}>
@@ -160,8 +161,6 @@ function makeStyles(c: ThemePalette) {
return StyleSheet.create({
wrap: {
position: 'absolute',
left: spacing.md,
right: spacing.md,
zIndex: 50,
},
bar: {
+1 -1
View File
@@ -1,7 +1,7 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, Linking } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Download, ShieldAlert, X } from 'lucide-react-native';
import { Download, ShieldAlert, X } from './icons';
import { useUpdatesStore } from '../stores/updates-store';
import { useLocaleStore } from '../stores/locale-store';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
@@ -8,7 +8,7 @@ import { findUntranslatedLiterals } from './untranslated-literals';
// Pane file (relative to src/components/) → texts that stay as they are on
// purpose: product names, units, format patterns and sample data.
const TRANSLATED_PANES: Record<string, string[]> = {
'settings/AboutDataSettings.tsx': ['MB', 'GitHub'],
'settings/AboutSettings.tsx': [],
'settings/AccountSecuritySettings.tsx': ['AES-128', 'AES-256'],
'settings/AccountSettings.tsx': [],
'settings/AppearanceSettings.tsx': [],
@@ -31,12 +31,14 @@ const TRANSLATED_PANES: Record<string, string[]> = {
'settings/LanguageSettings.tsx': [],
'settings/LayoutSettings.tsx': [],
'settings/NotificationSettings.tsx': [],
'settings/PluginsSettings.tsx': [],
'settings/LicencesSettings.tsx': ['GNU Affero General Public License v3.0'],
'settings/PrivacySettings.tsx': [],
'settings/ReadingSettings.tsx': [],
'settings/SidebarAppsSettings.tsx': ['https://example.com'],
'settings/SmimeSettings.tsx': [],
'settings/StorageSettings.tsx': ['MB'],
'settings/TemplateSettings.tsx': ['JSON', '{"templates": [...]}'],
'settings/ThemesSettings.tsx': [],
'settings/TroubleshootingSettings.tsx': [],
'settings/UpdatesSettings.tsx': [],
'settings/VacationSettings.tsx': ['YYYY-MM-DD HH:MM'],
'settings/settings-section.tsx': [],
+133 -57
View File
@@ -9,8 +9,8 @@ import {
type LayoutChangeEvent,
type ViewToken,
} from 'react-native';
import { format, type Locale } from 'date-fns';
import { displayNow, isDisplayToday, isDisplayTomorrow } from '../../lib/calendar-timezone';
import { endOfWeek, format, startOfWeek, type Locale } from 'date-fns';
import { displayNow, isDisplayToday } from '../../lib/calendar-timezone';
import type { Calendar, CalendarEvent } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
@@ -47,25 +47,28 @@ interface AgendaViewProps {
/** Reports the first day in view as the user scrolls. */
onVisibleDateChange?: (date: Date) => void;
timeFormat?: TimeFormat;
weekStartsOn?: 0 | 1 | 6;
/** The user's addresses, to draw events they declined as inactive. */
currentUserEmails?: string[];
onSelectEvent?: (event: CalendarEvent) => void;
/** Tapping an empty "today" row creates an event on that day. */
onCreateAt?: (date: Date) => void;
}
interface DaySection extends AgendaDay {
key: string;
title: string;
/** Set on the first day of a month in the list: the month's name. */
monthTitle: string | null;
/** Set on the first day of a week in the list: the week's range. */
weekTitle: string | null;
}
function formatDayHeader(
date: Date,
t: (key: string, fallback?: string) => string,
locale: Locale,
): string {
// Today and tomorrow on a clock in the calendar's time zone.
if (isDisplayToday(date)) return t('calendar.events.today_header', 'Today');
if (isDisplayTomorrow(date)) return t('calendar.events.tomorrow_header', 'Tomorrow');
return format(date, 'EEEE, MMM d', { locale });
// The week separators: "Oct 11 – 17", or "Sep 27 – Oct 3" across months.
function formatWeekRange(date: Date, weekStartsOn: 0 | 1 | 6, locale: Locale): string {
const start = startOfWeek(date, { weekStartsOn });
const end = endOfWeek(date, { weekStartsOn });
const sameMonth = start.getMonth() === end.getMonth();
return `${format(start, 'MMM d', { locale })} – ${format(end, sameMonth ? 'd' : 'MMM d', { locale })}`;
}
const VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 1 };
@@ -92,8 +95,10 @@ export function AgendaView({
isLoading = false,
onVisibleDateChange,
timeFormat,
weekStartsOn = 1,
currentUserEmails,
onSelectEvent,
onCreateAt,
}: AgendaViewProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -104,15 +109,21 @@ export function AgendaView({
[eventsByDay, events],
);
const sections = React.useMemo<DaySection[]>(
() =>
buildAgendaDays(index, loaded, displayNow()).map((day) => ({
...day,
key: dayKey(day.date),
title: formatDayHeader(day.date, t, locale),
})),
[index, loaded, t, locale],
);
// A heading where a month starts and a separator where a week starts,
// then the days.
const sections = React.useMemo<DaySection[]>(() => {
let lastMonth = '';
let lastWeek = '';
return buildAgendaDays(index, loaded, displayNow()).map((day) => {
const month = format(day.date, 'yyyy-MM');
const week = dayKey(startOfWeek(day.date, { weekStartsOn }));
const monthTitle = month !== lastMonth ? format(day.date, 'LLLL yyyy', { locale }) : null;
const weekTitle = week !== lastWeek ? formatWeekRange(day.date, weekStartsOn, locale) : null;
lastMonth = month;
lastWeek = week;
return { ...day, key: dayKey(day.date), monthTitle, weekTitle };
});
}, [index, loaded, weekStartsOn, locale]);
const listRef = React.useRef<SectionList<CalendarEvent, DaySection>>(null);
const sectionsRef = React.useRef(sections);
@@ -297,7 +308,7 @@ export function AgendaView({
ref={listRef}
sections={sections}
keyExtractor={(item, i) => `${item.id}:${i}`}
stickySectionHeadersEnabled
stickySectionHeadersEnabled={false}
// Earlier days are inserted above what the user is reading without
// moving it.
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
@@ -314,32 +325,42 @@ export function AgendaView({
initialNumToRender={12}
maxToRenderPerBatch={12}
windowSize={11}
renderSectionHeader={({ section }) => {
const today = isDisplayToday(section.date);
return (
<View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, today && styles.sectionTitleToday]}>
{section.title}
</Text>
<Text style={styles.sectionSub}>{format(section.date, 'PP', { locale })}</Text>
renderSectionHeader={({ section }) =>
section.monthTitle || section.weekTitle ? (
<View>
{section.monthTitle && <Text style={styles.monthTitle}>{section.monthTitle}</Text>}
{section.weekTitle && <Text style={styles.weekTitle}>{section.weekTitle}</Text>}
</View>
) : null
}
renderItem={({ item, index: i, section }) => (
<View style={[styles.row, i === section.data.length - 1 && styles.rowLast]}>
<DateColumn date={section.date} show={i === 0} locale={locale} styles={styles} />
<View style={styles.eventCol}>
<EventCard
event={item}
calendars={calendars}
timeFormat={timeFormat}
currentUserEmails={currentUserEmails}
onPress={onSelectEvent}
day={section.date}
/>
</View>
);
}}
renderItem={({ item }) => (
<View style={styles.itemWrap}>
<EventCard
event={item}
calendars={calendars}
timeFormat={timeFormat}
currentUserEmails={currentUserEmails}
onPress={onSelectEvent}
/>
</View>
)}
renderSectionFooter={({ section }) =>
section.data.length === 0 ? (
<View style={styles.emptyDayWrap}>
<Text style={styles.emptyDayText}>{t('calendar.events.no_events', 'No events')}</Text>
<View style={[styles.row, styles.rowLast]}>
<DateColumn date={section.date} show locale={locale} styles={styles} />
<Pressable
style={styles.emptyDay}
onPress={onCreateAt ? () => onCreateAt(section.date) : undefined}
accessibilityRole={onCreateAt ? 'button' : undefined}
>
<Text style={styles.emptyDayText}>
{t('calendar.events.nothing_planned', 'Nothing planned. Tap to create.')}
</Text>
</Pressable>
</View>
) : null
}
@@ -347,24 +368,79 @@ export function AgendaView({
);
}
type AgendaStyles = ReturnType<typeof makeStyles>;
// The day on the left of its first row: weekday over the day number, today
// in a filled circle.
function DateColumn({
date,
show,
locale,
styles,
}: {
date: Date;
show: boolean;
locale: Locale;
styles: AgendaStyles;
}) {
if (!show) return <View style={styles.dateCol} />;
const today = isDisplayToday(date);
return (
<View style={styles.dateCol}>
<Text style={[styles.dateWeekday, today && styles.dateWeekdayToday]}>
{format(date, 'EEE', { locale })}
</Text>
<View style={[styles.dateNumber, today && styles.dateNumberToday]}>
<Text style={[styles.dateNumberText, today && styles.dateNumberTextToday]}>
{format(date, 'd')}
</Text>
</View>
</View>
);
}
const DATE_COL_WIDTH = 52;
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
sectionHeader: {
flexDirection: 'row',
alignItems: 'baseline',
gap: spacing.sm,
monthTitle: {
...typography.h2,
fontWeight: '500',
color: c.text,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
backgroundColor: c.surface,
borderBottomWidth: 1,
borderBottomColor: c.border,
paddingTop: spacing.xxl,
paddingBottom: spacing.sm,
},
sectionTitle: { ...typography.bodyMedium, color: c.text },
sectionTitleToday: { color: c.primary },
sectionSub: { ...typography.caption, color: c.textMuted },
itemWrap: { paddingHorizontal: spacing.lg, paddingTop: spacing.sm },
emptyDayWrap: { paddingHorizontal: spacing.lg, paddingVertical: spacing.md },
emptyDayText: { ...typography.caption, color: c.textMuted, textAlign: 'center' },
weekTitle: {
...typography.caption,
color: c.textMuted,
paddingLeft: DATE_COL_WIDTH + spacing.sm,
paddingTop: spacing.md,
paddingBottom: spacing.xs,
},
row: {
flexDirection: 'row',
alignItems: 'flex-start',
paddingRight: spacing.lg,
paddingLeft: spacing.sm,
},
rowLast: { paddingBottom: spacing.sm },
dateCol: { width: DATE_COL_WIDTH, alignItems: 'center' },
dateWeekday: { ...typography.captionMedium, color: c.textSecondary },
dateWeekdayToday: { color: c.primary },
dateNumber: {
width: 34,
height: 34,
borderRadius: 17,
alignItems: 'center',
justifyContent: 'center',
},
dateNumberToday: { backgroundColor: c.primary },
dateNumberText: { fontSize: 20, lineHeight: 26, fontWeight: '500', color: c.text },
dateNumberTextToday: { color: c.primaryForeground },
eventCol: { flex: 1, marginLeft: spacing.md },
emptyDay: { flex: 1, marginLeft: spacing.md, paddingTop: 18 },
emptyDayText: { ...typography.body, color: c.textSecondary },
emptyWrap: { paddingVertical: spacing.xl, alignItems: 'center' },
edge: {
paddingHorizontal: spacing.lg,
@@ -10,13 +10,15 @@ import {
TouchableWithoutFeedback,
View,
} from 'react-native';
import { Check } from 'lucide-react-native';
import { Check } from '../icons';
import type { Calendar } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { CALENDAR_COLOR_PALETTE, calendarColorName, getCalendarColor } from '../../lib/calendar-utils';
import Button from '../Button';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
export interface CalendarEditValues {
name: string;
@@ -35,6 +37,8 @@ interface CalendarEditSheetProps {
// Create / rename / recolour / describe a calendar. The same fields the
// webmail's calendar-management settings offer, as a bottom sheet.
export function CalendarEditSheet({ visible, calendar, onSave, onClose }: CalendarEditSheetProps) {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
@@ -69,9 +73,9 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
};
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.backdrop}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback>
<View style={styles.sheet}>
<Text style={styles.title}>
+157
View File
@@ -0,0 +1,157 @@
import React from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Animated, Easing, Pressable, StyleSheet, Text, View } from 'react-native';
import { CalendarPlus, CircleCheck, Plus } from '../icons';
import { componentSizes, radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useAnimDuration } from '../../theme/dynamic';
import { useLocaleStore } from '../../stores/locale-store';
interface CalendarFabProps {
onNewEvent: () => void;
/** With tasks turned on, the button first offers "Event" and "Task". */
onNewTask?: () => void;
}
/**
* The floating create button in the corner of the calendar, styled like the
* mail list's compose button. Without tasks it creates an event at once;
* with tasks it opens a small menu over a scrim.
*/
export function CalendarFab({ onNewEvent, onNewTask }: CalendarFabProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const [open, setOpen] = React.useState(false);
const progress = React.useRef(new Animated.Value(0)).current;
const duration = useAnimDuration(160);
React.useEffect(() => {
Animated.timing(progress, {
toValue: open ? 1 : 0,
duration,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}).start();
}, [open, progress, duration]);
const pick = (action: () => void) => {
setOpen(false);
action();
};
const insets = useSafeAreaInsets();
const onMainPress = () => {
if (!onNewTask) {
onNewEvent();
return;
}
if (open) pick(onNewEvent);
else setOpen(true);
};
const rotate = progress.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '90deg'] });
const lift = progress.interpolate({ inputRange: [0, 1], outputRange: [12, 0] });
return (
<>
{open && (
<Animated.View style={[styles.scrim, { opacity: progress }]}>
<Pressable
style={StyleSheet.absoluteFill}
onPress={() => setOpen(false)}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
/>
</Animated.View>
)}
{/* Clear of a side inset (camera cutout, navigation bar) in landscape. */}
<View style={[styles.anchor, { right: spacing.lg + insets.right }]} pointerEvents="box-none">
{open && onNewTask && (
<Animated.View style={[styles.option, { opacity: progress, transform: [{ translateY: lift }] }]}>
<Text style={styles.optionLabel}>{t('calendar.fab.task', 'Task')}</Text>
<Pressable
style={styles.miniFab}
onPress={() => pick(onNewTask)}
accessibilityRole="button"
accessibilityLabel={t('calendar.events.new_task', 'New task')}
>
<CircleCheck size={20} color={c.text} />
</Pressable>
</Animated.View>
)}
<View style={styles.mainRow}>
{open && (
<Animated.Text style={[styles.optionLabel, { opacity: progress }]}>
{t('calendar.fab.event', 'Event')}
</Animated.Text>
)}
<Pressable
style={({ pressed }) => [styles.fab, pressed && styles.fabPressed]}
onPress={onMainPress}
accessibilityRole="button"
accessibilityLabel={t('calendar.events.new_event', 'New event')}
>
{open ? (
<CalendarPlus size={24} color={c.background} />
) : (
<Animated.View style={{ transform: [{ rotate }] }}>
<Plus size={26} color={c.background} />
</Animated.View>
)}
</Pressable>
</View>
</View>
</>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
scrim: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.55)',
zIndex: 20,
},
anchor: {
position: 'absolute',
right: spacing.lg,
bottom: spacing.lg,
alignItems: 'flex-end',
gap: spacing.lg,
zIndex: 21,
},
option: { flexDirection: 'row', alignItems: 'center', gap: spacing.md, marginRight: 8 },
mainRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.md },
optionLabel: { ...typography.baseMedium, color: '#ffffff' },
miniFab: {
width: 40,
height: 40,
borderRadius: radius.full,
backgroundColor: c.surface,
alignItems: 'center',
justifyContent: 'center',
elevation: 3,
shadowColor: '#000',
shadowOpacity: 0.2,
shadowRadius: 4,
shadowOffset: { width: 0, height: 2 },
},
// Same as the compose button on the mail list (EmailListScreen).
fab: {
width: componentSizes.fab,
height: componentSizes.fab,
borderRadius: radius.full,
backgroundColor: c.text,
alignItems: 'center',
justifyContent: 'center',
elevation: 6,
shadowColor: '#000',
shadowOpacity: 0.25,
shadowRadius: 6,
shadowOffset: { width: 0, height: 3 },
},
fabPressed: { opacity: 0.85 },
});
}
@@ -11,12 +11,14 @@ import {
TouchableWithoutFeedback,
View,
} from 'react-native';
import { Trash2, UserPlus, Users } from 'lucide-react-native';
import { Trash2, UserPlus, Users } from '../icons';
import { getPrincipals, ownPrincipalId } from '../../api/files';
import type { Calendar, CalendarRights, Principal } from '../../api/types';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
type RolePreset = 'freeBusy' | 'read' | 'readWrite' | 'manager';
@@ -69,6 +71,8 @@ interface CalendarShareSheetProps {
// principal list comes from the same Principal/query the Files share sheet
// uses.
export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShareSheetProps) {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
@@ -155,9 +159,9 @@ export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShare
};
return (
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.backdrop}>
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
<TouchableWithoutFeedback>
<View style={styles.sheet}>
<View style={styles.titleRow}>
@@ -12,8 +12,9 @@ import {
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import {
Check, X, Upload, Rss, Shuffle, Star, Plus, Pencil, Share2, Eraser, Trash2,
} from 'lucide-react-native';
Check, X, Upload, Rss, Shuffle, Star, Plus, Pencil, Share2, Eraser, Trash2, RefreshCw, ArrowUp, ArrowDown,
type AppIcon,
} from '../icons';
import type { Calendar } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
@@ -22,9 +23,21 @@ import { CALENDAR_COLOR_PALETTE, calendarColorName, getCalendarColor } from '../
import { BIRTHDAY_CALENDAR_ID } from '../../lib/birthday-calendar';
import { isWritableCalendar } from '../../lib/calendar-editability';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
/** A calendar view listed at the top of the drawer. */
export interface DrawerViewOption {
key: string;
label: string;
Icon: AppIcon;
}
interface CalendarSidebarDrawerProps {
visible: boolean;
views?: DrawerViewOption[];
activeView?: string;
onSelectView?: (key: string) => void;
onRefresh?: () => void;
calendars: Calendar[];
hiddenCalendarIds: string[];
onToggle: (id: string) => void;
@@ -42,12 +55,19 @@ interface CalendarSidebarDrawerProps {
onShare?: (calendar: Calendar) => void;
onClear?: (calendar: Calendar) => void;
onDelete?: (calendar: Calendar) => void;
// Move one of the user's own calendars next to `targetId`, a neighbour in
// the same section (before it when moving up, after it when moving down).
onMove?: (calendar: Calendar, targetId: string) => void;
// Client-side iCal subscriptions are managed from the subscriptions sheet.
isSubscriptionCalendar?: (calendarId: string) => boolean;
}
export function CalendarSidebarDrawer({
visible,
views,
activeView,
onSelectView,
onRefresh,
calendars,
hiddenCalendarIds,
onToggle,
@@ -62,6 +82,7 @@ export function CalendarSidebarDrawer({
onShare,
onClear,
onDelete,
onMove,
isSubscriptionCalendar,
}: CalendarSidebarDrawerProps) {
const c = useColors();
@@ -131,11 +152,12 @@ export function CalendarSidebarDrawer({
onShare,
onClear,
onDelete,
onMove,
isSubscriptionCalendar,
};
return (
<Modal
<Modal supportedOrientations={MODAL_ORIENTATIONS}
visible={visible}
transparent
animationType="none"
@@ -161,10 +183,44 @@ export function CalendarSidebarDrawer({
>
<X size={20} color={c.text} />
</Pressable>
<Text style={styles.headerTitle}>{t('calendar.my_calendars', 'Calendars')}</Text>
<Text style={styles.headerTitle}>{t('calendar.title', 'Calendar')}</Text>
</View>
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
{views && views.length > 0 && (
<View style={styles.viewsSection}>
{views.map(({ key, label, Icon }) => {
const active = key === activeView;
return (
<Pressable
key={key}
onPress={() => onSelectView?.(key)}
style={({ pressed }) => [
styles.viewRow,
active && styles.viewRowActive,
pressed && !active && styles.rowPressed,
]}
accessibilityRole="button"
accessibilityState={{ selected: active }}
>
<Icon size={16} color={active ? c.text : c.textSecondary} />
<Text style={[styles.viewText, active && styles.viewTextActive]}>{label}</Text>
</Pressable>
);
})}
{onRefresh && (
<Pressable
onPress={onRefresh}
style={({ pressed }) => [styles.viewRow, styles.refreshRow, pressed && styles.rowPressed]}
accessibilityRole="button"
>
<RefreshCw size={16} color={c.textSecondary} />
<Text style={styles.viewText}>{t('common.refresh', 'Refresh')}</Text>
</Pressable>
)}
</View>
)}
{calendars.length === 0 && (
<Text style={styles.empty}>{t('calendar.drawer.no_calendars', 'No calendars yet.')}</Text>
)}
@@ -233,6 +289,7 @@ function Section({
onShare,
onClear,
onDelete,
onMove,
isSubscriptionCalendar,
}: {
title: string;
@@ -248,11 +305,14 @@ function Section({
onShare?: (calendar: Calendar) => void;
onClear?: (calendar: Calendar) => void;
onDelete?: (calendar: Calendar) => void;
onMove?: (calendar: Calendar, targetId: string) => void;
isSubscriptionCalendar?: (calendarId: string) => boolean;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
// Neighbours a calendar can move past: the server calendars of this section.
const orderable = calendars.filter((cal) => cal.id !== BIRTHDAY_CALENDAR_ID);
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{title}</Text>
@@ -269,7 +329,11 @@ function Section({
const canClear = !!onClear && isOwn && isWritableCalendar(cal);
// Like webmail: the default calendar and shared calendars can't be deleted.
const canDelete = !!onDelete && isOwn && !cal.isDefault && (!cal.myRights || cal.myRights.mayDelete !== false);
const hasActions = canSetDefault || canRecolor || canRename || canShare || canClear || canDelete;
const orderIndex = orderable.indexOf(cal);
const previousId = isOwn && orderIndex > 0 ? orderable[orderIndex - 1].id : undefined;
const nextId = isOwn && orderIndex >= 0 ? orderable[orderIndex + 1]?.id : undefined;
const canMove = !!onMove && (!!previousId || !!nextId);
const hasActions = canSetDefault || canRecolor || canRename || canShare || canClear || canDelete || canMove;
const expanded = expandedId === cal.id && hasActions;
return (
<View key={cal.id}>
@@ -304,6 +368,27 @@ function Section({
{expanded && (
<View style={styles.actionsPanel}>
{/* The panel stays open so a calendar can be moved several places. */}
{canMove && previousId && (
<Pressable
onPress={() => onMove!(cal, previousId)}
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
accessibilityRole="button"
>
<ArrowUp size={16} color={c.textSecondary} />
<Text style={styles.panelRowText}>{t('settings.account.accounts.move_up', 'Move up')}</Text>
</Pressable>
)}
{canMove && nextId && (
<Pressable
onPress={() => onMove!(cal, nextId)}
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
accessibilityRole="button"
>
<ArrowDown size={16} color={c.textSecondary} />
<Text style={styles.panelRowText}>{t('settings.account.accounts.move_down', 'Move down')}</Text>
</Pressable>
)}
{canSetDefault && (
<Pressable
onPress={() => { onExpand(null); onSetDefault!(cal); }}
@@ -431,6 +516,27 @@ function makeStyles(c: ThemePalette) {
headerTitle: { ...typography.h3, color: c.text },
scroll: { flex: 1 },
viewsSection: {
paddingTop: spacing.sm,
paddingBottom: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.border,
},
// Same rows as the mail drawer's folders (SidebarDrawer).
viewRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.lg,
minHeight: 44,
borderLeftWidth: 2,
borderLeftColor: 'transparent',
},
viewRowActive: { backgroundColor: c.accent, borderLeftColor: c.primary },
refreshRow: { marginTop: spacing.xs },
viewText: { ...typography.body, color: c.text },
viewTextActive: { ...typography.bodySemibold },
scrollContent: { paddingBottom: spacing.lg },
empty: {
+10 -5
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Clock, MapPin, Users } from 'lucide-react-native';
import { Clock, MapPin, Users } from '../icons';
import { format } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
@@ -8,6 +8,7 @@ import { useColors } from '../../theme/colors';
import {
eventTimeRange,
getEventColor,
isTimedEventFullDayOnDate,
timePattern,
type TimeFormat,
} from '../../lib/calendar-utils';
@@ -22,6 +23,8 @@ interface EventCardProps {
currentUserEmails?: string[];
onPress?: (event: CalendarEvent) => void;
onLongPress?: (event: CalendarEvent) => void;
/** The day the row is listed under: a timed event filling it reads as all day. */
day?: Date;
}
function participantCount(event: CalendarEvent): number {
@@ -33,12 +36,14 @@ function participantCount(event: CalendarEvent): number {
* colour before the title, no card and no bar. Declined and cancelled events
* get a hollow dot and a struck-through, muted title.
*/
export function EventCard({ event, calendars, timeFormat, currentUserEmails, onPress, onLongPress }: EventCardProps) {
export function EventCard({ event, calendars, timeFormat, currentUserEmails, onPress, onLongPress, day }: EventCardProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const { locale, t } = useCalendarLocale();
const color = getEventColor(event, calendars);
const { start, end, allDay } = eventTimeRange(event);
const range = eventTimeRange(event);
const { start, end } = range;
const allDay = range.allDay || (!!day && isTimedEventFullDayOnDate(event, day));
const fmt = timePattern(timeFormat);
const time = allDay
? t('calendar.events.all_day', 'All day')
@@ -59,7 +64,7 @@ export function EventCard({ event, calendars, timeFormat, currentUserEmails, onP
<Text style={[styles.title, inactive && styles.titleInactive]} numberOfLines={1}>
{event.title || t('calendar.events.no_title', '(No title)')}
</Text>
{event.showWithoutTime && (
{allDay && (
<View style={styles.allDayBadge}>
<Text style={styles.allDayText}>{t('calendar.events.all_day', 'All day')}</Text>
</View>
@@ -70,7 +75,7 @@ export function EventCard({ event, calendars, timeFormat, currentUserEmails, onP
</View>
)}
</View>
{!event.showWithoutTime && (
{!allDay && (
<View style={styles.detailRow}>
<Clock size={12} color={c.textMuted} />
<Text style={styles.detailText}>{time}</Text>
+66 -14
View File
@@ -4,14 +4,19 @@ import {
Dimensions,
Easing,
Modal,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import * as Clipboard from 'expo-clipboard';
import { openExternalUrl } from '../../lib/open-url';
import { toast } from '../../stores/toast-store';
import { ToastHost } from '../ToastHost';
import { splitTextLinks } from '../../lib/linkify-text';
import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from '../../lib/event-links';
import { SafeAreaView } from 'react-native-safe-area-context';
import {
AlignLeft,
@@ -30,7 +35,7 @@ import {
Users,
Video,
X,
} from 'lucide-react-native';
} from '../icons';
import { format, type Locale } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
@@ -52,9 +57,12 @@ import {
import { getEventEditability } from '../../lib/calendar-editability';
import { useContactNameResolver } from '../../lib/contact-name-resolver';
import { useCalendarLocale } from '../../lib/calendar-locale';
import { formatCalendarDate } from '../../lib/date-format';
import { useSheetDrag } from '../../lib/use-sheet-drag';
import { recurrenceEndLabel, recurrenceIntervalLabel } from '../../lib/recurrence';
import type { TranslateFn } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
type RsvpStatus = 'accepted' | 'declined' | 'tentative';
@@ -88,9 +96,9 @@ function formatRange(event: CalendarEvent, timeFormat: TimeFormat | undefined, l
start.getMonth() === end.getMonth() &&
start.getDate() === end.getDate()
) {
return format(start, 'EEEE, MMM d, yyyy', opts);
return formatCalendarDate(start, locale.code, 'longWeekdayMonthDayYear');
}
return `${format(start, 'MMM d', opts)} – ${format(end, 'MMM d, yyyy', opts)}`;
return `${formatCalendarDate(start, locale.code, 'monthDay')} – ${formatCalendarDate(end, locale.code, 'monthDayYear')}`;
}
const tp = timePattern(timeFormat);
const sameDay =
@@ -98,9 +106,9 @@ function formatRange(event: CalendarEvent, timeFormat: TimeFormat | undefined, l
start.getMonth() === end.getMonth() &&
start.getDate() === end.getDate();
if (sameDay) {
return `${format(start, 'EEE, MMM d', opts)} · ${format(start, tp, opts)} – ${format(end, tp, opts)}`;
return `${formatCalendarDate(start, locale.code, 'weekdayMonthDay')} · ${format(start, tp, opts)} – ${format(end, tp, opts)}`;
}
return `${format(start, `MMM d, ${tp}`, opts)} – ${format(end, `MMM d, ${tp}`, opts)}`;
return `${formatCalendarDate(start, locale.code, 'monthDay')}, ${format(start, tp, opts)} – ${formatCalendarDate(end, locale.code, 'monthDay')}, ${format(end, tp, opts)}`;
}
function recurrenceLabel(event: CalendarEvent, t: TranslateFn, locale: Locale): string | null {
@@ -135,6 +143,8 @@ export function EventDetailSheet({
// name) render with the contact card's name instead of the raw email.
const resolveContactName = useContactNameResolver();
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const visible = !!event;
const dragHandlers = useSheetDrag({
@@ -184,8 +194,11 @@ export function EventDetailSheet({
const recurrence = recurrenceLabel(event, t, locale);
const reminders = alertsToReminders(event.alerts);
const participants = getParticipantList(event, { resolveName: resolveContactName });
const location = event.locations ? Object.values(event.locations)[0]?.name : undefined;
const videoUri = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri : undefined;
const location = primaryLocationName(event);
// Invitations (Teams above all) leave virtualLocations empty and bury the
// join URL in the description; surface it as the meeting link anyway.
const meeting = findMeetingLink(event);
const videoUri = meeting?.uri;
const isCancelled = event.status === 'cancelled';
// Can the signed-in user RSVP? Only when they appear as a non-organizer
@@ -207,6 +220,23 @@ export function EventDetailSheet({
);
const myStatus = myParticipantId ? event.participants?.[myParticipantId]?.participationStatus : undefined;
const openLocation = () => {
if (!location) return;
const action = locationAction(location, meeting);
if (action.kind === 'url') {
void openExternalUrl(action.uri, { confirm: true });
} else {
void openExternalUrl(mapsUrl(action.query, Platform.OS));
}
};
const copyLocation = () => {
if (!location) return;
void Clipboard.setStringAsync(location).then(() => {
toast.success(t('notifications.copied_to_clipboard', 'Copied to clipboard'));
}).catch(() => {});
};
const doRsvp = async (status: RsvpStatus) => {
if (!onRsvp || !myParticipantId || rsvpBusy) return;
setRsvpBusy(true);
@@ -218,7 +248,7 @@ export function EventDetailSheet({
};
return (
<Modal
<Modal supportedOrientations={MODAL_ORIENTATIONS}
visible={visible}
transparent
animationType="none"
@@ -229,7 +259,7 @@ export function EventDetailSheet({
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }], ...sheetInsets }]}>
<SafeAreaView edges={['bottom']} style={styles.sheetSafe}>
<View {...dragHandlers}>
<View style={styles.handleHit}>
@@ -247,7 +277,7 @@ export function EventDetailSheet({
<Pressable
onPress={onClose}
style={styles.closeBtn}
hitSlop={8}
hitSlop={10}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
@@ -263,7 +293,21 @@ export function EventDetailSheet({
>
<DetailRow icon={<Clock size={16} color={c.textMuted} />} text={range} />
{location ? (
<DetailRow icon={<MapPin size={16} color={c.textMuted} />} text={location} />
<View style={styles.detailRow}>
<View style={styles.detailIcon}>
<MapPin size={16} color={c.textMuted} />
</View>
<Text
style={[styles.detailText, styles.detailLink]}
numberOfLines={3}
onPress={openLocation}
onLongPress={copyLocation}
accessibilityRole="link"
accessibilityHint={t('calendar.detail.location_hint', 'Opens the location; long-press to copy it')}
>
{location}
</Text>
</View>
) : null}
{videoUri ? (
<View style={styles.detailRow}>
@@ -274,7 +318,10 @@ export function EventDetailSheet({
style={styles.joinBtn}
onPress={() => { void openExternalUrl(videoUri, { confirm: true }); }}
>
<Text style={styles.joinBtnText}>{t('calendar.detail.open_link', 'Open link')}</Text>
<Text style={styles.joinBtnText}>
{t('calendar.detail.open_link', 'Open link')}
{meeting?.provider ? ` · ${meeting.provider}` : ''}
</Text>
</Pressable>
</View>
) : null}
@@ -414,6 +461,9 @@ export function EventDetailSheet({
)}
</SafeAreaView>
</Animated.View>
{/* The screen's own toast host sits under this Modal; this one shows
the copy confirmation above the sheet. */}
<ToastHost />
</Modal>
);
}
@@ -568,7 +618,9 @@ function makeStyles(c: ThemePalette) {
borderTopWidth: 1,
borderTopColor: c.border,
},
sheetSafe: { paddingTop: spacing.sm },
// The body shrinks to what is left under the sheet's max height, so the
// action row stays on screen in a short (landscape) window.
sheetSafe: { paddingTop: spacing.sm, flexShrink: 1 },
handleHit: {
alignItems: 'center',
paddingTop: spacing.xs,
@@ -595,7 +647,7 @@ function makeStyles(c: ThemePalette) {
subtitle: { ...typography.caption, color: c.textMuted, marginTop: 2 },
closeBtn: { padding: 4 },
body: { maxHeight: 400 },
body: { flexShrink: 1 },
bodyContent: {
paddingHorizontal: spacing.lg,
paddingBottom: spacing.lg,
+30 -23
View File
@@ -7,8 +7,8 @@ import {
TextInput,
Pressable,
StyleSheet,
Switch,
Platform,
Alert as NativeAlert,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import DateTimePicker from '@react-native-community/datetimepicker';
@@ -24,7 +24,7 @@ import {
Users,
Trash2,
AlignLeft,
} from 'lucide-react-native';
} from '../icons';
import { addHours, addMinutes, format } from 'date-fns';
import type {
Alert,
@@ -46,6 +46,7 @@ import { editorTimesFromEvent, eventTimeFieldsToSave } from '../../lib/event-edi
import { useSettingsStore } from '../../stores/settings-store';
import { canCreateEventsIn } from '../../lib/calendar-editability';
import { useCalendarLocale } from '../../lib/calendar-locale';
import { formatCalendarDate } from '../../lib/date-format';
import {
alertsToReminders,
remindersToAlerts,
@@ -67,6 +68,8 @@ import { Button } from '..';
import { ParticipantInput } from './ParticipantInput';
import { useContactNameResolver } from '../../lib/contact-name-resolver';
import { RecurrenceEditor } from './RecurrenceEditor';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import ToggleSwitch from '../ToggleSwitch';
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
@@ -99,9 +102,11 @@ interface EventModalProps {
onClose: () => void;
}
// A time already on the hour or half hour (a tapped slot) stays as it is.
function nextHalfHour(d: Date): Date {
const date = new Date(d);
const min = date.getMinutes();
if (min % 30 === 0 && date.getSeconds() === 0 && date.getMilliseconds() === 0) return date;
const add = min < 30 ? 30 - min : 60 - min;
date.setMinutes(date.getMinutes() + add, 0, 0);
return date;
@@ -361,6 +366,12 @@ export function EventModal({
sendSchedulingMessages: attendees.length > 0 && sendInvitations,
});
onClose();
} catch (err) {
// The editor stays open with everything typed, and says why.
NativeAlert.alert(
t('calendar.notifications.event_error', 'Failed to save event'),
err instanceof Error ? err.message : undefined,
);
} finally {
setSaving(false);
}
@@ -408,7 +419,7 @@ export function EventModal({
);
return (
<Modal
<Modal supportedOrientations={MODAL_ORIENTATIONS}
visible={visible}
animationType="slide"
presentationStyle="fullScreen"
@@ -448,7 +459,7 @@ export function EventModal({
value={title}
onChangeText={setTitle}
placeholder={t('calendar.form.title', 'Title')}
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
style={styles.titleInput}
/>
@@ -504,12 +515,10 @@ export function EventModal({
<Section icon={<Clock size={16} color={c.textMuted} />} label={t('calendar.form.time', 'Time')}>
<View style={styles.allDayRow}>
<Text style={styles.fieldText}>{t('calendar.form.all_day_event', 'All-day event')}</Text>
<Switch
<ToggleSwitch
value={allDay}
onValueChange={setAllDay}
accessibilityLabel={t('calendar.form.all_day_event', 'All-day event')}
thumbColor={allDay ? c.primary : c.textMuted}
trackColor={{ false: c.surface, true: c.primaryBg }}
/>
</View>
@@ -520,7 +529,7 @@ export function EventModal({
style={styles.dateBtn}
onPress={() => setShowStartDate(true)}
>
<Text style={styles.dateText}>{format(start, 'EEE, MMM d', { locale })}</Text>
<Text style={styles.dateText}>{formatCalendarDate(start, locale.code, 'weekdayMonthDay')}</Text>
</Pressable>
{!allDay && (
<Pressable
@@ -540,7 +549,7 @@ export function EventModal({
style={styles.dateBtn}
onPress={() => setShowEndDate(true)}
>
<Text style={styles.dateText}>{format(end, 'EEE, MMM d', { locale })}</Text>
<Text style={styles.dateText}>{formatCalendarDate(end, locale.code, 'weekdayMonthDay')}</Text>
</Pressable>
{!allDay && (
<Pressable
@@ -662,7 +671,7 @@ export function EventModal({
keyboardType="number-pad"
style={styles.customAmountInput}
placeholder="30"
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
/>
<View style={styles.customUnits}>
{(['minutes', 'hours', 'days', 'weeks'] as ReminderUnit[]).map((unit) => (
@@ -697,7 +706,7 @@ export function EventModal({
value={location}
onChangeText={setLocation}
placeholder={t('calendar.form.location_placeholder', 'Place or address')}
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
style={styles.fieldButton}
/>
</Section>
@@ -707,7 +716,7 @@ export function EventModal({
value={videoUrl}
onChangeText={setVideoUrl}
placeholder="https://meet.example.com/…"
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
@@ -723,7 +732,7 @@ export function EventModal({
value={description}
onChangeText={setDescription}
placeholder={t('calendar.form.description_placeholder', 'Notes, agenda…')}
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
multiline
style={styles.descriptionInput}
/>
@@ -745,12 +754,10 @@ export function EventModal({
<Text style={[styles.fieldText, { flex: 1 }]}>
{t('calendar.participants.send_invitations', 'Send invitations to participants')}
</Text>
<Switch
<ToggleSwitch
value={sendInvitations}
onValueChange={setSendInvitations}
accessibilityLabel={t('calendar.participants.send_invitations', 'Send invitations to participants')}
thumbColor={sendInvitations ? c.primary : c.textMuted}
trackColor={{ false: c.surface, true: c.primaryBg }}
/>
</View>
)}
@@ -902,7 +909,7 @@ function makeStyles(c: ThemePalette) {
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
backgroundColor: c.background,
justifyContent: 'center',
},
fieldText: { ...typography.body, color: c.text },
@@ -919,7 +926,7 @@ function makeStyles(c: ThemePalette) {
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
backgroundColor: c.background,
},
reminderRemove: { padding: 2 },
customReminderRow: {
@@ -928,7 +935,7 @@ function makeStyles(c: ThemePalette) {
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: c.surface,
backgroundColor: c.background,
},
customAmountInput: {
...typography.body,
@@ -964,7 +971,7 @@ function makeStyles(c: ThemePalette) {
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: c.surface,
backgroundColor: c.popover,
overflow: 'hidden',
},
popoverRow: {
@@ -986,7 +993,7 @@ function makeStyles(c: ThemePalette) {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.sm,
backgroundColor: c.surface,
backgroundColor: c.background,
borderWidth: 1,
borderColor: c.border,
},
@@ -998,7 +1005,7 @@ function makeStyles(c: ThemePalette) {
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
backgroundColor: c.background,
},
dateLabel: { ...typography.body, color: c.textSecondary, flex: 1 },
datePickers: { flexDirection: 'row', gap: spacing.sm },
@@ -1019,7 +1026,7 @@ function makeStyles(c: ThemePalette) {
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
backgroundColor: c.background,
color: c.text,
...typography.body,
textAlignVertical: 'top',
+7 -3
View File
@@ -3,7 +3,7 @@ import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, Alert, ActivityIndicator,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { Upload, X, Check, CalendarPlus, AlertTriangle, Clock } from 'lucide-react-native';
import { Upload, X, Check, CalendarPlus, AlertTriangle, Clock } from '../icons';
import { format, parseISO } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { uploadBlob } from '../../api/blob';
@@ -17,6 +17,8 @@ import { ImportRefusedError, type ImportResult } from '../../stores/calendar-sto
import { useLocaleStore } from '../../stores/locale-store';
import { useCalendarLocale } from '../../lib/calendar-locale';
import type { Locale } from 'date-fns';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
// expo-document-picker is loaded lazily on first use; its native module is not
// linked into every build (matches the FilesScreen / ContactImportSheet flow).
@@ -74,6 +76,8 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
const [result, setResult] = React.useState<ImportResult | null>(null);
const slideY = React.useRef(new Animated.Value(900)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const resetState = React.useCallback(() => {
@@ -184,11 +188,11 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
};
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }], ...sheetInsets }]}>
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
<View style={styles.header}>
<Text style={styles.title}>{t('calendar.import.title', 'Import Calendar')}</Text>
@@ -1,9 +1,9 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput, ActivityIndicator,
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput, ActivityIndicator, Alert,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { X, Plus, RefreshCw, Trash2, Rss, AlertTriangle } from 'lucide-react-native';
import { X, Plus, RefreshCw, Trash2, Rss, AlertTriangle } from '../icons';
import { formatDistanceToNow } from 'date-fns';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
@@ -17,6 +17,8 @@ import {
type CalendarSubscription,
} from '../../stores/calendar-subscriptions-store';
import { jmapClient } from '../../api/jmap-client';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
// The webmail's refresh intervals and their labels.
const INTERVAL_OPTIONS: { minutes: number; key: string; fallback: string }[] = [
@@ -75,6 +77,8 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
};
const slideY = React.useRef(new Animated.Value(900)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
React.useEffect(() => {
@@ -131,11 +135,11 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
};
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }], ...sheetInsets }]}>
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
<View style={styles.header}>
<Rss size={20} color={c.text} />
@@ -275,7 +279,24 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
)}
</Pressable>
<Pressable
onPress={() => { void removeSubscription(sub.id); }}
// Unsubscribing also deletes the calendar and its events.
onPress={() => Alert.alert(
t('calendar.subscription.unsubscribe', 'Unsubscribe'),
t('calendar.subscription.confirm_delete', 'Unsubscribe from "{name}"? The calendar and all its events will be removed.', { name: sub.name }),
[
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
{
text: t('calendar.subscription.unsubscribe', 'Unsubscribe'),
style: 'destructive',
onPress: () => {
removeSubscription(sub.id).catch((err: unknown) => Alert.alert(
t('calendar.subscription.delete_error', 'Failed to remove subscription'),
err instanceof Error ? err.message : undefined,
));
},
},
],
)}
hitSlop={6}
style={styles.subBtn}
accessibilityRole="button"
+144
View File
@@ -0,0 +1,144 @@
import React from 'react';
import {
FlatList,
ScrollView,
StyleSheet,
View,
useWindowDimensions,
type ListRenderItemInfo,
type NativeScrollEvent,
type NativeSyntheticEvent,
} from 'react-native';
import { addMonths, startOfMonth } from 'date-fns';
import type { Calendar } from '../../api/types';
import type { ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import type { EventDayIndex } from '../../lib/calendar-utils';
import { MONTH_ROW_HEIGHT, MonthView } from './MonthView';
type WeekStart = 0 | 1 | 6;
/** Months either side of the one the panel opens on. */
const MONTHS_EACH_SIDE = 24;
/** Six week rows plus the weekday initials. */
const PANEL_HEIGHT = MONTH_ROW_HEIGHT * 6 + 22;
interface MonthDropdownProps {
/** The month the panel opens on. */
month: Date;
selectedDate: Date;
eventsByDay: EventDayIndex;
calendars: Calendar[];
weekStartsOn: WeekStart;
/** Reports the month swiped to, so the title can follow it. */
onMonthChange: (month: Date) => void;
onSelectDate: (date: Date) => void;
}
/**
* The month that drops down under the calendar title: swipe sideways
* between months, tap a day to go there.
*/
export function MonthDropdown({
month,
selectedDate,
eventsByDay,
calendars,
weekStartsOn,
onMonthChange,
onSelectDate,
}: MonthDropdownProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
// Pages are as wide as the panel (the window, or a pane on a tablet) and
// re-measure on rotation. In a short window the panel takes at most about
// half of it and its month scrolls inside.
const [measuredWidth, setMeasuredWidth] = React.useState(0);
const width = measuredWidth || windowWidth;
const panelHeight = Math.min(PANEL_HEIGHT, Math.round(windowHeight * 0.55));
// The months are fixed while the panel is open; it remounts on each open.
const [months] = React.useState(() => {
const first = addMonths(startOfMonth(month), -MONTHS_EACH_SIDE);
return Array.from({ length: MONTHS_EACH_SIDE * 2 + 1 }, (_, i) => addMonths(first, i));
});
const lastIndexRef = React.useRef(MONTHS_EACH_SIDE);
const handleMomentumEnd = React.useCallback(
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
const index = Math.round(e.nativeEvent.contentOffset.x / width);
if (index === lastIndexRef.current || !months[index]) return;
lastIndexRef.current = index;
onMonthChange(months[index]);
},
[width, months, onMonthChange],
);
const renderItem = React.useCallback(
({ item }: ListRenderItemInfo<Date>) => (
<ScrollView
style={{ width, height: panelHeight }}
scrollEnabled={panelHeight < PANEL_HEIGHT}
showsVerticalScrollIndicator={false}
>
<MonthView
currentDate={item}
selectedDate={selectedDate}
events={[]}
eventsByDay={eventsByDay}
calendars={calendars}
weekStartsOn={weekStartsOn}
onSelectDate={onSelectDate}
/>
</ScrollView>
),
[width, panelHeight, selectedDate, eventsByDay, calendars, weekStartsOn, onSelectDate],
);
const getItemLayout = React.useCallback(
(_: ArrayLike<Date> | null | undefined, i: number) => ({ length: width, offset: width * i, index: i }),
[width],
);
return (
<View
style={styles.panel}
onLayout={(e) => {
const w = Math.round(e.nativeEvent.layout.width);
if (w > 0 && w !== measuredWidth) setMeasuredWidth(w);
}}
>
<FlatList
// A new width remounts the pager on the month it was showing.
key={width}
horizontal
pagingEnabled
data={months}
keyExtractor={monthKey}
renderItem={renderItem}
getItemLayout={getItemLayout}
initialScrollIndex={lastIndexRef.current}
onMomentumScrollEnd={handleMomentumEnd}
showsHorizontalScrollIndicator={false}
initialNumToRender={1}
windowSize={3}
style={{ height: panelHeight }}
/>
</View>
);
}
function monthKey(month: Date): string {
return `${month.getFullYear()}-${month.getMonth()}`;
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
panel: {
backgroundColor: c.background,
borderBottomWidth: 1,
borderBottomColor: c.border,
paddingTop: 4,
},
});
}
+48 -12
View File
@@ -2,6 +2,7 @@ import React from 'react';
import {
FlatList,
View,
type LayoutChangeEvent,
type ListRenderItemInfo,
type NativeScrollEvent,
type NativeSyntheticEvent,
@@ -17,6 +18,7 @@ import type { CalendarFocus, DayRange } from '../../lib/calendar-scroll-window';
import {
dayIndexIn,
monthFocusRow,
monthRowGeometry,
monthKeyOf,
monthMask,
sampledRow,
@@ -26,8 +28,7 @@ import {
import { useCalendarLocale } from '../../lib/calendar-locale';
import { displayNow } from '../../lib/calendar-timezone';
import {
MONTH_ROW_HEIGHT,
MONTH_ROW_HEIGHT_CHIPS,
MONTH_ROW_HEIGHT_FULL_MIN,
MonthWeekRow,
MonthWeekdayHeader,
useMonthStyles,
@@ -117,8 +118,16 @@ function MonthScrollViewInner({
}, [window]);
const weeksRef = React.useRef(weeks);
weeksRef.current = weeks;
const rowHeight = showTimeInMonthView ? MONTH_ROW_HEIGHT_CHIPS : MONTH_ROW_HEIGHT;
const viewportHeight = rowHeight * VISIBLE_ROWS;
// Six weeks fill the space under the weekday initials, like Google
// Calendar's month; rows wait for that space to be measured.
const [listHeight, setListHeight] = React.useState(0);
const handleListLayout = React.useCallback((e: LayoutChangeEvent) => {
const height = e.nativeEvent.layout.height;
if (height > 0) setListHeight(height);
}, []);
// A phone in landscape has room for three or four weeks, not six.
const { rowHeight, visibleRows } = monthRowGeometry(listHeight, MONTH_ROW_HEIGHT_FULL_MIN, VISIBLE_ROWS);
const viewportHeight = listHeight;
// The month in focus: set by navigation, then by what scrolls under the
// sample line.
@@ -127,16 +136,28 @@ function MonthScrollViewInner({
const onVisibleRef = React.useRef(onVisibleDateChange);
onVisibleRef.current = onVisibleDateChange;
const offsetRef = React.useRef(0);
// Row at the top of the viewport; a change of row height (the "show time"
// setting) remounts the list there.
const topRowRef = React.useRef<number | null>(null);
// Week at the top of the viewport; a change of row height (the "show time"
// setting, a rotation) remounts the list there. Kept as the week, not its
// index, because weeks added above shift the indices.
const topWeekRef = React.useRef<string | null>(null);
// A (re)mounted list reports offset 0 before its initial scroll lands; that
// report would set the month to the window's first one (a rotation jumped
// back a month). Until the list reaches its start row, or the user drags,
// scroll reports don't move the month.
const settledRef = React.useRef(false);
const handleScroll = React.useCallback(
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
const offset = e.nativeEvent.contentOffset.y;
if (!settledRef.current) {
const expected = (initialRef.current?.row ?? 0) * rowHeight;
if (Math.abs(offset - expected) > rowHeight / 2) return;
settledRef.current = true;
}
offsetRef.current = offset;
topRowRef.current = Math.round(offset / rowHeight);
const list = weeksRef.current;
const top = list[Math.max(0, Math.min(list.length - 1, Math.round(offset / rowHeight)))];
if (top) topWeekRef.current = dayKey(top[0]);
const row = sampledRow(offset, viewportHeight, rowHeight, list.length);
const days = list[row];
if (!days) return;
@@ -151,15 +172,18 @@ function MonthScrollViewInner({
[viewportHeight, rowHeight],
);
const markSettled = React.useCallback(() => { settledRef.current = true; }, []);
// Navigation inside the window scrolls the focused month's first week to
// the top. The list mounts there already, so its own nonce needs nothing.
const listRef = React.useRef<FlatList<Date[]>>(null);
const handledNonceRef = React.useRef(focus.nonce);
const initialRef = React.useRef<{ rowHeight: number; row: number } | null>(null);
if (initialRef.current === null || initialRef.current.rowHeight !== rowHeight) {
settledRef.current = false;
initialRef.current = {
rowHeight,
row: topRowRef.current ?? monthFocusRow(window, focus.date, weeks.length, opts),
row: rowOfWeek(weeks, topWeekRef.current) ?? monthFocusRow(window, focus.date, weeks.length, opts),
};
}
React.useEffect(() => {
@@ -196,6 +220,7 @@ function MonthScrollViewInner({
timeFormat={timeFormat}
currentUserEmails={currentUserEmails}
labelMonths
variant="full"
height={rowHeight}
locale={locale}
styles={styles}
@@ -232,8 +257,10 @@ function MonthScrollViewInner({
);
return (
<View style={styles.grid}>
<View style={styles.fullGrid}>
<MonthWeekdayHeader weekStartsOn={weekStartsOn} showWeekNumbers={showWeekNumbers} styles={styles} />
<View style={styles.fullRows} onLayout={handleListLayout}>
{listHeight > 0 && (
<FlatList
key={rowHeight}
ref={listRef}
@@ -253,14 +280,17 @@ function MonthScrollViewInner({
onEndReached={onExtendEnd}
onEndReachedThreshold={0.5}
onScroll={handleScroll}
onScrollBeginDrag={markSettled}
scrollEventThrottle={32}
snapToInterval={rowHeight}
decelerationRate="fast"
showsVerticalScrollIndicator={false}
initialNumToRender={VISIBLE_ROWS + 2}
maxToRenderPerBatch={VISIBLE_ROWS}
initialNumToRender={visibleRows + 2}
maxToRenderPerBatch={visibleRows}
windowSize={5}
/>
)}
</View>
</View>
);
}
@@ -270,3 +300,9 @@ export const MonthScrollView = React.memo(MonthScrollViewInner);
function weekKey(days: Date[]): string {
return dayKey(days[0]);
}
function rowOfWeek(weeks: Date[][], key: string | null): number | null {
if (!key) return null;
const i = weeks.findIndex((days) => dayKey(days[0]) === key);
return i >= 0 ? i : null;
}
+209 -5
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { View, Text, StyleSheet, Pressable, useWindowDimensions } from 'react-native';
import {
addDays,
endOfMonth,
@@ -19,11 +19,12 @@ import {
eventsOnDayFromIndex,
getEventColor,
getEventStartDate,
isTimedEventFullDayOnDate,
timePattern,
type EventDayIndex,
type TimeFormat,
} from '../../lib/calendar-utils';
import { dayIndexIn, monthKeyOf, monthMask } from '../../lib/calendar-month-scroll';
import { dayIndexIn, monthChipShowsTime, monthKeyOf, monthMask } from '../../lib/calendar-month-scroll';
import { isInactiveEvent } from '../../lib/calendar-participants';
import { eventBlockColors } from '../../lib/event-colors';
import { useCalendarLocale } from '../../lib/calendar-locale';
@@ -35,6 +36,10 @@ type WeekStart = 0 | 1 | 6;
// scrolling month uses them as fixed row heights.
export const MONTH_ROW_HEIGHT = 54;
export const MONTH_ROW_HEIGHT_CHIPS = 74;
// Smallest week row of the full-screen month grid.
export const MONTH_ROW_HEIGHT_FULL_MIN = 72;
/** Smallest row of the paged month grid, which can't scroll to hidden weeks. */
const MONTH_ROW_HEIGHT_FILL_MIN = 36;
// Corner radius of event bars (repos/branding/APP.md).
const EVENT_RADIUS = 2;
@@ -48,6 +53,8 @@ interface MonthViewProps {
showWeekNumbers?: boolean;
// Render compact event chips (title + start time) instead of dots (#666).
showTimeInMonthView?: boolean;
/** Fill the available height with the full month grid. */
fill?: boolean;
timeFormat?: TimeFormat;
/** The user's addresses, to draw events they declined as inactive. */
currentUserEmails?: string[];
@@ -113,6 +120,12 @@ export interface MonthWeekRowProps {
labelMonths?: boolean;
/** Fixed row height (continuous scrolling); natural height otherwise. */
height?: number;
/**
* 'full': the month fills the screen, with grid
* lines and an event chip per event. 'compact': numbers with event dots
* (the month under the title).
*/
variant?: 'compact' | 'full';
locale: Locale;
styles: MonthStyles;
onSelectDate: (date: Date) => void;
@@ -133,12 +146,38 @@ function MonthWeekRowInner({
currentUserEmails,
labelMonths = false,
height,
variant = 'compact',
locale,
styles,
onSelectDate,
onLongPressDate,
}: MonthWeekRowProps) {
const c = useColors();
const { width } = useWindowDimensions();
const chipTime = monthChipShowsTime(true, width / 7);
if (variant === 'full') {
return (
<FullWeekRow
days={days}
activeMask={activeMask}
todayIndex={todayIndex}
index={index}
calendars={calendars}
weekStartsOn={weekStartsOn}
showWeekNumbers={showWeekNumbers}
showTimeInMonthView={showTimeInMonthView}
timeFormat={timeFormat}
currentUserEmails={currentUserEmails}
labelMonths={labelMonths}
height={height ?? MONTH_ROW_HEIGHT_FULL_MIN}
locale={locale}
styles={styles}
colors={c}
onSelectDate={onSelectDate}
onLongPressDate={onLongPressDate}
/>
);
}
return (
<View style={[styles.weekRow, height !== undefined && { height, overflow: 'hidden' }]}>
{showWeekNumbers && (
@@ -221,7 +260,7 @@ function MonthWeekRowInner({
]}
>
<Text style={[styles.chipText, { color: colors.text }]} numberOfLines={1}>
{event.showWithoutTime
{event.showWithoutTime || !chipTime
? null
: `${format(getEventStartDate(event), timePattern(timeFormat), { locale })} `}
<Text style={inactive && styles.chipTextInactive}>{event.title || ''}</Text>
@@ -243,6 +282,115 @@ function MonthWeekRowInner({
export const MonthWeekRow = React.memo(MonthWeekRowInner);
/** Day number area at the top of a full month cell. */
const FULL_NUMBER_HEIGHT = 24;
/** One event chip in a full month cell, with its gap. */
const FULL_CHIP_HEIGHT = 16;
const FULL_CHIP_GAP = 2;
/** Event chips that fit a full month cell of `height`, keeping a line for "+N" when needed. */
export function fullCellChipCount(height: number, eventCount: number): number {
const room = Math.max(0, Math.floor((height - FULL_NUMBER_HEIGHT - 2) / (FULL_CHIP_HEIGHT + FULL_CHIP_GAP)));
if (eventCount <= room) return eventCount;
return Math.max(0, room - 1);
}
// A week of the full-screen month grid: thin cell lines, the day number
// small at the top (today in a filled circle), then one filled chip per
// event and "+N" for those that don't fit.
function FullWeekRow({
days,
activeMask,
todayIndex,
index,
calendars,
weekStartsOn,
showWeekNumbers,
showTimeInMonthView,
timeFormat,
currentUserEmails,
labelMonths,
height,
locale,
styles,
colors: c,
onSelectDate,
onLongPressDate,
}: Omit<MonthWeekRowProps, 'selectedIndex' | 'variant'> & {
height: number;
colors: ThemePalette;
}) {
const { width } = useWindowDimensions();
const showTime = monthChipShowsTime(showTimeInMonthView, width / 7);
return (
<View style={[styles.fullWeekRow, { height }]}>
{showWeekNumbers && (
<Text style={[styles.weekNumberCell, styles.fullWeekNumber]}>{weekNumberFor(days[0], weekStartsOn)}</Text>
)}
{days.map((d, i) => {
const sameMonth = (activeMask & (1 << i)) !== 0;
const today = i === todayIndex;
const dayEvents = eventsOnDayFromIndex(index, d);
const shown = fullCellChipCount(height, dayEvents.length);
const overflow = dayEvents.length - shown;
const label = labelMonths && d.getDate() === 1 ? format(d, 'MMM d', { locale }) : format(d, 'd');
return (
<Pressable
key={i}
style={({ pressed }) => [styles.fullCell, i === 0 && styles.fullCellFirst, pressed && styles.fullCellPressed]}
onPress={() => onSelectDate(d)}
onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined}
accessibilityRole="button"
accessibilityLabel={format(d, 'PPPP', { locale })}
>
<View style={styles.fullNumberWrap}>
<View style={[styles.fullNumber, today && styles.fullNumberToday]}>
<Text
numberOfLines={1}
style={[
styles.fullNumberText,
!sameMonth && styles.dayTextMuted,
today && styles.fullNumberTextToday,
]}
>
{label}
</Text>
</View>
</View>
{dayEvents.slice(0, shown).map((event, idx) => {
const inactive = isInactiveEvent(event, currentUserEmails);
const colors = eventBlockColors(getEventColor(event, calendars), inactive, c);
// No time on events that fill the whole day.
const time = showTime && !event.showWithoutTime && !isTimedEventFullDayOnDate(event, d)
? `${format(getEventStartDate(event), timePattern(timeFormat), { locale })} `
: '';
return (
<View
key={`${event.id}-${idx}`}
style={[
styles.fullChip,
{ backgroundColor: colors.fill },
colors.border !== null && [styles.chipInactive, { borderColor: colors.border }],
]}
>
<Text
style={[styles.fullChipText, { color: colors.text }, inactive && styles.chipTextInactive]}
numberOfLines={1}
ellipsizeMode="clip"
>
{time}{event.title || ''}
</Text>
</View>
);
})}
{overflow > 0 && <Text style={styles.fullOverflow}>+{overflow}</Text>}
</Pressable>
);
})}
</View>
);
}
function MonthViewInner({
currentDate,
selectedDate,
@@ -252,12 +400,14 @@ function MonthViewInner({
weekStartsOn = 0,
showWeekNumbers = false,
showTimeInMonthView = false,
fill = false,
timeFormat,
currentUserEmails,
onSelectDate,
onLongPressDate,
}: MonthViewProps) {
const styles = useMonthStyles();
const [gridHeight, setGridHeight] = React.useState(0);
const { locale } = useCalendarLocale();
const index = React.useMemo(
() => eventsByDay ?? buildEventDayIndex(events),
@@ -280,12 +430,25 @@ function MonthViewInner({
// Today on a clock in the calendar's time zone.
const today = displayNow();
// Every week of the month has to show (there is no scrolling here), so in a
// short window (phone landscape) rows shrink below the usual minimum and
// their chips collapse into "+N".
const fullRowHeight = fill && gridHeight > 0
? Math.max(MONTH_ROW_HEIGHT_FILL_MIN, Math.floor(gridHeight / rows.length))
: undefined;
return (
<View style={styles.grid}>
<View style={fill ? styles.fullGrid : styles.grid}>
<MonthWeekdayHeader weekStartsOn={weekStartsOn} showWeekNumbers={showWeekNumbers} styles={styles} />
{rows.map((days) => (
<View
style={fill && styles.fullRows}
onLayout={fill ? (e) => setGridHeight(e.nativeEvent.layout.height) : undefined}
>
{(!fill || fullRowHeight !== undefined) && rows.map((days) => (
<MonthWeekRow
key={days[0].toISOString()}
variant={fill ? 'full' : 'compact'}
height={fullRowHeight}
days={days}
activeMask={monthMask(days, activeMonth)}
selectedIndex={dayIndexIn(days, selectedDate)}
@@ -303,6 +466,7 @@ function MonthViewInner({
onLongPressDate={onLongPressDate}
/>
))}
</View>
</View>
);
}
@@ -386,5 +550,45 @@ function makeStyles(c: ThemePalette) {
marginLeft: 1,
textAlign: 'center',
},
fullGrid: { flex: 1 },
fullRows: { flex: 1 },
fullWeekRow: {
flexDirection: 'row',
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: c.border,
},
fullWeekNumber: { paddingTop: 6 },
fullCell: {
flex: 1,
borderLeftWidth: StyleSheet.hairlineWidth,
borderLeftColor: c.border,
paddingHorizontal: 1,
overflow: 'hidden',
},
fullCellFirst: { borderLeftWidth: 0 },
fullCellPressed: { backgroundColor: c.surfaceHover },
fullNumberWrap: { height: FULL_NUMBER_HEIGHT, alignItems: 'center', justifyContent: 'center' },
fullNumber: {
minWidth: 22,
height: 22,
borderRadius: 11,
paddingHorizontal: 4,
alignItems: 'center',
justifyContent: 'center',
},
fullNumberToday: { backgroundColor: c.primary },
fullNumberText: { fontSize: 12, lineHeight: 16, fontWeight: '500', color: c.text },
fullNumberTextToday: { color: c.primaryForeground, fontWeight: '700' },
fullChip: {
height: FULL_CHIP_HEIGHT,
marginBottom: FULL_CHIP_GAP,
borderRadius: EVENT_RADIUS,
paddingHorizontal: 3,
justifyContent: 'center',
},
// 9px text on the phone's month bars (repos/branding/APP.md).
fullChipText: { fontSize: 9, lineHeight: 12, fontWeight: '500' },
fullOverflow: { fontSize: 10, lineHeight: 13, fontWeight: '500', color: c.textSecondary, paddingHorizontal: 3 },
});
}
+4 -1
View File
@@ -7,7 +7,7 @@ import {
TextInput,
ScrollView,
} from 'react-native';
import { X } from 'lucide-react-native';
import { X } from '../icons';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useContactsStore } from '../../stores/contacts-store';
@@ -107,6 +107,9 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu
)}
<TextInput
// No full-screen keyboard in landscape: it would hide the suggestions
// and the text around the field.
disableFullscreenUI
value={draft}
onChangeText={setDraft}
placeholder={t('calendar.event_modal.participant_placeholder', 'Add participant by email')}
+1 -1
View File
@@ -1,7 +1,7 @@
import React from 'react';
import { View, Text, TextInput, Pressable, StyleSheet, Platform } from 'react-native';
import DateTimePicker from '@react-native-community/datetimepicker';
import { ChevronDown } from 'lucide-react-native';
import { ChevronDown } from '../icons';
import { format, parseISO } from 'date-fns';
import type { RecurrenceRule } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
@@ -1,10 +1,11 @@
import React from 'react';
import { Modal, View, Text, Pressable, StyleSheet } from 'react-native';
import { Repeat, Trash2 } from 'lucide-react-native';
import { Repeat, Trash2 } from '../icons';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { Button } from '..';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
export type RecurrenceEditScope = 'this' | 'this_and_future' | 'all';
@@ -42,7 +43,7 @@ export function RecurrenceScopeDialog({
}, [visible]);
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View style={styles.backdrop}>
<View style={styles.dialog}>
<View style={styles.header}>
+19 -11
View File
@@ -24,7 +24,7 @@ import {
Plus,
Trash2,
X,
} from 'lucide-react-native';
} from '../icons';
import { format, isBefore, startOfDay } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
@@ -41,7 +41,10 @@ import {
} from '../../lib/task-editor';
import { isWritableCalendar } from '../../lib/calendar-editability';
import { useCalendarLocale } from '../../lib/calendar-locale';
import { formatCalendarDate } from '../../lib/date-format';
import { useSheetDrag } from '../../lib/use-sheet-drag';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
type TaskFilter = 'all' | 'pending' | 'completed' | 'overdue';
@@ -52,6 +55,8 @@ interface TasksSheetProps {
timeFormat?: TimeFormat;
// Task to open in the editor when the sheet appears (from a calendar chip).
initialTaskId?: string | null;
// Open straight on an empty editor (the calendar's "new task" button).
startNew?: boolean;
onClose: () => void;
onCreate: (task: Partial<CalendarEvent>, calendarId: string) => Promise<void> | void;
onUpdate?: (id: string, changes: Partial<CalendarEvent>) => Promise<void> | void;
@@ -94,6 +99,7 @@ export function TasksSheet({
calendars,
timeFormat,
initialTaskId,
startNew = false,
onClose,
onCreate,
onUpdate,
@@ -104,6 +110,8 @@ export function TasksSheet({
const styles = React.useMemo(() => makeStyles(c), [c]);
const { locale, t } = useCalendarLocale();
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({
slideY,
@@ -132,10 +140,10 @@ export function TasksSheet({
setExpanded(true);
} else {
setEditor(emptyTaskEditor(defaultCalendarId));
setExpanded(false);
setExpanded(startNew);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible, initialTaskId]);
}, [visible, initialTaskId, startNew]);
React.useEffect(() => {
if (visible && !editor.calendarId && defaultCalendarId) {
@@ -206,8 +214,8 @@ export function TasksSheet({
if (!d || !task.due) return null;
const hasTime = !task.showWithoutTime && !/^\d{4}-\d{2}-\d{2}$/.test(task.due);
return hasTime
? format(d, `EEE, MMM d · ${timePattern(timeFormat)}`, { locale })
: format(d, 'EEE, MMM d', { locale });
? `${formatCalendarDate(d, locale.code, 'weekdayMonthDay')} · ${format(d, timePattern(timeFormat), { locale })}`
: formatCalendarDate(d, locale.code, 'weekdayMonthDay');
};
const priorityColor = (level: PriorityLevel): string =>
@@ -227,12 +235,12 @@ export function TasksSheet({
];
return (
<Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }], ...sheetInsets }]}>
<SafeAreaView edges={['bottom']} style={styles.sheetSafe}>
<View {...dragHandlers}>
<View style={styles.handleHit}>
@@ -244,7 +252,7 @@ export function TasksSheet({
<Pressable
onPress={onClose}
style={styles.closeBtn}
hitSlop={8}
hitSlop={10}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
@@ -292,7 +300,7 @@ export function TasksSheet({
>
<CalendarIcon size={14} color={editor.due ? c.primary : c.textMuted} />
<Text style={[styles.chipText, editor.due && styles.chipTextActive]}>
{editor.due ? format(editor.due, 'MMM d', { locale }) : t('calendar.tasks.due_date', 'Due date')}
{editor.due ? formatCalendarDate(editor.due, locale.code, 'monthDay') : t('calendar.tasks.due_date', 'Due date')}
</Text>
{editor.due && (
<Pressable
@@ -522,7 +530,7 @@ function makeStyles(c: ThemePalette) {
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: c.surface,
backgroundColor: c.background,
},
addBtn: {
width: 38,
@@ -544,7 +552,7 @@ function makeStyles(c: ThemePalette) {
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: c.surface,
backgroundColor: c.background,
textAlignVertical: 'top',
},
editorRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flexWrap: 'wrap' },
+79 -3
View File
@@ -37,6 +37,7 @@ import { isInactiveEvent } from '../../lib/calendar-participants';
import { eventBlockColors } from '../../lib/event-colors';
import {
buildAllDaySegments,
columnsPerScreen,
headerColumnRange,
hourAtOffset,
timeGridFocusColumn,
@@ -60,6 +61,8 @@ type TimeFormat = '12h' | '24h';
interface TimeGridScrollViewProps {
mode: TimeGridMode;
/** Days of the multi-day mode (`multiDayCount`). */
multiDays?: number;
/** The day the user navigated to; its week (week view) or itself (day view) is scrolled into view. */
focus: CalendarFocus;
/** The loaded window: one column per day. */
@@ -95,6 +98,7 @@ interface TimeGridScrollViewProps {
*/
function TimeGridScrollViewInner({
mode,
multiDays = 3,
focus,
window,
selectedDate,
@@ -119,7 +123,7 @@ function TimeGridScrollViewInner({
[eventsByDay, events],
);
const perScreen = mode === 'week' ? 7 : 1;
const perScreen = columnsPerScreen(mode, multiDays);
const { width: screenWidth } = useWindowDimensions();
const [rootWidth, setRootWidth] = React.useState(screenWidth);
const listWidth = Math.max(perScreen, rootWidth - GUTTER_WIDTH);
@@ -216,6 +220,7 @@ function TimeGridScrollViewInner({
listener: (e: NativeSyntheticEvent<NativeScrollEvent>) => {
const list = daysRef.current;
offsetXRef.current = e.nativeEvent.contentOffset.x;
if (e.nativeEvent.contentOffset.x > 0) scrolledSinceMountRef.current = true;
const col = Math.max(
0,
Math.min(list.length - 1, Math.round(e.nativeEvent.contentOffset.x / colWidthRef.current)),
@@ -238,7 +243,12 @@ function TimeGridScrollViewInner({
const visibleHandlerRef = React.useRef(onVisibleDateChange);
visibleHandlerRef.current = onVisibleDateChange;
const lastVisibleKeyRef = React.useRef<string | null>(null);
// Right after a (re)mount the list reports its first days as visible
// before the initial scroll lands; that would set the title to the window's
// first month. Reports wait until the list has scrolled at all.
const scrolledSinceMountRef = React.useRef(false);
const onViewableItemsChanged = React.useRef(({ viewableItems }: { viewableItems: ViewToken[] }) => {
if (!scrolledSinceMountRef.current) return;
let first: Date | null = null;
for (const token of viewableItems) {
const day = token.item as Date;
@@ -288,6 +298,7 @@ function TimeGridScrollViewInner({
const handleLongPressAt = React.useCallback(
(day: Date, hour: number) => {
if (!onCreateAtTime) return;
setDraft(null);
// A display date (that hour in the calendar's zone); the editor turns
// it into the real instant when it saves (eventTimeFieldsToSave).
const date = new Date(day);
@@ -297,6 +308,14 @@ function TimeGridScrollViewInner({
[onCreateAtTime],
);
// A tap on an empty slot marks it with a "New event" block; tapping that
// block creates the event. Two taps, so a stray tap never opens the editor.
const [draft, setDraft] = React.useState<{ key: string; hour: number } | null>(null);
const handleTapAt = React.useCallback((day: Date, hour: number) => {
const key = dayKey(day);
setDraft((cur) => (cur && cur.key === key && cur.hour === hour ? null : { key, hour }));
}, []);
const todayKey = dayKey(displayNow());
const renderItem = React.useCallback(
({ item: day }: ListRenderItemInfo<Date>) => (
@@ -312,11 +331,13 @@ function TimeGridScrollViewInner({
styles={styles}
onSelectEvent={onSelectEvent}
onLongPressAt={onCreateAtTime ? handleLongPressAt : undefined}
onTapAt={onCreateAtTime ? handleTapAt : undefined}
draftHour={draft && draft.key === dayKey(day) ? draft.hour : -1}
/>
),
[
layoutsFor, colWidth, todayKey, nowMinutes, calendars, timeFormat, currentUserEmails, c, styles,
onSelectEvent, onCreateAtTime, handleLongPressAt,
onSelectEvent, onCreateAtTime, handleLongPressAt, handleTapAt, draft,
],
);
@@ -367,6 +388,21 @@ function TimeGridScrollViewInner({
),
};
}
// The remounted list starts at that day, but the header strip still holds
// the old pixel offset until the list scrolls: move it now, or the header
// shows other days (or none) after a rotation.
React.useLayoutEffect(() => {
const index = initialIndexRef.current?.index ?? 0;
scrolledSinceMountRef.current = false;
offsetXRef.current = index * colWidth;
scrollX.setValue(offsetXRef.current);
const day = days[index];
if (day) {
headerAnchorRef.current = day;
setHeaderAnchor(day);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [listKey]);
const listStyle = React.useMemo(() => ({ width: listWidth, height: GRID_HEIGHT }), [listWidth]);
return (
@@ -376,7 +412,9 @@ function TimeGridScrollViewInner({
<View style={{ height: HEADER_HEIGHT }} />
{allDayHeight > 0 && (
<View style={[styles.allDayLabelWrap, { height: allDayHeight }]}>
<Text style={styles.allDayLabel} numberOfLines={2}>
{/* One line that shrinks to the gutter: a second line was cut
off by a one-row strip, leaving "All". */}
<Text style={styles.allDayLabel} numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.6}>
{t('calendar.events.all_day', 'All day')}
</Text>
</View>
@@ -442,6 +480,7 @@ function TimeGridScrollViewInner({
data={days}
keyExtractor={dayKey}
renderItem={renderItem}
extraData={draft}
getItemLayout={getItemLayout}
initialScrollIndex={initialIndexRef.current.index}
// Days added at the start keep the columns on screen in place.
@@ -534,6 +573,8 @@ const DayColumn = React.memo(function DayColumn({
styles,
onSelectEvent,
onLongPressAt,
onTapAt,
draftHour,
}: {
day: Date;
layouts: TimedEventLayout[];
@@ -547,6 +588,9 @@ const DayColumn = React.memo(function DayColumn({
styles: GridStyles;
onSelectEvent?: (event: CalendarEvent) => void;
onLongPressAt?: (day: Date, hour: number) => void;
onTapAt?: (day: Date, hour: number) => void;
/** Hour of the "New event" block in this column, or -1. */
draftHour: number;
}) {
const { t } = useCalendarLocale();
const handleLongPress = React.useCallback(
@@ -555,9 +599,16 @@ const DayColumn = React.memo(function DayColumn({
},
[onLongPressAt, day],
);
const handlePress = React.useCallback(
(e: GestureResponderEvent) => {
onTapAt?.(day, hourAtOffset(e.nativeEvent.locationY, HOUR_HEIGHT));
},
[onTapAt, day],
);
return (
<Pressable
style={[styles.dayCol, { width }]}
onPress={onTapAt ? handlePress : undefined}
onLongPress={onLongPressAt ? handleLongPress : undefined}
>
{layouts.map(({ event, column, totalColumns, startMinutes, endMinutes, continuesBefore, continuesAfter }) => {
@@ -584,6 +635,18 @@ const DayColumn = React.memo(function DayColumn({
/>
);
})}
{draftHour >= 0 && (
<Pressable
style={[styles.draft, { top: draftHour * HOUR_HEIGHT, height: HOUR_HEIGHT - 1 }]}
onPress={() => onLongPressAt?.(day, draftHour)}
accessibilityRole="button"
accessibilityLabel={t('calendar.events.new_event', 'New event')}
>
<Text style={styles.draftText} numberOfLines={1}>
{t('calendar.events.new_event', 'New event')}
</Text>
</Pressable>
)}
{nowMinutes >= 0 && (
<View pointerEvents="none" style={[styles.nowLine, { top: (nowMinutes / 60) * HOUR_HEIGHT }]}>
<View style={styles.nowDot} />
@@ -683,5 +746,18 @@ function makeStyles(c: ThemePalette) {
backgroundColor: c.error,
},
nowBar: { flex: 1, height: 1, backgroundColor: c.error },
draft: {
position: 'absolute',
left: 1,
right: 2,
borderRadius: 2,
borderWidth: 1,
borderColor: c.primary,
backgroundColor: c.primaryBg,
paddingHorizontal: 4,
paddingVertical: 2,
zIndex: 2,
},
draftText: { fontSize: 12, lineHeight: 16, fontWeight: '500', color: c.text },
});
}
+1 -1
View File
@@ -179,7 +179,7 @@ function WeekViewInner({
{allDayRowCount > 0 && (
<View style={[styles.allDayStrip, { height: allDayStripHeight }]}>
<View style={[styles.gutter, styles.allDayLabelWrap]}>
<Text style={styles.allDayLabel} numberOfLines={2}>{t('calendar.events.all_day', 'All day')}</Text>
<Text style={styles.allDayLabel} numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.6}>{t('calendar.events.all_day', 'All day')}</Text>
</View>
<View style={styles.allDayGrid}>
{/* Empty per-day columns to draw vertical separators */}
@@ -3,12 +3,14 @@ import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput, Alert,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { BookUser, X, Check, Plus } from 'lucide-react-native';
import { BookUser, X, Check, Plus } from '../icons';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSheetDrag } from '../../lib/use-sheet-drag';
import { useContactsStore } from '../../stores/contacts-store';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
interface Props {
visible: boolean;
@@ -34,6 +36,8 @@ export default function AddressBookPickerSheet({
const [busy, setBusy] = React.useState(false);
const slideY = React.useRef(new Animated.Value(600)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 600, onClose });
@@ -70,14 +74,14 @@ export default function AddressBookPickerSheet({
};
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }], ...sheetInsets },
]}
>
<View {...dragHandlers}>
@@ -88,7 +92,7 @@ export default function AddressBookPickerSheet({
<Text style={styles.title}>{title ?? t('contacts.bulk.move_to_address_book', 'Move to address book')}</Text>
<Pressable
onPress={onClose}
hitSlop={8}
hitSlop={10}
style={styles.close}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
+4 -1
View File
@@ -2,7 +2,7 @@ import React from 'react';
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Mail as MailIcon, CalendarDays } from 'lucide-react-native';
import { Mail as MailIcon, CalendarDays } from '../icons';
import { format, isAfter, parseISO, type Locale } from 'date-fns';
import type { ContactCard, Email, CalendarEvent } from '../../api/types';
import type { RootStackParamList } from '../../navigation/types';
@@ -281,6 +281,9 @@ function makeStyles(c: ThemePalette) {
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
sectionIcon: { width: 16 },
sectionLabel: {
// Fill the row: shrink-wrapped, Android measured the letter-spaced
// label a few px short and wrapped its last word out of sight ("WOR").
flex: 1,
...typography.bodyMedium,
color: c.textSecondary,
textTransform: 'uppercase',
@@ -4,7 +4,7 @@ import {
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import * as FileSystem from 'expo-file-system/legacy';
import { Upload, X, Check, FileText, AlertTriangle, BookUser } from 'lucide-react-native';
import { Upload, X, Check, FileText, AlertTriangle, BookUser } from '../icons';
import type { ContactCard } from '../../api/types';
import { parseVCard, detectDuplicates } from '../../lib/vcard';
import { getContactDisplayName, getContactPrimaryEmail } from '../../lib/contact-utils';
@@ -13,6 +13,8 @@ import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
// expo-document-picker is loaded lazily on first use; its native module is not
// linked into every build (matches the FilesScreen upload flow).
@@ -56,6 +58,8 @@ export default function ContactImportSheet({
const [result, setResult] = React.useState<{ imported: number; failed: number } | null>(null);
const slideY = React.useRef(new Animated.Value(900)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const resetState = React.useCallback(() => {
@@ -160,11 +164,11 @@ export default function ContactImportSheet({
};
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }], ...sheetInsets }]}>
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
<View style={styles.header}>
<Text style={styles.title}>{t('contacts.import.title', 'Import Contacts')}</Text>
+13 -2
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, Image } from 'react-native';
import { Check, Users } from 'lucide-react-native';
import { Check, Users } from '../icons';
import type { ContactCard } from '../../api/types';
import {
getContactDisplayName,
@@ -19,6 +19,8 @@ interface ContactListRowProps {
onPress?: () => void;
onLongPress?: () => void;
selected?: boolean;
/** The contact open in the detail pane of a split view. */
active?: boolean;
selectionMode?: boolean;
secondaryText?: string;
}
@@ -28,6 +30,7 @@ export default function ContactListRow({
onPress,
onLongPress,
selected,
active = false,
selectionMode,
secondaryText,
}: ContactListRowProps) {
@@ -52,6 +55,7 @@ export default function ContactListRow({
style={({ pressed }) => [
styles.row,
pressed && styles.rowPressed,
active && styles.rowActive,
selected && styles.rowSelected,
]}
>
@@ -95,6 +99,13 @@ function makeStyles(c: ThemePalette) {
},
rowPressed: { backgroundColor: c.surfaceHover },
rowSelected: { backgroundColor: c.primaryBg },
// Same as the open message in the mail list (webmail bg-selection + bar).
rowActive: {
backgroundColor: c.selection,
borderLeftWidth: 2,
borderLeftColor: c.primary,
paddingLeft: spacing.lg - 2,
},
checkbox: {
width: 22,
height: 22,
@@ -125,6 +136,6 @@ function makeStyles(c: ThemePalette) {
info: { flex: 1, minWidth: 0 },
name: { ...typography.bodyMedium, color: c.text },
subtitle: { ...typography.caption, color: c.textMuted, marginTop: 1 },
tertiary: { ...typography.small, color: c.textSecondary, marginTop: 1 },
tertiary: { ...typography.caption, color: c.mutedForeground, marginTop: 1 },
});
}
@@ -4,7 +4,7 @@ import {
Animated, Dimensions, Easing,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Search, X, Check } from 'lucide-react-native';
import { Search, X, Check } from '../icons';
import type { ContactCard } from '../../api/types';
import {
useContactsStore,
@@ -16,6 +16,8 @@ import { useSheetDrag } from '../../lib/use-sheet-drag';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
interface Props {
visible: boolean;
@@ -41,6 +43,8 @@ export default function ContactPickerSheet({
const [selected, setSelected] = React.useState<Set<string>>(new Set());
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlay = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({
slideY,
@@ -94,12 +98,12 @@ export default function ContactPickerSheet({
};
return (
<Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<Animated.View style={[styles.overlay, { opacity: overlay }]}>
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }], ...sheetInsets }]}>
<SafeAreaView style={{ flex: 1 }} edges={['bottom']}>
<View {...dragHandlers}>
<View style={styles.handleHit}>
@@ -7,7 +7,7 @@ import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import {
X, Users, Tag, BookUser, Inbox, ChevronDown, ChevronRight, Plus, Share2, Check,
} from 'lucide-react-native';
} from '../icons';
import type { RootStackParamList } from '../../navigation/types';
import type { AddressBook } from '../../api/types';
import type { ContactCategory } from '../../stores/contacts-store';
@@ -18,6 +18,7 @@ import { getContactDisplayName, getContactKeywords, isGroup } from '../../lib/co
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
type Nav = NativeStackNavigationProp<RootStackParamList>;
@@ -161,7 +162,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
);
return (
<Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose} onShow={runOpen}>
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose} onShow={runOpen}>
<Animated.View style={[styles.overlay, { opacity: overlay }]}>
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>

Some files were not shown because too many files have changed in this diff Show More