diff --git a/docs/native-sync-2026-11.md b/docs/native-sync-2026-11.md
index 5589eb33..9b000c77 100644
--- a/docs/native-sync-2026-11.md
+++ b/docs/native-sync-2026-11.md
@@ -50,7 +50,7 @@ both versions want.
Findings:
-- [ ] **A new calendar lands in the store without its name and colour** — `P2` — `bug` (also on 1.0.0)
+- [x] **A new calendar lands in the store without its name and colour** — `P2` — `bug` (also on 1.0.0) — fixed in a2344ea0
- What Stalwart does: the Calendar/set create answer never echoes properties the client sent (crates/jmap/src/calendar/set.rs:1307-1367).
- What RN does: createCalendar returns `created` alone (src/api/calendar.ts:1269) and the store inserts it (src/stores/calendar-store.ts:1415-1423), so the calendar shows nameless and colourless until the next fetch.
- Fix hint: `{ ...props, ...created }`, as createEvent does. Live test in live-stalwart-calendar.
@@ -117,9 +117,9 @@ parses it, nothing stores it), the global locks need no webmail change.
- [ ] **iOS: reader CSP, composer nonce, `target=_blank` taps, #81 keyboard** — `P2` — `partial` — needs an iOS run (e2e-ios.yml in testing step 4)
- [ ] **iOS push lapses after 7 days without opening the app** — `P2` — `bug` — the extension has no expiry handling (plugins/ios-push/NotificationService.swift), no BGTask; needs a device to verify
- [ ] **Login switch from a deep link or notification keeps the old navigation stack** — `P2` — `bug` — App.tsx:118-122, :141-145, :181-186 switch and navigate on top; question 7
-- [ ] **Sieve editor loses typed text (#1162)** — `P3` — `bug` — src/components/filters/SieveEditorSheet.tsx:35-41 re-seeds on every content change
-- [ ] **No forward limit (#1155, #1208), no vacation length warning** — `P3` — `missing` — FilterRuleModal.tsx:443-459, VacationSettings.tsx:140-142
-- [ ] **Other-zone daily series keeps a wrong recurrenceId on the device's DST day** — `P3` — `bug` — src/lib/recurrence-expansion.ts:526-536 builds candidates from device-local fields
+- [x] **Sieve editor loses typed text (#1162)** — `P3` — `bug` — src/components/filters/SieveEditorSheet.tsx:35-41 re-seeds on every content change — fixed in 6c3046c8
+- [x] **No forward limit (#1155, #1208), no vacation length warning** — `P3` — `missing` — FilterRuleModal.tsx:443-459, VacationSettings.tsx:140-142 — fixed in dcbfc907
+- [x] **Other-zone daily series keeps a wrong recurrenceId on the device's DST day** — `P3` — `bug` — src/lib/recurrence-expansion.ts:526-536 builds candidates from device-local fields — fixed in 6e697baa
- [ ] **New strings English-only** — `P3` — `partial` — 331 overlay keys missing in 25 locales, 86 in de; question 6
- [ ] **Org policy, hand-off password** — blocked on the webmail (above)
- [ ] **`C:\ns` junction and the native-sync worktree still exist** — chore — C:\ns points at C:\Users\info\worktrees\native-sync (e6e5e521, on main)
@@ -152,7 +152,7 @@ current lines is in "Webmail bugs" at the end of this file.
- Fixed on main, unreleased (close after 0.1.87 ships): #80, #82, #83, #84, #86, #87, #89. #81 is fixed in code (01983a38), unchecked on iOS.
- [ ] **#91 multi-select without long press** — `P3` — `missing` — EmailListScreen.tsx:190-218; tap on the avatar toggles selection
-- [ ] **#90 custom calendar colours** — `P3` — `missing` — CalendarEditSheet.tsx:222 offers the palette only
+- [x] **#90 custom calendar colours** — `P3` — `missing` — CalendarEditSheet.tsx:222 offers the palette only — fixed in d3f6d6ae
- [ ] **#85 keep the shade open on Delete** — question 1
- [ ] **#58 PGP / S/MIME** — `P3` — L, not in this run
- [ ] **#3 TLS client certificates** — Android done in 0.1.83; iOS has none (L)
diff --git a/docs/parity/05-calendar.md b/docs/parity/05-calendar.md
index 84ab8b21..daa02e32 100644
--- a/docs/parity/05-calendar.md
+++ b/docs/parity/05-calendar.md
@@ -83,7 +83,7 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi
- What RN does: fixed preset list, multiple allowed, non-offset alerts are dropped from the UI but kept until save (`src/components/calendar/EventModal.tsx:299-312`, `src/lib/calendar-alerts.ts:51-70`). Note the "kept until save" comment is wrong: `remindersToAlerts` rebuilds the map from presets only, so an absolute-trigger alert is lost on the next save.
- Fix hint: add a custom value+unit row; when rebuilding alerts, carry over alerts whose trigger has no `offset`.
-- [ ] **Duplicate event, export .ics, copy meeting link / title, add note** — `P3` — `missing` — duplicate (+1 day, opens editor), share as .ics (`eventToICS` port + expo-sharing) and copy meeting link done in 5fe8e40; deferred: copy title, append-a-note
+- [x] **Duplicate event, export .ics, copy meeting link / title, add note** — `P3` — `missing` — duplicate (+1 day, opens editor), share as .ics (`eventToICS` port + expo-sharing) and copy meeting link done in 5fe8e40; deferred: copy title, append-a-note — fixed in baa3ff59 (copy title, add note)
- What WEB does: popover/context-menu actions duplicate (+1 day, opens editor), export via `downloadEventICS`, copy meeting link, copy title, append a timestamped note to the description (ref `components/calendar/calendar-app.tsx:1017-1125`, `lib/calendar-ics-export.ts:96, 176`).
- What RN does: `EventDetailSheet` has an `onDuplicate` prop that `CalendarScreen` never passes (`src/components/calendar/EventDetailSheet.tsx:59, 336-342`); no export/copy/note.
- Fix hint: wire `onDuplicate` (clone like WEB `handleDuplicateFromDetail`), add "Share .ics" via `expo-sharing` + a port of `eventToICS`, add "Copy link" with `Clipboard`.
@@ -169,7 +169,7 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi
### Views / navigation / locale
-- [ ] **Entire calendar UI is hard-coded English although RN has i18n** — `P2` — `missing` — partial in 0f80e98: screen, month/week/agenda views, event card/sheet/modal, drawer, scope dialog, settings, banner and calendar/share sheets use `t()` + date-fns locales; TasksSheet (09c24fa), `formatReminder` (2abea97) and ICalImportSheet (b7f7110) localized too; deferred: ICalSubscriptionSheet and RecurrenceEditor still hard-code English
+- [x] **Entire calendar UI is hard-coded English although RN has i18n** — `P2` — `missing` — partial in 0f80e98: screen, month/week/agenda views, event card/sheet/modal, drawer, scope dialog, settings, banner and calendar/share sheets use `t()` + date-fns locales; TasksSheet (09c24fa), `formatReminder` (2abea97) and ICalImportSheet (b7f7110) localized too; deferred: ICalSubscriptionSheet and RecurrenceEditor still hard-code English — fixed in 84fef142
- What WEB does: everything through `next-intl` (`t('calendar.*')`), month/day names and popover dates localized (changelog 1.6.x "Localize event start date in detail popover and event modal").
- What RN does: `src/i18n/index.ts` + `useLocaleStore` are used by 18 other files, but none of `src/components/calendar/*`, `src/screens/CalendarScreen.tsx` or `CalendarSettings.tsx` import it; all labels ("Today", "No events", "Does not repeat", "Going?", RSVP labels, drawer titles, etc.) and `date-fns` `format()` calls without a `locale` are English (`CalendarScreen.tsx:408, 448, 504, 640-641`, `EventModal.tsx:60-67, 326, ...`, `MonthView.tsx:25-26`, `AgendaView.tsx:33-37`).
- Fix hint: add a `calendar.*` namespace to `locales/*.json` (WEB's keys can be copied) and pass a date-fns locale from the locale store into `format()`.
@@ -214,7 +214,7 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi
- What RN does: `formatRange` uses `eventTimeRange` -> `getEventEndDate` (exclusive), so a single-day all-day event on Mar 1 prints "Mar 1 – Mar 2" and a two-day one "Mar 1 – Mar 3" (`src/components/calendar/EventDetailSheet.tsx:63-74`, `src/lib/calendar-utils.ts:84-90`).
- Fix hint: for `allDay` use `getEventDisplayEndDate(event)` before comparing/formatting.
-- [ ] **Deep links to calendar/event** — `P3` — `missing` — partly: incoming links are parsed since 5802cae (`src/navigation/linking.ts`), and an event link opens the event since 9282ee6; a date link only opens the Calendar tab
+- [ ] **Deep links to calendar/event** — `P3` — `missing` — partly: incoming links are parsed since 5802cae (`src/navigation/linking.ts`), and an event link opens the event since 9282ee6; a date link only opens the Calendar tab — not done: date deep links need a route in App.tsx/linking.ts (same verdict as the 10 row)
- What WEB does: `parseCalendarPath`/`buildCalendarPath` handle `/calendar//?event=` (ref `lib/deep-links.ts:272-300`, changelog 1.8.3 "Deep links for mail, calendar, contacts, files").
- What RN does: `Calendar: undefined` route params (`src/navigation/types.ts:33`); `handleDeepLink` hands an event to the Calendar tab through `pending-calendar-open`, the same path a tapped reminder takes, but ignores the date.
- Fix hint: accept `{ date?, eventId? }` params on the Calendar route and open the detail sheet.
@@ -267,7 +267,7 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi
### Settings
-- [ ] **No custom time-zone setting (#755); no birthday-calendar colour; no "Day" view on mobile (see above)** — `P3` — `missing` — time-zone setting (`calendarTimeZone`, used for saved events and JMAP queries) done in 58a6f45 + 0f80e98; the editors and views work in calendar-zone wall time since a8e6f6e and 8af163a, and floating task dues since 1a651a9; deferred: birthday-calendar colour setting
+- [x] **No custom time-zone setting (#755); no birthday-calendar colour; no "Day" view on mobile (see above)** — `P3` — `missing` — time-zone setting (`calendarTimeZone`, used for saved events and JMAP queries) done in 58a6f45 + 0f80e98; the editors and views work in calendar-zone wall time since a8e6f6e and 8af163a, and floating task dues since 1a651a9; deferred: birthday-calendar colour setting — fixed in 574db426 (birthday colour; the time zone and the Day view were done before)
- What WEB does: `timeZone: 'auto' | IANA` overriding browser detection, used for display (`displayNow`, `toDisplayDate`) and for the `timeZone` on saved events (ref `lib/timezone.ts:29-75`, `stores/settings-store.ts:317, 553`, changelog 1.9.2 #755); `birthdayCalendarColor` (`settings-store.ts:377, 609`).
- What RN does: device zone only (`src/api/calendar.ts:15-21`); birthday colour fixed (`src/lib/birthday-calendar.ts:7`).
- Fix hint: add `timeZone` to the settings store (synced with WEB's key so it round-trips through settings sync), pass it as the JMAP `timeZone` arg and into saved events; convert display via `Intl` like WEB's `getWallClock`.
diff --git a/docs/parity/06-contacts.md b/docs/parity/06-contacts.md
index 21a57355..6e3409b0 100644
--- a/docs/parity/06-contacts.md
+++ b/docs/parity/06-contacts.md
@@ -73,7 +73,7 @@ RN covers the visible surface reasonably well (list with alphabetical index, det
- What RN does: shows the trash icon whenever `books.length > 1 && mayDelete !== false` (`src/components/settings/ContactsSettings.tsx:209`); the server then rejects and the user sees a raw error.
- Fix hint: add `!book.isDefault` to the guard.
-- [ ] **Address book share / visibility (subscription) management missing** — `P3` — `missing`
+- [ ] **Address book share / visibility (subscription) management missing** — `P3` — `missing` — not done: needs its own share sheet; ran out of time (same verdict as the 10 row)
- deferred: `shareWith`/`myRights` are now requested explicitly (580e010) and typed on `AddressBook`; no sharing sheet on mobile yet (reuse the calendar-sharing sheet when it exists).
- What WEB does: `shareAddressBook` via `shareWith/` patch + ShareCollectionDialog (`stores/contact-store.ts:1064-1085`, `components/contacts/contacts-app.tsx:1142-1158`); sidebar shows a shared indicator (`contacts-sidebar.tsx:879-881`).
- What RN does: nothing; `getAddressBooks` requests no `properties` so `shareWith` is whatever the server defaults to (`src/api/contacts.ts:17-24`; WEB requests `ADDRESS_BOOK_PROPERTIES` incl. `shareWith`, `lib/jmap/client.ts:252-261`, #257).
@@ -146,7 +146,7 @@ RN covers the visible surface reasonably well (list with alphabetical index, det
- What RN does: filter = contacts with no `addressBookIds` (`src/stores/contacts-store.ts:439-440`; `src/screens/ContactsScreen.tsx:146-149`), drawer row passes `count={0}` (`src/components/contacts/ContactsSidebarDrawer.tsx:128-136`) so the count never shows; on Stalwart every card has a book so the view is always empty.
- Fix hint: redefine as "no keywords", move the row under the Tags section, compute the count.
-- [ ] **Contact list filters (org / title / location / domain / birthday month / has email-phone-photo) missing** — `P3` — `missing`
+- [x] **Contact list filters (org / title / location / domain / birthday month / has email-phone-photo) missing** — `P3` — `missing` — fixed in 18c6b03f
- deferred: optional; free-text search only.
- What WEB does: filter drawer with tri-state chips (`components/contacts/contact-list.tsx:18-76, 154-228, 350-456`; changelog 1.5.x "Revamp contact detail view with filters").
- What RN does: free-text search only (`src/lib/contact-utils.ts:216-241`).
@@ -189,7 +189,7 @@ RN covers the visible surface reasonably well (list with alphabetical index, det
### Composer autocomplete
-- [ ] **Autocomplete lacks groups, recent recipients (Sent), on-demand server search and directory principals** — `P2` — `missing`
+- [ ] **Autocomplete lacks groups, recent recipients (Sent), on-demand server search and directory principals** — `P2` — `missing` — event participants get groups and recent recipients in e988dc29; not done: on-demand server search and directory principals in the composer (compose agent's side)
- store-side done in 26a34d5 (`getAutocomplete` with group entries + recent recipients, `loadRecentRecipients`/`searchRecipients` fed by `src/api/recent-recipients.ts`, `getGroupRecipients`, #672 normalisation); the composer uses `getAutocomplete` and the recent Sent recipients since 5b72c4d. Still open: the on-demand server search (`searchRecipients` has no caller), directory principals, and the calendar's `ParticipantInput`, which still offers contacts only.
- What WEB does: `getAutocomplete` merges contacts, group entries (as one chip with `memberCount`), RFC 9670 directory principals and recent Sent recipients, deduped and sanitised (#672) (`stores/contact-store.ts:622-707, 23-49`; `loadRecentRecipients` `:1118-1142` fed from `mail-app.tsx:423`; `searchRecipients` `:1144-1154` used by `components/email/email-composer.tsx:1175-1212`; group chip insertion `:1218-1233`; changelogs 1.6.x "Recipient autocomplete from Sent, with on-demand server search", 1.7.x group chips).
- What RN does: suggestions come only from `individuals` matched with `matchesContactSearch` (`src/screens/ComposeScreen.tsx:367-392`); groups are explicitly filtered out and nothing else is consulted. `ParticipantInput` (calendar) likewise flattens contacts only (`src/components/calendar/ParticipantInput.tsx:34-51`).
@@ -209,7 +209,7 @@ RN covers the visible surface reasonably well (list with alphabetical index, det
- What RN does: none (see previous item).
- Fix hint: same sheet as above; show the matching card's phone/org and an "Open" that navigates to `ContactDetail`.
-- [ ] **Contact deep links (`/contacts/[/edit]`, `/contacts/new`)** — `P3` — `missing`
+- [ ] **Contact deep links (`/contacts/[/edit]`, `/contacts/new`)** — `P3` — `missing` — not done: needs routes in linking.ts, the platform agent's file (same verdict as the 10 row)
- partly: `/contacts/` opens the contact since 5802cae; `/contacts/new` and `/contacts//edit` only open the Contacts tab.
- What WEB does: `parseContactsPath`/`buildContactsPath` (`lib/deep-links.ts:323-329`), applied once on mount (`components/contacts/contacts-app.tsx:182-224`).
- What RN does: `src/navigation/linking.ts` (5802cae) maps `/contacts/` to `ContactDetail { contactId }` and every other contacts path to the tab; it never opens `ContactForm`.
diff --git a/docs/parity/07-filters-vacation-files.md b/docs/parity/07-filters-vacation-files.md
index f8d1a8d6..82808302 100644
--- a/docs/parity/07-filters-vacation-files.md
+++ b/docs/parity/07-filters-vacation-files.md
@@ -143,7 +143,7 @@ Filters: RN carries a byte-for-byte port of WEB's Sieve parser/generator/tests a
- What WEB does: on first listing, reparents nodes named with `/`, `∕`, `⁄`, `/` separators and replaces blob-backed "dir marker" files with real folders, with a progress overlay (`files-app.tsx:245-263`, `710-734`).
- What RN does: reads only the real hierarchy (`src/api/files.ts:6-14` comment acknowledges WEB migrates). A user who never opens WEB sees legacy files as flat names at the root. Acceptable if WEB is always used at least once; otherwise port `migrateLegacyFlatNodes`.
-- [ ] **Deep link `/files/` and `?preview=` not handled** — `P3` — `missing` (changelog 1.8.1 "Deep links for mail, calendar, contacts, files") — partly: `/files` links open the Files tab since 5802cae; folder paths and `?preview=` are ignored
+- [ ] **Deep link `/files/` and `?preview=` not handled** — `P3` — `missing` (changelog 1.8.1 "Deep links for mail, calendar, contacts, files") — partly: `/files` links open the Files tab since 5802cae; folder paths and `?preview=` are ignored — not done: ran out of time (same verdict as the 10 row)
- What WEB does: `parseFilesPath`/`buildFilesPath` walk the drive to the folder and open the preview (`components/files/files-app.tsx:190-278`; `lib/deep-links.ts:381`).
- What RN does: `src/navigation/linking.ts` (5802cae) turns any `/files/...` link into `{ kind: 'files' }` and opens the tab; `Files: undefined` in `src/navigation/types.ts:35`.
- Fix hint: parse the rest of the path in `linking.ts`, pass it to `Files` as a `path` param and walk `allNodes` by names.
diff --git a/docs/parity/10-webmail-1.10-to-1.18.md b/docs/parity/10-webmail-1.10-to-1.18.md
index a3f78737..206c608d 100644
--- a/docs/parity/10-webmail-1.10-to-1.18.md
+++ b/docs/parity/10-webmail-1.10-to-1.18.md
@@ -317,16 +317,16 @@ Stalwart 1.0.0 items found in the same pass (1.0 SSO scopes, Files on 1.0, #1385
- [x] **Blank participant names are written (#748)** — `P3` — `bug` — fixed in 59c32782
- What WEB does: `lib/calendar-participants.ts:368`. What RN does: `src/lib/calendar-participants.ts:360`, `:385`, `:484`.
-- [ ] **Offer to save without invitations when the server refuses them** — `P3` — `partial`
+- [x] **Offer to save without invitations when the server refuses them** — `P3` — `partial` — fixed in 9b5692a8
- What WEB does: `lib/jmap/scheduling-error.ts:7`. What RN does: `src/components/calendar/EventModal.tsx:385-390`.
-- [ ] **Moving a task into or within a shared calendar (#1204)** — `P3` — `partial`
+- [x] **Moving a task into or within a shared calendar (#1204)** — `P3` — `partial` — fixed in b6995bb6
- What WEB does: `lib/jmap/client.ts:9473`. What RN does: updateTask sends unmapped calendarIds (`src/stores/calendar-store.ts:1311-1315`).
-- [ ] **Working hours (#1164); week all-day area (#1122); light tinted colours; Jalali; birthday colour; copy title/append note; component set on new calendars (#760); date deep links; background reminder refresh without device sync** — `P3` — `missing`/`partial`
+- [ ] **Working hours (#1164); week all-day area (#1122); light tinted colours; Jalali; birthday colour; copy title/append note; component set on new calendars (#760); date deep links; background reminder refresh without device sync** — `P3` — `missing`/`partial` — birthday colour fixed in 574db426, copy title/append note in baa3ff59 (duplicate and .ics export were there); not done: week all-day area, light tinted colours (needs a toggle on the platform agent's calendar settings page), date deep links (route in App.tsx/linking.ts, see the calendar report) and background reminder refresh: ran out of time; working hours and Jalali: own feature, not in this run; component set (#760): needs a CalDAV client (05 row)
- What RN does: `src/components/calendar/WeekView.tsx`, `src/components/calendar/TimeGridScrollView.tsx:177-185`, `src/lib/birthday-calendar.ts:6`, `src/components/calendar/EventDetailSheet.tsx:422`, `src/api/calendar.ts:1150`, `src/navigation/linking.ts:151`, `:288-299`, `src/lib/push-notifications.ts:243`.
-- [ ] **Free/busy, CalendarEventNotification, organizer from ParticipantIdentity (1.10.0)** — `P3` — `missing`
+- [ ] **Free/busy, CalendarEventNotification, organizer from ParticipantIdentity (1.10.0)** — `P3` — `missing` — not done: own feature, not in this run
- What WEB does: `lib/jmap/client.ts:1198`.
### Contacts
@@ -350,7 +350,7 @@ Stalwart 1.0.0 items found in the same pass (1.0 SSO scopes, Files on 1.0, #1385
- [x] **vCard 4.0 export (#1407)** — `P3` — `bug` — fixed in 07fd03e2
- What RN does: dashes in dates, MOBILE instead of CELL (`src/lib/vcard.ts:21`, `:24`, `:1264`).
-- [ ] **Delete contact categories; address book sharing; list filters; deep links new/edit** — `P3` — `missing`/`partial`
+- [ ] **Delete contact categories; address book sharing; list filters; deep links new/edit** — `P3` — `missing`/`partial` — category delete with Undo fixed in 65ffef71, list filters in 18c6b03f; not done: address book sharing (needs its own share sheet, CalendarShareSheet is calendar-only; ran out of time) and deep links new/edit (route in linking.ts, platform agent's file)
- What WEB does: `components/settings/address-book-management-settings.tsx:84`, `:435`, `components/contacts/contact-list.tsx:30`, `lib/deep-links.ts:394`. What RN does: `src/components/contacts/ContactsSidebarDrawer.tsx:144`, `src/components/settings/ContactsSettings.tsx:33`, `src/screens/ContactsScreen.tsx:163`, `src/navigation/linking.ts:155`.
### Files
@@ -362,7 +362,7 @@ Stalwart 1.0.0 items found in the same pass (1.0 SSO scopes, Files on 1.0, #1385
- [x] **Save an attachment to Files (incl. shared and group mail)** — `P2` — `missing` — fixed in 8e07b7d8, 39e82f99
- What WEB does: `components/email/save-to-files-dialog.tsx:43`, `lib/jmap/client.ts:9981`. What RN does: `src/components/email/AttachmentChips.tsx:183`; `src/api/files.ts:411` has createFileNodeFromBlob.
-- [ ] **Copy folders, numbered new folder names, next/previous in preview, starred and recent, deep links** — `P3` — `partial`/`missing`
+- [ ] **Copy folders, numbered new folder names, next/previous in preview, starred and recent, deep links** — `P3` — `partial`/`missing` — copy folders fixed in a0f586f9; numbered new folder names: no webmail behaviour to port (its New folder dialog also takes the typed name, file-browser.tsx:1389); not done: next/previous in preview (needs header buttons in AttachmentPreviewModal, the reader agent's file), starred and recent and deep links: ran out of time
- What WEB does: `lib/jmap/client.ts:10155`, `stores/file-store.ts:640`, `lib/deep-links.ts:444`. What RN does: `src/api/files.ts:511-519`, `:328-345`, `src/components/files/FilePreviewModal.tsx`, `src/navigation/linking.ts:158`, `:307`.
- [ ] **Sharing fails on Stalwart before 0.16.6** — `P3` — `bug`
@@ -394,7 +394,7 @@ Stalwart 1.0.0 items found in the same pass (1.0 SSO scopes, Files on 1.0, #1385
- [x] **Create rules from a message** — `P2` — `missing` — fixed in d546d509, 7b7236f0
- What WEB does: `components/email/rules-menu.tsx`, `lib/filters/quick-rules.ts:187`. What RN does: `src/components/email/ActionSheet.tsx`, `src/components/filters/FilterRuleModal.tsx`.
-- [ ] **Forward limit (#1155, #1208); vacation length warning; run a rule on a folder (#1166); Sieve editor loses typed text (#1162); size value raw into Sieve; undo after deleting tags/rules/trusted senders** — `P3` — `missing`/`bug`
+- [ ] **Forward limit (#1155, #1208); vacation length warning; run a rule on a folder (#1166); Sieve editor loses typed text (#1162); size value raw into Sieve; undo after deleting tags/rules/trusted senders** — `P3` — `missing`/`bug` — forward limit and vacation length fixed in dcbfc907, Sieve editor text in 6c3046c8, size value in 2167a9d7 (before this pass); undo after deleting rules: store side in b95f2c29, FilterSettings (platform agent's file) still has to call deleteRuleNow; not done: undo after deleting tags and trusted senders (KeywordSettings and the trusted-senders list are not the calendar agent's files); run a rule on a folder: needs server-side run, its own feature
- What WEB does: `lib/filters/forward-limit.ts:16`, `components/settings/vacation-settings.tsx:253`, `lib/filters/quick-rule-flow.ts:221`, `lib/sieve/generator.ts:63`. What RN does: `src/components/filters/FilterRuleModal.tsx:63`, `:413`, `src/components/settings/VacationSettings.tsx:106`, `src/components/filters/SieveEditorSheet.tsx:35-41`, `src/lib/sieve/generator.ts:69-74`, `src/components/settings/KeywordSettings.tsx:50-56`.
### Push, settings and core
diff --git a/docs/parity/11-webmail-1.19.md b/docs/parity/11-webmail-1.19.md
index 1d6b77b2..fd0aa44a 100644
--- a/docs/parity/11-webmail-1.19.md
+++ b/docs/parity/11-webmail-1.19.md
@@ -150,7 +150,7 @@ Classification: `bug` = fixed in the webmail, still live in the app; `missing`;
- What RN does: textMuted is `#94a3b8` on white in the light theme (2.56:1) and `#737373` in dark (4.18:1 on the background, 3.19:1 on surfaces) (`src/theme/tokens.ts:236`, `:140`); used in 456 places, also as placeholder colour.
- Fix hint: adjust both, add a contrast test over tokens.ts and builtin-themes.ts.
-- [ ] **The calendar grid reads poorly with a screen reader** — `P2` — `bug`
+- [x] **The calendar grid reads poorly with a screen reader** — `P2` — `bug` — fixed in 294023e2
- What WEB does: events sit inside their day cell and day cells have full date names (0026abba4, 19e0acde9).
- What RN does: month day cells read only the number and the dots are silent (`src/components/calendar/MonthView.tsx:199-240`); event blocks have no role, label or date (`src/components/calendar/EventBlock.tsx:72-104`).
- Fix hint: label day cells with date and event count; event blocks get a button role and a label with title, date and time.
@@ -207,17 +207,17 @@ Classification: `bug` = fixed in the webmail, still live in the app; `missing`;
### Calendar
-- [ ] **A cancellation mail can't remove the meeting from the calendar (#1425)** — `P2` — `missing`
+- [x] **A cancellation mail can't remove the meeting from the calendar (#1425)** — `P2` — `missing` — fixed in a19afe9b
- What WEB does: offers "Remove from calendar" for a trusted cancellation from the organizer, for the series or one occurrence, and says when it's outdated (`components/email/calendar-invitation-banner.tsx:627-635`, `:831`, `:1229`, `lib/calendar-cancellation.ts:49`; 357b6af3c).
- What RN does: only "This event was cancelled" (`src/components/email/CalendarInvitationBanner.tsx:201`, `:270-272`); it already looks up the server copy (`:122`).
- Fix hint: port planCancellationRemoval behind the banner's trust check.
-- [ ] **The event and task editors throw away typed changes on Close or Back** — `P2` — `bug`
+- [x] **The event and task editors throw away typed changes on Close or Back** — `P2` — `bug` — fixed in 10e9f81d
- What WEB does: asks before closing with unsaved changes (`components/calendar/event-modal.tsx:530-547`, `components/calendar/task-modal.tsx`; 13f9f27c4).
- What RN does: close and Back call onClose (`src/components/calendar/EventModal.tsx:495`, `:500`); the task editor resets on the next open (`src/components/calendar/TasksSheet.tsx:135-146`, `:238-253`).
- Fix hint: compare with the initial form and ask, as ContactFormScreen does (`src/screens/ContactFormScreen.tsx:639`).
-- [ ] **A shared calendar's server colour is replaced by a random one (#1414)** — `P3` — `bug`
+- [x] **A shared calendar's server colour is replaced by a random one (#1414)** — `P3` — `bug` — fixed in 64eb97f4
- What WEB does: auto-colours only shared calendars with neither a server colour nor an override (`lib/shared-calendar-colors.ts:79`; 2dbe5d024).
- What RN does: colours every shared calendar without an override (`src/screens/CalendarScreen.tsx:315-333`).
- Fix hint: skip calendars with `cal.color`.
diff --git a/locales/rn/en.json b/locales/rn/en.json
index a34db434..a6cb4b8a 100644
--- a/locales/rn/en.json
+++ b/locales/rn/en.json
@@ -6,6 +6,9 @@
"not_starred": "Not starred"
},
"calendar": {
+ "a11y": {
+ "event_count": "{count, plural, one {# event} other {# events}}"
+ },
"account_changed": "The account changed while the calendar was loading or saving.",
"alerts": {
"minutes_after": "{count, plural, one {# minute after} other {# minutes after}}"
@@ -17,6 +20,12 @@
"detail": {
"location_hint": "Opens the location; long-press to copy it"
},
+ "discard": {
+ "confirm": "Discard",
+ "keep": "Keep editing",
+ "message": "Your changes haven't been saved.",
+ "title": "Discard changes?"
+ },
"drawer": {
"my_calendars": "My calendars",
"no_calendars": "No calendars yet.",
@@ -28,6 +37,7 @@
},
"events": {
"copy_link_short": "Link",
+ "copy_title_short": "Title",
"export_ics_short": ".ics",
"nothing_planned": "Nothing planned. Tap to create.",
"tap_to_create": "Tap + to create one"
@@ -59,12 +69,16 @@
"add_to_calendar": "Add to calendar",
"added": "Added to calendar",
"already_in_calendar": "Already in your calendar",
+ "cancel_outdated": "Your calendar has a newer version of this event than this cancellation.",
"cancelled": "This event was cancelled",
"decline": "No",
"join_video": "Join video call",
"no_writable_calendar": "No writable calendar available",
"organizer": "Organizer",
"reading": "Reading invitation…",
+ "remove_error": "Could not remove the event",
+ "remove_from_calendar": "Remove from calendar",
+ "removed_from_calendar": "Removed from your calendar.",
"reply": "A participant replied to this invitation",
"response_error": "Could not send your response",
"response_sent": "Response sent",
@@ -80,10 +94,14 @@
"clear_confirm": "Remove all",
"clear_description": "Every event in this calendar will be deleted. Events that also belong to another calendar are only unlinked.",
"clear_title": "Remove all events?",
+ "color_invalid": "Enter a color as # and six hex digits, such as #3b82f6.",
+ "custom_color": "Custom color (hex)",
"delete_description": "The calendar and all of its events will be deleted. This cannot be undone.",
"delete_title": "Delete calendar?",
"description_placeholder": "Optional",
"edit_calendar": "Edit calendar",
+ "fewer_colors": "Fewer colors",
+ "more_colors": "More colors",
"new_calendar": "New calendar"
},
"notifications": {
@@ -95,6 +113,7 @@
},
"occurrence_not_found_again": "The change was not saved: the occurrence could not be found again on the server. Please try again.",
"participants": {
+ "group_members": "{count, plural, one {# member} other {# members}}",
"limit_warning": "More than {max, plural, one {# participant} other {# participants}}, including the organizer: the server may refuse to save this event. An administrator can raise the limit."
},
"recurrence": {
@@ -188,8 +207,11 @@
"move_to_address_book": "Move to address book",
"tag_failed": "Tagging failed"
},
+ "category_delete_failed": "Couldn't delete the category",
+ "category_deleted": "Category \"{name}\" deleted",
"contact": "Contact",
"create_hint_mobile": "Long-press to create a group instead",
+ "delete_category": "Delete category",
"detail": {
"add_to_group_failed": "Add to group failed",
"copy_name": "{name} (Copy)",
@@ -287,6 +309,7 @@
},
"rename_tag_hint_mobile": "Long-press a tag to rename it",
"tag_name_placeholder": "New tag name",
+ "undo": "Undo",
"unnamed": "Unnamed"
},
"context_menu": {
diff --git a/src/api/__tests__/calendar.test.ts b/src/api/__tests__/calendar.test.ts
index ea16e878..9f376758 100644
--- a/src/api/__tests__/calendar.test.ts
+++ b/src/api/__tests__/calendar.test.ts
@@ -15,6 +15,7 @@ import {
queryEvents,
getEvents,
createEvent,
+ createCalendar,
updateEvent,
deleteEvents,
batchCreateEvents,
@@ -557,6 +558,35 @@ describe('calendar operations', () => {
});
});
+ describe('createCalendar', () => {
+ it('returns the new calendar with the name and colour sent when the server echoes only its id', async () => {
+ mockRequest.mockResolvedValue({
+ methodResponses: [
+ ['Calendar/set', { created: { 'new-cal': { id: 'c9', color: null, myRights: { mayDelete: true } } } }, '0'],
+ ['error', { type: 'serverFail' }, '1'],
+ ],
+ });
+ const cal = await createCalendar('Work', '#123456');
+ expect(cal).toMatchObject({ id: 'c9', name: 'Work', color: '#123456', isVisible: true, myRights: { mayDelete: true } });
+ });
+
+ it('takes the read-back calendar with its rights', async () => {
+ const rights = { mayReadItems: true, mayWriteAll: true, mayDelete: true, mayShare: true };
+ mockRequest.mockResolvedValue({
+ methodResponses: [
+ ['Calendar/set', { created: { 'new-cal': { id: 'c9' } } }, '0'],
+ ['Calendar/get', { list: [{ id: 'c9', name: 'Work', color: '#123456', myRights: rights }] }, '1'],
+ ],
+ });
+ const cal = await createCalendar('Work', '#123456');
+ expect(cal).toMatchObject({ id: 'c9', name: 'Work', color: '#123456', myRights: rights });
+ const calls = mockRequest.mock.calls[0][0];
+ expect(calls[1][0]).toBe('Calendar/get');
+ // The creation id: 0.16 refuses a result reference into `created`.
+ expect(calls[1][1].ids).toEqual(['#new-cal']);
+ });
+ });
+
describe('refusals for the participant limit (Stalwart 1.0)', () => {
// 1.0 enforces maxParticipantsPerEvent on every create and update, an
// answer included; 0.16 advertises the limit without enforcing it.
diff --git a/src/api/__tests__/files.test.ts b/src/api/__tests__/files.test.ts
index d49bac33..5bcad0d8 100644
--- a/src/api/__tests__/files.test.ts
+++ b/src/api/__tests__/files.test.ts
@@ -30,6 +30,7 @@ import { CAPABILITIES } from '../types';
import {
accountSupportsFiles,
copyFileNode,
+ copyFileTree,
createFolder,
deleteFileNodes,
fileNodeKind,
@@ -665,6 +666,52 @@ describe('copyFileNode', () => {
});
});
+describe('copyFileTree', () => {
+ const tree = [
+ { id: 'src', name: 'Docs', type: '', blobId: null, parentId: null },
+ { id: 'sub', name: 'Old', type: '', blobId: null, parentId: 'src' },
+ { id: 'f1', name: 'a.txt', type: 'text/plain', blobId: 'b1', parentId: 'src' },
+ { id: 'f2', name: 'b.txt', type: 'text/plain', blobId: 'b2', parentId: 'sub' },
+ { id: 'other', name: 'c.txt', type: 'text/plain', blobId: 'b3', parentId: null },
+ ];
+ /** Answers each FileNode/set create with a new id, or fails the call for `failOn`. */
+ function serve(failOn?: string) {
+ let n = 0;
+ const creates: Record[] = [];
+ const destroys: string[][] = [];
+ mockRequest.mockImplementation(async (calls: [string, Record, string][]) => {
+ const [, args] = calls[0];
+ if (args.destroy) {
+ destroys.push(args.destroy);
+ return { methodResponses: [['FileNode/set', { destroyed: args.destroy }, '0']] };
+ }
+ const [key, props] = Object.entries(args.create as Record>)[0];
+ if (props.name === failOn) return { methodResponses: [['FileNode/set', { notCreated: { [key]: { type: 'forbidden' } } }, '0']] };
+ creates.push(props);
+ return { methodResponses: [['FileNode/set', { created: { [key]: { id: `new-${++n}` } } }, '0']] };
+ });
+ return { creates, destroys };
+ }
+
+ it('makes the folder again with its folders and files, the files on the same blobs', async () => {
+ const { creates } = serve();
+ const copy = await copyFileTree(tree[0], tree, null, 'Docs (1)');
+ expect(copy.id).toBe('new-1');
+ expect(creates).toEqual([
+ { name: 'Docs (1)' },
+ { name: 'Old', parentId: 'new-1' },
+ { name: 'b.txt', type: 'text/plain', blobId: 'b2', parentId: 'new-2' },
+ { name: 'a.txt', type: 'text/plain', blobId: 'b1', parentId: 'new-1' },
+ ]);
+ });
+
+ it('takes a half-made copy back, the deepest level first', async () => {
+ const { destroys } = serve('a.txt');
+ await expect(copyFileTree(tree[0], tree, null, 'Docs (1)')).rejects.toThrow();
+ expect(destroys).toEqual([['new-3'], ['new-2'], ['new-1']]);
+ });
+});
+
describe('fileNodeKind', () => {
it('takes nodeType as Stalwart 0.16.6+ returns it', () => {
expect(fileNodeKind({ nodeType: 'file', blobId: 'b1' })).toBe('file');
diff --git a/src/api/__tests__/live-stalwart-calendar.test.ts b/src/api/__tests__/live-stalwart-calendar.test.ts
index e5b382e8..6cda02e3 100644
--- a/src/api/__tests__/live-stalwart-calendar.test.ts
+++ b/src/api/__tests__/live-stalwart-calendar.test.ts
@@ -29,6 +29,7 @@ import type { Calendar, CalendarEvent, Participant } from '../types';
import {
createEvent,
deleteEvents,
+ findEventsByUid,
getCalendars,
getEvents,
queryEvents,
@@ -43,6 +44,7 @@ import { expandRecurringEvents } from '../../lib/recurrence-expansion';
import { getEventStartDate } from '../../lib/calendar-utils';
import { alertsToReminders, calendarDefaultAlerts, defaultAlertIdPrefix, remindersToDefaultAlerts } from '../../lib/calendar-alerts';
import { getEffectiveAlerts } from '../../lib/calendar-alert-scheduler';
+import { removeCancelledMeeting } from '../../lib/calendar-cancellation';
const SERVER = process.env.BULWARK_LIVE_JMAP;
const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org';
@@ -607,6 +609,89 @@ live('live Stalwart calendar', () => {
}, 120_000);
});
+ liveTwo('a cancelled meeting (#1425)', () => {
+ const after = '2027-10-01T00:00:00.000Z';
+ const before = '2027-10-31T00:00:00.000Z';
+ const me = [USER];
+
+ /** Run `fn` as the second user, in the app's singleton. */
+ async function asOther(fn: () => Promise): Promise {
+ (await singleton()).restoreSnapshot(other!.snapshot());
+ useCalendarStore.getState().reset();
+ try {
+ return await fn();
+ } finally {
+ await connectSingleton();
+ }
+ }
+
+ function removeAsOther(cancel: Partial) {
+ return asOther(async () => {
+ await useCalendarStore.getState().fetchCalendars();
+ const s = useCalendarStore.getState();
+ return removeCancelledMeeting(cancel, {
+ findEventsByUid: (u) => findEventsByUid(u),
+ deleteEvent: s.deleteEvent,
+ updateEvent: s.updateEvent,
+ });
+ });
+ }
+
+ it('takes one cancelled occurrence, then the cancelled series, out of the attendee\'s calendar', async () => {
+ await connectSingleton();
+ // Four Mondays from 4 October 2027, the second user invited.
+ const invitation = editorSave(
+ { title: `${RUN} cancelled`, start: '2027-10-04T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin' } as CalendarEvent,
+ { attendees: [{ name: 'Land', email: USER2! }] },
+ me,
+ );
+ const created = await useCalendarStore.getState().createEvent({
+ ...seriesData('cancelled', '2027-10-04T10:00:00', 4),
+ participants: invitation.updates.participants,
+ organizerCalendarAddress: invitation.updates.organizerCalendarAddress,
+ }, calendarId, { sendSchedulingMessages: true });
+ const uid = created.uid!;
+ const copies = await waitFor(() => seriesByUid(other!, uid), (list) => list.length > 0);
+ expect(copies, 'the invitation reached the second user').toHaveLength(1);
+
+ // The organizer cancels the second Monday. The attendee's 1.0 keeps
+ // it, marked cancelled; 0.16 takes it out itself.
+ const occurrences = await loadOccurrences(uid, after, before);
+ const second = occurrences.find((e) => (e.recurrenceId ?? '').startsWith('2027-10-11'))!;
+ expect(await deleteOccurrence('this', { event: second, userEmails: me }, storeActions())).toBe(true);
+ const key = '2027-10-11T10:00:00';
+ const marked = await waitFor(() => seriesByUid(other!, uid), (list) =>
+ /cancelled|excluded/.test(JSON.stringify(list[0]?.recurrenceOverrides?.[key] ?? null)));
+ const entry = marked[0].recurrenceOverrides?.[key] as { sequence?: number; excluded?: boolean } | undefined;
+ expect(JSON.stringify(entry ?? null)).toMatch(/cancelled|excluded/);
+
+ const one = await removeAsOther({
+ uid,
+ recurrenceId: key,
+ recurrenceIdTimeZone: 'Europe/Berlin',
+ sequence: entry?.sequence ?? 0,
+ });
+ expect(one.kind).toBe(entry?.excluded ? 'none' : 'exclude');
+ const [excluded] = await seriesByUid(other!, uid);
+ expect(excluded.recurrenceOverrides?.[key]).toEqual({ excluded: true });
+ expect(excluded.status).not.toBe('cancelled');
+
+ // An older cancellation of the whole meeting is not applied.
+ const outdated = await removeAsOther({ uid, sequence: -1 });
+ expect(outdated.kind).toBe('outdated');
+
+ // The organizer cancels the series.
+ const left = await loadOccurrences(uid, after, before);
+ expect(await deleteOccurrence('all', { event: left[0], userEmails: me }, storeActions())).toBe(true);
+ // Both servers keep it, marked cancelled.
+ const marked2 = await waitFor(() => seriesByUid(other!, uid), (list) => list[0]?.status === 'cancelled');
+ expect(marked2[0]?.status).toBe('cancelled');
+ const all = await removeAsOther({ uid, sequence: marked2[0].sequence ?? 0 });
+ expect(all.kind).toBe('delete');
+ expect(await seriesByUid(other!, uid)).toEqual([]);
+ }, 120_000);
+ });
+
liveTwo('shares', () => {
let mailboxId = '';
let sharedCalendarId = '';
@@ -698,6 +783,59 @@ live('live Stalwart calendar', () => {
expect(list).toHaveLength(1);
expect(list[0].recurrenceOverrides?.['2027-10-11T10:00:00']?.title).toBe(`${RUN} owner second`);
}, 60_000);
+
+ it('moves a task into the shared calendar and back out of it (#1204)', async () => {
+ expect(sharedCalendarId, 'the calendar shared above').toBeTruthy();
+ await connectSingleton();
+ const [[, made]] = await raw(client, [['CalendarEvent/set', {
+ accountId: client.accountId,
+ create: { t: { '@type': 'Task', title: `${RUN} moving task`, calendarIds: { [calendarId]: true }, due: '2027-11-03T10:00:00', timeZone: 'Europe/Berlin' } },
+ }, '0']]);
+ expect(made.created?.t?.id, JSON.stringify(made.notCreated)).toBeTruthy();
+ const store = useCalendarStore.getState();
+ await store.fetchCalendars();
+ await useCalendarStore.getState().fetchTasks();
+ const taskOf = () => useCalendarStore.getState().tasks.find((t) => t.title === `${RUN} moving task`);
+ const shared = useCalendarStore.getState().calendars.find((c) => c.name === `${RUN} team`)!;
+ const task = taskOf()!;
+ expect(task.accountId).toBeFalsy();
+
+ await useCalendarStore.getState().updateTask(task.id, { calendarIds: { [shared.id]: true } });
+ const inShared = (await raw(other!, [['CalendarEvent/query', { accountId: other!.accountId, filter: { inCalendar: sharedCalendarId } }, 'q'],
+ ['CalendarEvent/get', { accountId: other!.accountId, '#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' }, properties: ['id', 'uid', 'title', '@type'] }, 'g']]))
+ .find((r) => r[2] === 'g')?.[1].list as Record[];
+ const moved = inShared.find((e) => e.title === `${RUN} moving task`);
+ expect(moved?.uid).toBe(task.uid);
+ expect((await raw(client, [['CalendarEvent/get', { accountId: client.accountId, ids: [task.id], properties: ['id'] }, '0']]))[0][1].notFound)
+ .toEqual([task.id]);
+ expect(taskOf()?.accountId).toBe(other!.accountId);
+
+ // And back into the user's own calendar.
+ await useCalendarStore.getState().updateTask(taskOf()!.id, { calendarIds: { [calendarId]: true } });
+ expect(taskOf()?.accountId).toBeFalsy();
+ await removeRunEvents(client);
+ }, 60_000);
+ });
+
+ describe('a new calendar', () => {
+ let newCalendarId = '';
+
+ afterAll(async () => {
+ if (newCalendarId) {
+ await raw(client, [['Calendar/set', { accountId: client.accountId, destroy: [newCalendarId], onDestroyRemoveEvents: true }, '0']]).catch(() => undefined);
+ }
+ }, 30_000);
+
+ it('has its name, colour and rights in the store right after it is made', async () => {
+ await connectSingleton();
+ await useCalendarStore.getState().fetchCalendars();
+ const made = await useCalendarStore.getState().createCalendar(`${RUN} new`, '#0f766e');
+ newCalendarId = made.id;
+ const inStore = useCalendarStore.getState().calendars.find((c) => c.id === made.id);
+ expect(inStore).toMatchObject({ name: `${RUN} new`, color: '#0f766e' });
+ expect(inStore?.myRights?.mayDelete).toBe(true);
+ expect(inStore?.myRights?.mayWriteAll).toBe(true);
+ }, 30_000);
});
describe('a calendar\'s default reminders (#1209)', () => {
diff --git a/src/api/__tests__/live-stalwart-contacts.test.ts b/src/api/__tests__/live-stalwart-contacts.test.ts
index 9eb71442..1f012675 100644
--- a/src/api/__tests__/live-stalwart-contacts.test.ts
+++ b/src/api/__tests__/live-stalwart-contacts.test.ts
@@ -171,6 +171,29 @@ live('live Stalwart: contacts and filters (0.16 and 1.0)', () => {
// 1.0 refuses to destroy a book that holds cards without
// onDestroyRemoveContents (addressBookHasContents).
+ it('deletes a category from its contacts and puts it back on Undo', async () => {
+ const tag = `live-cat-${RUN}`;
+ const one = await createContact({
+ '@type': 'Card', version: '1.0', kind: 'individual', name: { full: `Live Cat One ${RUN}` },
+ keywords: { [tag]: true, keep: true },
+ } as Partial, bookId);
+ const two = await createContact({
+ '@type': 'Card', version: '1.0', kind: 'individual', name: { full: `Live Cat Two ${RUN}` },
+ keywords: { [tag]: true },
+ } as Partial, bookId);
+ createdCards.push(one.id, two.id);
+ await useContactsStore.getState().fetchContacts();
+
+ const ids = await useContactsStore.getState().removeKeyword(tag);
+ expect(ids.sort()).toEqual([one.id, two.id].sort());
+ expect((await getContact(one.id))?.keywords ?? {}).toEqual({ keep: true });
+ expect((await getContact(two.id))?.keywords?.[tag]).toBeFalsy();
+
+ await useContactsStore.getState().addKeyword(tag, ids);
+ expect((await getContact(one.id))?.keywords).toEqual({ [tag]: true, keep: true });
+ expect((await getContact(two.id))?.keywords).toEqual({ [tag]: true });
+ }, 60_000);
+
it('deletes an address book that still holds contacts', async () => {
const book = await createAddressBook(`live-contacts-${RUN}-full`);
const card = await createContact({ '@type': 'Card', version: '1.0', name: { full: `Live Book ${RUN}` } } as Partial, book.id);
diff --git a/src/api/__tests__/live-stalwart-files.test.ts b/src/api/__tests__/live-stalwart-files.test.ts
index 7c4d7d4e..3a9a2c41 100644
--- a/src/api/__tests__/live-stalwart-files.test.ts
+++ b/src/api/__tests__/live-stalwart-files.test.ts
@@ -34,6 +34,7 @@ import { CAPABILITIES } from '../types';
import type { FileNode } from '../types';
import {
copyFileNode,
+ copyFileTree,
createFolder,
deleteFileNodes,
destroyEmptyFolders,
@@ -161,6 +162,30 @@ live('live Stalwart files', () => {
expect(all.filter((n) => [copy.id, box.id, file.id, inner.id].includes(n.id))).toEqual([]);
}, 90_000);
+ it('copies a folder with the folders and files in it', async () => {
+ const source = await createFolder('Copy me', scratch);
+ const sub = await createFolder('Inside', source.id);
+ await uploadFileBatch([pick('top.txt', 'top')], source.id, []);
+ await uploadFileBatch([pick('deep.txt', 'deeper')], sub.id, []);
+ const all = await nodes();
+
+ const copy = await copyFileTree(all.find((n) => n.id === source.id)!, all, scratch, 'Copy me (1)');
+ const after = await nodes();
+ const copied = after.find((n) => n.id === copy.id)!;
+ expect(copied).toMatchObject({ name: 'Copy me (1)', parentId: scratch });
+ expect(fileNodeKind(copied)).toBe('directory');
+ const inCopy = after.filter((n) => n.parentId === copy.id);
+ expect(inCopy.map((n) => n.name).sort()).toEqual(['Inside', 'top.txt']);
+ const copiedSub = inCopy.find((n) => n.name === 'Inside')!;
+ expect(fileNodeKind(copiedSub)).toBe('directory');
+ const deep = after.find((n) => n.parentId === copiedSub.id)!;
+ expect(deep).toMatchObject({ name: 'deep.txt', size: 6 });
+ const res = await fetch(getFileNodeDownloadUrl(deep), { headers: { Authorization: client.authHeader } });
+ expect(await res.text()).toBe('deeper');
+ // The source is untouched.
+ expect(after.filter((n) => n.parentId === source.id).map((n) => n.name).sort()).toEqual(['Inside', 'top.txt']);
+ }, 90_000);
+
it('moves a node to the drive root and back', async () => {
const { uploaded } = await uploadFileBatch([pick('root-trip.txt', 'x')], scratch, []);
const id = uploaded[0].id;
diff --git a/src/api/__tests__/live-stalwart-mail.test.ts b/src/api/__tests__/live-stalwart-mail.test.ts
index 9cfe4a25..2c7c805d 100644
--- a/src/api/__tests__/live-stalwart-mail.test.ts
+++ b/src/api/__tests__/live-stalwart-mail.test.ts
@@ -45,8 +45,9 @@ import { pruneEmailSubmissions } from '../submissions';
import { listPrincipals } from '../files';
import { createContact, deleteContacts, getAddressBooks, getContact } from '../contacts';
import {
- createSieveScript, deleteSieveScript, getSieveScriptContent, getSieveScripts, validateSieveScript,
+ createSieveScript, deleteSieveScript, getSieveScriptContent, getSieveScripts, updateSieveScript, validateSieveScript,
} from '../sieve';
+import { useFilterStore } from '../../stores/filter-store';
import { inlineContactMediaForExport, writeContactWithNewPhoto } from '../../lib/contact-media';
import { getBulwarkMetadata, resetBulwarkMetadata, supportsBulwarkMetadata } from '../bulwark-metadata';
import { parseSharedTags, SHARED_TAGS_KEY } from '../../lib/shared-tags';
@@ -405,6 +406,47 @@ live('live Stalwart: mail, contacts, search (0.16 and 1.0)', () => {
}
}, 60_000);
+ it('deletes a filter rule at once and puts it back on Undo, in the script on the server', async () => {
+ // The account's filters as they are, to put back afterwards.
+ const before = await getSieveScripts();
+ const active = before.find((s) => s.isActive && s.name !== 'vacation');
+ const original = active ? await getSieveScriptContent(active.blobId) : null;
+ const activeContent = async () => {
+ const now = (await getSieveScripts()).find((s) => s.isActive && s.name !== 'vacation');
+ return now ? getSieveScriptContent(now.blobId) : '';
+ };
+ try {
+ await useFilterStore.getState().selectAccount(null);
+ expect(useFilterStore.getState().isOpaque, 'the test account has no hand-written filters').toBe(false);
+ const rule = (id: string) => ({
+ id, name: `live ${id} ${RUN}`, enabled: true, matchType: 'all' as const,
+ conditions: [{ field: 'subject' as const, comparator: 'contains' as const, value: `${id}-${RUN}` }],
+ actions: [{ type: 'mark_read' as const, value: '' }], stopProcessing: false,
+ });
+ useFilterStore.getState().addRule(rule('one'));
+ useFilterStore.getState().addRule(rule('two'));
+ await useFilterStore.getState().saveFilters();
+ expect(await activeContent()).toContain(`one-${RUN}`);
+
+ const undo = await useFilterStore.getState().deleteRuleNow('one');
+ let content = await activeContent();
+ expect(content).not.toContain(`one-${RUN}`);
+ expect(content).toContain(`two-${RUN}`);
+
+ expect(await undo!()).toBe(true);
+ content = await activeContent();
+ expect(content.indexOf(`one-${RUN}`)).toBeGreaterThan(-1);
+ expect(content.indexOf(`one-${RUN}`)).toBeLessThan(content.indexOf(`two-${RUN}`));
+ } finally {
+ const after = await getSieveScripts();
+ if (active && original !== null) await updateSieveScript(active.id, original, true);
+ for (const script of after) {
+ if (!before.some((b) => b.id === script.id)) await deleteSieveScript(script.id).catch(() => undefined);
+ }
+ useFilterStore.getState().clearState();
+ }
+ }, 60_000);
+
it('changes and restores an identity\'s name and signature', async () => {
const [identity] = await getIdentities();
const before = { name: identity.name, textSignature: identity.textSignature ?? '' };
diff --git a/src/api/calendar.ts b/src/api/calendar.ts
index d66e7471..a21b3ea5 100644
--- a/src/api/calendar.ts
+++ b/src/api/calendar.ts
@@ -1250,23 +1250,43 @@ export async function createCalendar(
const accountId = targetAccountId || jmapClient.accountId;
const props: Record = { name, color, isVisible: true, isSubscribed: true };
if (description?.trim()) props.description = description.trim();
+ // Stalwart's create answer never carries the name sent, and 1.0 answers
+ // `color: null` for one it stored. A get in the same request reads the
+ // calendar back with its rights, as the webmail re-reads it. The creation
+ // id stands for the new id: 0.16 refuses a result reference into
+ // `created`, both take `#new-cal`.
const res = await jmapClient.request(
- [['Calendar/set', {
- accountId,
- create: { 'new-cal': props },
- }, '0']],
+ [
+ ['Calendar/set', {
+ accountId,
+ create: { 'new-cal': props },
+ }, '0'],
+ ['Calendar/get', {
+ accountId,
+ ids: ['#new-cal'],
+ properties: CALENDAR_PROPERTIES,
+ }, '1'],
+ ],
USING,
);
const result = methodResult<{
created?: Record;
notCreated?: Record;
- }>(res);
+ }>(res, '0');
const created = result.created?.['new-cal'];
if (!created) {
const err = result.notCreated?.['new-cal'];
throw new Error(err?.description || err?.type || 'Failed to create calendar');
}
- return created;
+ let stored: Calendar | undefined;
+ try {
+ stored = methodResult<{ list?: Calendar[] }>(res, '1').list?.find((c) => c.id === created.id);
+ } catch {
+ // The calendar exists; what was sent stands in for the read-back.
+ }
+ if (stored) return { ...props, ...created, ...stored } as Calendar;
+ const echoed = Object.fromEntries(Object.entries(created).filter(([, v]) => v !== null));
+ return { ...props, ...echoed } as Calendar;
}
/**
diff --git a/src/api/files.ts b/src/api/files.ts
index 8907d2e2..64c9c02a 100644
--- a/src/api/files.ts
+++ b/src/api/files.ts
@@ -723,6 +723,66 @@ export async function copyFileNode(
return createFileNodeFromBlob(newName, node.blobId, node.type, parentId);
}
+/**
+ * Copy a file, or a folder with everything in it, into `parentId` (webmail
+ * copyFileNode). A folder node carries no content, so the subtree is made
+ * again level by level from `tree`, the listing the ids of `node` and
+ * `parentId` come from; the files in it point at the same blobs. A copy that
+ * fails part-way is taken back, the deepest level first, without
+ * onDestroyRemoveChildren: whatever someone else put into its folders
+ * meanwhile stays.
+ */
+export async function copyFileTree(
+ node: FileNode,
+ tree: FileNode[],
+ parentId: string | null,
+ newName: string = node.name,
+): Promise {
+ const kind = fileNodeKind(node);
+ if (kind === 'symlink') throw new Error('Symbolic links cannot be copied');
+ if (kind === 'file') return copyFileNode(node, parentId, newName);
+
+ const childrenOf = new Map();
+ for (const entry of tree) {
+ if (entry.parentId == null) continue;
+ const siblings = childrenOf.get(entry.parentId);
+ if (siblings) siblings.push(entry);
+ else childrenOf.set(entry.parentId, [entry]);
+ }
+ // The ids this copy creates, by level below the copy (0 is the copy itself).
+ const made: string[][] = [];
+ const seen = new Set([node.id]);
+ const create = async (source: FileNode, name: string, parent: string | null, level: number): Promise => {
+ const created = fileNodeKind(source) === 'directory'
+ ? await createFolder(name, parent)
+ : await createFileNodeFromBlob(name, source.blobId!, source.type, parent);
+ (made[level] ??= []).push(created.id);
+ return created;
+ };
+ const copyChildren = async (folderId: string, copyId: string, level: number): Promise => {
+ for (const child of childrenOf.get(folderId) ?? []) {
+ // Symlinks have nothing to copy; a copy inside the source is not copied again.
+ if (seen.has(child.id) || fileNodeKind(child) === 'symlink') continue;
+ if (fileNodeKind(child) === 'file' && !child.blobId) continue;
+ seen.add(child.id);
+ const created = await create(child, child.name, copyId, level);
+ if (fileNodeKind(child) === 'directory') await copyChildren(child.id, created.id, level + 1);
+ }
+ };
+
+ const root = await create(node, newName, parentId, 0);
+ seen.add(root.id);
+ try {
+ await copyChildren(node.id, root.id, 1);
+ } catch (err) {
+ for (let level = made.length - 1; level >= 0; level--) {
+ await destroyEmptyFolders(made[level] ?? []).catch(() => []);
+ }
+ throw err;
+ }
+ return root;
+}
+
// ── Sharing (RFC 9670) ────────────────────────────────────
// Add, update, or remove a principal's rights on an owned FileNode (file or
diff --git a/src/api/sieve-rules.ts b/src/api/sieve-rules.ts
index 672faf77..03ad09df 100644
--- a/src/api/sieve-rules.ts
+++ b/src/api/sieve-rules.ts
@@ -33,6 +33,16 @@ export class FiltersLoginChangedError extends Error {
}
}
+/** The account's filters as the server has them now (its active script, parsed). */
+export async function readAccountFilters(accountId: string = getSieveAccountId()): Promise {
+ const all = await getSieveScripts(accountId);
+ const scripts = all.filter((s) => s.name !== VACATION_SCRIPT_NAME);
+ const script = scripts.find((s) => s.isActive) || scripts[0] || null;
+ return script
+ ? parseScript(await getSieveScriptContent(script.blobId, accountId))
+ : { rules: [], isOpaque: false, externalRequires: [] };
+}
+
/**
* Read the account's script from the server, let `modify` change its rules,
* and write the result back as the active script, keeping the vacation
diff --git a/src/components/calendar/CalendarEditSheet.tsx b/src/components/calendar/CalendarEditSheet.tsx
index eb435147..b6130c0b 100644
--- a/src/components/calendar/CalendarEditSheet.tsx
+++ b/src/components/calendar/CalendarEditSheet.tsx
@@ -27,6 +27,9 @@ import { radius, spacing, typography, type ThemePalette } from '../../theme/toke
import { useColors } from '../../theme/colors';
import { useLocaleStore, type TranslateFn } from '../../stores/locale-store';
import { CALENDAR_COLOR_PALETTE, calendarColorName, getCalendarColor } from '../../lib/calendar-utils';
+import { customColorGrid, normalizeHexColor } from '../../lib/calendar-custom-color';
+
+const CUSTOM_COLOR_GRID = customColorGrid();
import Button from '../Button';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout';
@@ -150,6 +153,9 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
const [name, setName] = React.useState('');
const [color, setColor] = React.useState(CALENDAR_COLOR_PALETTE[0]);
const [description, setDescription] = React.useState('');
+ // The colour as typed in the hex field; a pick from the swatches fills it.
+ const [hexInput, setHexInput] = React.useState('');
+ const [moreColors, setMoreColors] = React.useState(false);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState(null);
// The calendar's default reminders (#1209): the owner's to set, for an
@@ -165,7 +171,10 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
React.useEffect(() => {
if (!visible) return;
setName(calendar?.name ?? '');
- setColor(calendar ? getCalendarColor(calendar) : CALENDAR_COLOR_PALETTE[0]);
+ const initialColor = calendar ? getCalendarColor(calendar) : CALENDAR_COLOR_PALETTE[0];
+ setColor(initialColor);
+ setHexInput(initialColor);
+ setMoreColors(false);
setDescription(calendar?.description ?? '');
setTimedReminders(initialTimed);
setAllDayReminders(initialAllDay);
@@ -173,9 +182,15 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
setSaving(false);
}, [visible, calendar, initialTimed, initialAllDay]);
+ const pickColor = (col: string) => {
+ setColor(col);
+ setHexInput(col);
+ };
+ const hexInvalid = normalizeHexColor(hexInput) === null;
+
const handleSave = async () => {
const trimmed = name.trim();
- if (!trimmed || saving) return;
+ if (!trimmed || saving || hexInvalid) return;
setSaving(true);
setError(null);
try {
@@ -224,7 +239,7 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
return (
setColor(col)}
+ onPress={() => pickColor(col)}
style={[styles.swatch, { backgroundColor: col }, active && styles.swatchActive]}
accessibilityRole="radio"
accessibilityLabel={calendarColorName(col, t)}
@@ -235,6 +250,62 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
);
})}
+
+
+ {
+ setHexInput(v);
+ const hex = normalizeHexColor(v);
+ if (hex) setColor(hex);
+ }}
+ placeholder="#3b82f6"
+ placeholderTextColor={c.textMuted}
+ autoCapitalize="none"
+ autoCorrect={false}
+ maxLength={7}
+ accessibilityLabel={t('calendar.management.custom_color', 'Custom color (hex)')}
+ style={[styles.input, styles.hexInput, hexInvalid && styles.inputInvalid]}
+ />
+ setMoreColors((v) => !v)}
+ style={styles.moreColors}
+ accessibilityRole="button"
+ accessibilityState={{ expanded: moreColors }}
+ >
+
+ {moreColors
+ ? t('calendar.management.fewer_colors', 'Fewer colors')
+ : t('calendar.management.more_colors', 'More colors')}
+
+
+
+ {hexInvalid && (
+
+ {t('calendar.management.color_invalid', 'Enter a color as # and six hex digits, such as #3b82f6.')}
+
+ )}
+ {moreColors && (
+
+ {CUSTOM_COLOR_GRID.map((row, r) => (
+
+ {row.map((col) => {
+ const active = color.toLowerCase() === col;
+ return (
+ pickColor(col)}
+ style={[styles.gridSwatch, { backgroundColor: col }, active && styles.swatchActive]}
+ accessibilityRole="radio"
+ accessibilityLabel={col}
+ accessibilityState={{ selected: active }}
+ />
+ );
+ })}
+
+ ))}
+
+ )}
{t('calendar.management.description', 'Description')}
{ void handleSave(); }}
- disabled={!name.trim() || saving}
+ disabled={!name.trim() || saving || hexInvalid}
icon={saving ? : undefined}
>
{calendar ? t('common.save', 'Save') : t('common.create', 'Create')}
@@ -336,6 +407,15 @@ function makeStyles(c: ThemePalette) {
justifyContent: 'center',
},
swatchActive: { borderWidth: 2, borderColor: c.text },
+ hexRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, marginTop: spacing.xs },
+ hexPreview: { width: 28, height: 28, borderRadius: radius.full, borderWidth: 1, borderColor: c.border },
+ hexInput: { flex: 1 },
+ inputInvalid: { borderColor: c.error },
+ moreColors: { paddingVertical: spacing.xs, paddingHorizontal: spacing.sm },
+ moreColorsText: { ...typography.captionMedium, color: c.primary },
+ grid: { gap: 4, marginTop: spacing.xs },
+ gridRow: { flexDirection: 'row', gap: 4 },
+ gridSwatch: { flex: 1, aspectRatio: 1, borderRadius: radius.sm, maxWidth: 28 },
error: { ...typography.caption, color: c.error },
footer: { flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.md },
sectionTitle: { ...typography.bodyMedium, color: c.text, marginTop: spacing.md },
diff --git a/src/components/calendar/CalendarSidebarDrawer.tsx b/src/components/calendar/CalendarSidebarDrawer.tsx
index 59ec99e6..f725a8f6 100644
--- a/src/components/calendar/CalendarSidebarDrawer.tsx
+++ b/src/components/calendar/CalendarSidebarDrawer.tsx
@@ -322,8 +322,9 @@ function Section({
const isSubscription = !!isSubscriptionCalendar?.(cal.id);
const isOwn = !cal.isShared && !isBirthday && !isSubscription;
const canSetDefault = !!onSetDefault && isOwn && !cal.isDefault;
- // Own calendars recolour server-side; shared ones locally (#345).
- const canRecolor = !!onSetColor && (!!cal.isShared || isOwn);
+ // Own calendars recolour server-side; shared ones and the birthdays
+ // locally (#345).
+ const canRecolor = !!onSetColor && (!!cal.isShared || isOwn || isBirthday);
const canRename = !!onRename && isOwn;
const canShare = !!onShare && isOwn && (!cal.myRights || cal.myRights.mayShare !== false);
const canClear = !!onClear && isOwn && isWritableCalendar(cal);
diff --git a/src/components/calendar/EventBlock.tsx b/src/components/calendar/EventBlock.tsx
index 46e3df8c..57cce7c5 100644
--- a/src/components/calendar/EventBlock.tsx
+++ b/src/components/calendar/EventBlock.tsx
@@ -54,6 +54,8 @@ interface TimedEventBlockProps {
/** Position and size in the day column. */
style: StyleProp;
onPress?: () => void;
+ /** What a screen reader says: title, date, time (see lib/calendar-a11y). */
+ accessibilityLabel?: string;
}
/** A timed event in a day column. */
@@ -66,11 +68,14 @@ export function TimedEventBlock({
continuesAfter,
style,
onPress,
+ accessibilityLabel,
}: TimedEventBlockProps) {
const inactive = colors.border !== null;
return (
;
onPress?: () => void;
+ /** What a screen reader says: title, date, time (see lib/calendar-a11y). */
+ accessibilityLabel?: string;
}
/** An all-day or multi-day event in the strip above the grid. */
@@ -124,10 +131,13 @@ export function AllDayEventBar({
continuesAfter,
style,
onPress,
+ accessibilityLabel,
}: AllDayEventBarProps) {
const inactive = colors.border !== null;
return (
void;
onRsvp?: (event: CalendarEvent, participantId: string, status: RsvpStatus) => void | Promise;
+ /** Append a quick note to the description (webmail's "Add note"); offered where the event can be edited. */
+ onSaveNote?: (event: CalendarEvent, note: string) => Promise;
}
function formatRange(event: CalendarEvent, timeFormat: TimeFormat | undefined, locale: Locale): string {
@@ -134,10 +137,20 @@ export function EventDetailSheet({
onExport,
onCopyLink,
onRsvp,
+ onSaveNote,
}: EventDetailSheetProps) {
const c = useColors();
const { locale, t } = useCalendarLocale();
const [rsvpBusy, setRsvpBusy] = React.useState(false);
+ const [noteOpen, setNoteOpen] = React.useState(false);
+ const [noteText, setNoteText] = React.useState('');
+ const [noteBusy, setNoteBusy] = React.useState(false);
+ // A note typed for one event is not left for the next.
+ const eventId = event?.id;
+ React.useEffect(() => {
+ setNoteOpen(false);
+ setNoteText('');
+ }, [eventId]);
const styles = React.useMemo(() => makeStyles(c), [c]);
// Bare addresses (the organizer above all — Stalwart drops its display
// name) render with the contact card's name instead of the raw email.
@@ -230,6 +243,26 @@ export function EventDetailSheet({
}
};
+ const copyTitle = () => {
+ if (!event) return;
+ void Clipboard.setStringAsync(event.title || '').then(() => {
+ toast.success(t('calendar.notifications.title_copied', 'Title copied'));
+ }).catch(() => {});
+ };
+
+ const saveNote = async () => {
+ const note = noteText.trim();
+ if (!event || !onSaveNote || !note || noteBusy) return;
+ setNoteBusy(true);
+ try {
+ await onSaveNote(event, note);
+ setNoteText('');
+ setNoteOpen(false);
+ } finally {
+ setNoteBusy(false);
+ }
+ };
+
const copyLocation = () => {
if (!location) return;
void Clipboard.setStringAsync(location).then(() => {
@@ -333,6 +366,38 @@ export function EventDetailSheet({
linkify
/>
) : null}
+ {onSaveNote && canEdit && (noteOpen ? (
+
+
+
+ { setNoteText(''); setNoteOpen(false); }} style={styles.noteButton} accessibilityRole="button">
+ {t('calendar.form.cancel', 'Cancel')}
+
+ { void saveNote(); }}
+ disabled={!noteText.trim() || noteBusy}
+ style={[styles.noteButton, (!noteText.trim() || noteBusy) && { opacity: 0.5 }]}
+ accessibilityRole="button"
+ >
+ {t('calendar.detail.save_note', 'Save note')}
+
+
+
+ ) : (
+ setNoteOpen(true)} style={styles.noteToggle} accessibilityRole="button">
+
+ {t('calendar.detail.add_note', 'Add note')}
+
+ ))}
{recurrence && (
}
@@ -419,7 +484,7 @@ export function EventDetailSheet({
)}
- {(canEdit || onDuplicate || onExport || (onCopyLink && videoUri)) && (
+ {(
{onEdit && canEdit && (
onExport(event)}
/>
)}
+ }
+ label={t('calendar.events.copy_title_short', 'Title')}
+ onPress={copyTitle}
+ />
{onCopyLink && videoUri && (
}
@@ -669,6 +739,24 @@ function makeStyles(c: ThemePalette) {
joinBtnText: { ...typography.bodyMedium, color: c.primary },
rsvpBlock: { gap: spacing.sm, marginTop: spacing.xs },
+ noteToggle: { flexDirection: 'row', alignItems: 'center', gap: spacing.md, paddingVertical: spacing.xs },
+ noteToggleText: { ...typography.body, color: c.textMuted },
+ noteBlock: { gap: spacing.xs },
+ noteInput: {
+ ...typography.body,
+ color: c.text,
+ borderWidth: 1,
+ borderColor: c.border,
+ borderRadius: radius.md,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm,
+ minHeight: 56,
+ textAlignVertical: 'top',
+ },
+ noteButtons: { flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.sm },
+ noteButton: { paddingHorizontal: spacing.md, paddingVertical: spacing.xs },
+ noteCancel: { ...typography.bodyMedium, color: c.textMuted },
+ noteSave: { ...typography.bodyMedium, color: c.primary },
rsvpPrompt: { ...typography.bodyMedium, color: c.text },
rsvpButtons: { flexDirection: 'row', gap: spacing.sm },
rsvpBtn: {
diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx
index f3041203..108eb870 100644
--- a/src/components/calendar/EventModal.tsx
+++ b/src/components/calendar/EventModal.tsx
@@ -79,6 +79,8 @@ import { SafeAreaModal } from '../SafeAreaModal';
import { useKeyboardObstruction } from '../../lib/use-keyboard-obstruction';
import { editorKeyboardInset } from '../../lib/calendar-editor-keyboard';
import ToggleSwitch from '../ToggleSwitch';
+import { askBeforeDiscard, editorFormChanged, editorFormSnapshot } from '../../lib/calendar-editor-discard';
+import { saveOfferingWithoutInvitations } from '../../lib/calendar-scheduling-denied';
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
@@ -254,12 +256,34 @@ export function EventModal({
const [showEndTime, setShowEndTime] = React.useState(false);
const [saving, setSaving] = React.useState(false);
+ // What the form holds against what it opened with: Close and Back ask
+ // before they drop changes (webmail 13f9f27c4).
+ const formSnapshot = React.useMemo(() => editorFormSnapshot({
+ title, description, allDay, start, end, calendarId, attendees, recurrence, customRule,
+ reminders, useDefaults, preserved, sendInvitations, location, videoUrl,
+ }), [title, description, allDay, start, end, calendarId, attendees, recurrence, customRule,
+ reminders, useDefaults, preserved, sendInvitations, location, videoUrl]);
+ const [baseline, setBaseline] = React.useState(null);
+ // Before the reset below: the reset clears the baseline after this took
+ // the form of the render before it, and the next render takes the
+ // seeded form.
+ React.useEffect(() => {
+ if (!visible) {
+ if (baseline !== null) setBaseline(null);
+ return;
+ }
+ if (baseline === null) setBaseline(formSnapshot);
+ }, [visible, baseline, formSnapshot]);
+ const dirty = visible && editorFormChanged(baseline, formSnapshot);
+ const requestClose = () => askBeforeDiscard(dirty, t, onClose);
+
// Reset the form when the editor opens or switches to another event only.
// `calendars` and `currentUserEmails` get new identities when a calendar
// push or the alias lookup lands, which used to wipe what the user typed.
const formKey = visible ? event?.id ?? 'new' : null;
React.useEffect(() => {
if (!visible) return;
+ setBaseline(null);
if (event) {
setTitle(event.title || '');
setDescription(event.description || '');
@@ -306,7 +330,11 @@ export function EventModal({
// The calendars can arrive after a new-event form opened.
React.useEffect(() => {
- if (visible && !calendarId && fallbackCalendarId) setCalendarId(fallbackCalendarId);
+ if (visible && !calendarId && fallbackCalendarId) {
+ setCalendarId(fallbackCalendarId);
+ // Not a change of the user's.
+ setBaseline(null);
+ }
}, [visible, calendarId, fallbackCalendarId]);
React.useEffect(() => {
@@ -408,14 +436,18 @@ export function EventModal({
for (const key of Object.keys(data) as (keyof CalendarEvent)[]) {
if (data[key] === undefined) delete data[key];
}
- await onSave(data, calendarId, {
- // Only the organizer sends: on an invitation someone else sent, a
- // save would reach the organizer as a reply.
- sendSchedulingMessages: organizes && attendees.length > 0 && sendInvitations,
+ // Only the organizer sends: on an invitation someone else sent, a
+ // save would reach the organizer as a reply.
+ const send = organizes && attendees.length > 0 && sendInvitations;
+ // A server that refuses to send the invitations refuses the whole
+ // save: offer to keep the event without them.
+ const saved = await saveOfferingWithoutInvitations((sendSchedulingMessages) => onSave(data, calendarId, {
+ sendSchedulingMessages,
// The dates as shown: a series moves by the occurrence's move, on
// its own clock (lib/recurrence-scope-edit).
edit: { attendees, when: { allDay, start, end }, ruleChanged },
- });
+ }), send, t);
+ if (!saved) return;
onClose();
} catch (err) {
// The account changed under the save: the event is the previous
@@ -492,12 +524,12 @@ export function EventModal({
visible={visible}
animationType="slide"
presentationStyle="fullScreen"
- onRequestClose={onClose}
+ onRequestClose={requestClose}
>
onSelectDate(d)}
onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined}
+ accessibilityRole="button"
+ accessibilityLabel={dayCellAccessibilityLabel(d, dayEvents.length, t, locale)}
+ accessibilityState={{ selected }}
>
@@ -341,7 +347,7 @@ function FullWeekRow({
onPress={() => onSelectDate(d)}
onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined}
accessibilityRole="button"
- accessibilityLabel={format(d, 'PPPP', { locale })}
+ accessibilityLabel={dayCellAccessibilityLabel(d, dayEvents.length, t, locale)}
>
diff --git a/src/components/calendar/ParticipantInput.tsx b/src/components/calendar/ParticipantInput.tsx
index 34162d9b..2b03c95e 100644
--- a/src/components/calendar/ParticipantInput.tsx
+++ b/src/components/calendar/ParticipantInput.tsx
@@ -10,9 +10,10 @@ import {
import { X } from '../icons';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
-import { useContactsStore } from '../../stores/contacts-store';
+import { useContactsStore, type RecipientSuggestion } from '../../stores/contacts-store';
import { useLocaleStore } from '../../stores/locale-store';
import type { Attendee } from '../../lib/calendar-participants';
+import { participantSuggestions, participantsFromSuggestion } from '../../lib/calendar-participant-suggestions';
interface ParticipantInputProps {
attendees: Attendee[];
@@ -20,28 +21,10 @@ interface ParticipantInputProps {
onRemove: (email: string) => void;
}
-interface Suggestion {
- email: string;
- name?: string;
-}
-
function emailRegex(): RegExp {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
}
-function flattenContactEmails(): Suggestion[] {
- const contacts = useContactsStore.getState().contacts;
- const out: Suggestion[] = [];
- for (const c of contacts) {
- const name = c.name?.full || undefined;
- if (!c.emails) continue;
- for (const e of Object.values(c.emails)) {
- if (e.address) out.push({ email: e.address, name });
- }
- }
- return out;
-}
-
// Attendee rows only: the organizer participant is added by
// buildParticipantMap on save (see lib/calendar-participants).
export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInputProps) {
@@ -49,25 +32,29 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const [draft, setDraft] = React.useState('');
- const [allSuggestions] = React.useState(() => flattenContactEmails());
+ // Contacts, groups and recent Sent recipients, as the composer offers them.
+ const getAutocomplete = useContactsStore((s) => s.getAutocomplete);
+ const getGroupRecipients = useContactsStore((s) => s.getGroupRecipients);
+ const contactsVersion = useContactsStore((s) => s.contacts);
+ const recentVersion = useContactsStore((s) => s.recentRecipients);
const existingEmails = React.useMemo(() => {
return new Set(attendees.map((a) => a.email.toLowerCase()));
}, [attendees]);
- const filteredSuggestions = React.useMemo(() => {
- const q = draft.trim().toLowerCase();
- if (!q || q.length < 2) return [];
- return allSuggestions
- .filter((s) => {
- if (existingEmails.has(s.email.toLowerCase())) return false;
- return (
- s.email.toLowerCase().includes(q) ||
- s.name?.toLowerCase().includes(q)
- );
- })
- .slice(0, 6);
- }, [draft, allSuggestions, existingEmails]);
+ const filteredSuggestions = React.useMemo(
+ () => participantSuggestions(draft, existingEmails, getAutocomplete, getGroupRecipients),
+ // contactsVersion/recentVersion re-run the lookup when the store loads.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [draft, existingEmails, getAutocomplete, getGroupRecipients, contactsVersion, recentVersion],
+ );
+
+ const pickSuggestion = (s: RecipientSuggestion) => {
+ for (const person of participantsFromSuggestion(s, existingEmails, getGroupRecipients)) {
+ if (emailRegex().test(person.email.trim())) onAdd({ name: person.name, email: person.email.trim() });
+ }
+ setDraft('');
+ };
const addParticipant = (email: string, name?: string) => {
const trimmed = email.trim();
@@ -117,6 +104,10 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
+ // The app's own suggestions only: system autofill would cover them.
+ autoComplete="off"
+ importantForAutofill="no"
+ textContentType="none"
onSubmitEditing={handleSubmit}
onBlur={handleSubmit}
returnKeyType="done"
@@ -131,15 +122,19 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu
>
{filteredSuggestions.map((s) => (
[
styles.suggestionRow,
pressed && styles.suggestionRowPressed,
]}
- onPress={() => addParticipant(s.email, s.name)}
+ onPress={() => pickSuggestion(s)}
>
- {s.name && {s.name}}
- {s.email}
+ {!!s.name && {s.name}}
+
+ {s.group
+ ? t('calendar.participants.group_members', '{count, plural, one {# member} other {# members}}', { count: s.group.memberCount })
+ : s.email}
+
))}
diff --git a/src/components/calendar/TasksSheet.tsx b/src/components/calendar/TasksSheet.tsx
index 5f6e0726..778ce71f 100644
--- a/src/components/calendar/TasksSheet.tsx
+++ b/src/components/calendar/TasksSheet.tsx
@@ -34,10 +34,12 @@ import {
emptyTaskEditor,
priorityToLevel,
submitTaskEditor,
+ taskEditorChanged,
taskEditorFromTask,
type PriorityLevel,
type TaskEditorState,
} from '../../lib/task-editor';
+import { askBeforeDiscard } from '../../lib/calendar-editor-discard';
import { isWritableCalendar } from '../../lib/calendar-editability';
import { useCalendarLocale } from '../../lib/calendar-locale';
import { formatCalendarDate } from '../../lib/date-format';
@@ -113,10 +115,13 @@ export function TasksSheet({
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
+ // Close, Back, a tap beside the sheet and a drag down ask before they drop
+ // a changed task (webmail 13f9f27c4); kept, a dragged sheet comes back.
+ const closeRef = React.useRef<() => void>(onClose);
const dragHandlers = useSheetDrag({
slideY,
closedY: Dimensions.get('window').height,
- onClose,
+ onClose: React.useCallback(() => closeRef.current(), []),
});
const writableCalendars = React.useMemo(
@@ -127,6 +132,12 @@ export function TasksSheet({
const [filter, setFilter] = React.useState('all');
const [editor, setEditor] = React.useState(() => emptyTaskEditor(defaultCalendarId));
+ // The task as the editor was opened on it, to tell typed changes.
+ const [baseline, setBaseline] = React.useState(editor);
+ const seedEditor = (state: TaskEditorState) => {
+ setEditor(state);
+ setBaseline(state);
+ };
const [expanded, setExpanded] = React.useState(false);
const [showDatePicker, setShowDatePicker] = React.useState(false);
const [showTimePicker, setShowTimePicker] = React.useState(false);
@@ -136,10 +147,10 @@ export function TasksSheet({
if (!visible) return;
const initial = initialTaskId ? tasks.find((task) => task.id === initialTaskId) : undefined;
if (initial) {
- setEditor(taskEditorFromTask(initial, defaultCalendarId));
+ seedEditor(taskEditorFromTask(initial, defaultCalendarId));
setExpanded(true);
} else {
- setEditor(emptyTaskEditor(defaultCalendarId));
+ seedEditor(emptyTaskEditor(defaultCalendarId));
setExpanded(startNew);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -148,6 +159,7 @@ export function TasksSheet({
React.useEffect(() => {
if (visible && !editor.calendarId && defaultCalendarId) {
setEditor((e) => ({ ...e, calendarId: defaultCalendarId }));
+ setBaseline((b) => (b.calendarId ? b : { ...b, calendarId: defaultCalendarId }));
}
}, [visible, editor.calendarId, defaultCalendarId]);
@@ -189,10 +201,17 @@ export function TasksSheet({
}), [tasks]);
const resetEditor = () => {
- setEditor(emptyTaskEditor(defaultCalendarId));
+ seedEditor(emptyTaskEditor(defaultCalendarId));
setExpanded(false);
};
+ const dirty = visible && taskEditorChanged(baseline, editor);
+ const springBack = () => {
+ Animated.spring(slideY, { toValue: 0, useNativeDriver: true, bounciness: 0 }).start();
+ };
+ const requestClose = () => askBeforeDiscard(dirty, t, onClose, springBack);
+ closeRef.current = requestClose;
+
const handleSave = async () => {
if (!editor.title.trim() || !editor.calendarId || saving) return;
setSaving(true);
@@ -235,9 +254,9 @@ export function TasksSheet({
];
return (
-
+
-
+
@@ -250,7 +269,7 @@ export function TasksSheet({
{t('calendar.tasks.label', 'Tasks')}
)}
{editor.id && (
-
+ askBeforeDiscard(dirty, t, resetEditor)} style={styles.cancelEdit}>
{t('calendar.form.cancel', 'Cancel')}
)}
@@ -410,9 +429,11 @@ export function TasksSheet({
{
- if (!onUpdate) return;
- setEditor(taskEditorFromTask(task, defaultCalendarId));
- setExpanded(true);
+ if (!onUpdate || editor.id === task.id) return;
+ askBeforeDiscard(dirty, t, () => {
+ seedEditor(taskEditorFromTask(task, defaultCalendarId));
+ setExpanded(true);
+ });
}}
>
diff --git a/src/components/calendar/TimeGridScrollView.tsx b/src/components/calendar/TimeGridScrollView.tsx
index 8c226637..8ded28a2 100644
--- a/src/components/calendar/TimeGridScrollView.tsx
+++ b/src/components/calendar/TimeGridScrollView.tsx
@@ -20,6 +20,7 @@ import type { Calendar, CalendarEvent } from '../../api/types';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useCalendarLocale } from '../../lib/calendar-locale';
+import { eventAccessibilityLabel } from '../../lib/calendar-a11y';
import {
buildEventDayIndex,
dayKey,
@@ -439,6 +440,7 @@ function TimeGridScrollViewInner({
)}
continuesBefore={segment.continuesBefore}
continuesAfter={segment.continuesAfter}
+ accessibilityLabel={eventAccessibilityLabel(segment.event, { t, locale, timeFormat, calendars, currentUserEmails })}
onPress={() => onSelectEvent?.(segment.event)}
style={{
left: segment.startIndex * colWidth + 1,
@@ -592,7 +594,7 @@ const DayColumn = React.memo(function DayColumn({
/** Hour of the "New event" block in this column, or -1. */
draftHour: number;
}) {
- const { t } = useCalendarLocale();
+ const { locale, t } = useCalendarLocale();
const handleLongPress = React.useCallback(
(e: GestureResponderEvent) => {
onLongPressAt?.(day, hourAtOffset(e.nativeEvent.locationY, HOUR_HEIGHT));
@@ -630,6 +632,7 @@ const DayColumn = React.memo(function DayColumn({
ringColor={colors.background}
continuesBefore={continuesBefore}
continuesAfter={continuesAfter}
+ accessibilityLabel={eventAccessibilityLabel(event, { t, locale, timeFormat, calendars, currentUserEmails })}
onPress={() => onSelectEvent?.(event)}
style={{ top, height, left: `${column * widthPct}%`, width: `${widthPct}%` }}
/>
diff --git a/src/components/calendar/WeekView.tsx b/src/components/calendar/WeekView.tsx
index d3c156d9..0d7718c9 100644
--- a/src/components/calendar/WeekView.tsx
+++ b/src/components/calendar/WeekView.tsx
@@ -12,6 +12,7 @@ import type { Calendar, CalendarEvent } from '../../api/types';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useCalendarLocale } from '../../lib/calendar-locale';
+import { eventAccessibilityLabel } from '../../lib/calendar-a11y';
import {
buildEventDayIndex,
buildTimedFullDayWeekSegments,
@@ -202,6 +203,7 @@ function WeekViewInner({
)}
continuesBefore={segment.continuesBefore}
continuesAfter={segment.continuesAfter}
+ accessibilityLabel={eventAccessibilityLabel(segment.event, { t, locale, timeFormat, calendars, currentUserEmails })}
onPress={() => onSelectEvent?.(segment.event)}
style={{
left: `${leftPct}%`,
@@ -277,6 +279,7 @@ function WeekViewInner({
ringColor={c.background}
continuesBefore={continuesBefore}
continuesAfter={continuesAfter}
+ accessibilityLabel={eventAccessibilityLabel(event, { t, locale, timeFormat, calendars, currentUserEmails })}
onPress={() => onSelectEvent?.(event)}
style={{ top, height, left: `${leftPct}%`, width: `${widthPct}%` }}
/>
diff --git a/src/components/calendar/__tests__/participant-input-wiring.test.ts b/src/components/calendar/__tests__/participant-input-wiring.test.ts
new file mode 100644
index 00000000..abd8f2fd
--- /dev/null
+++ b/src/components/calendar/__tests__/participant-input-wiring.test.ts
@@ -0,0 +1,15 @@
+import { describe, expect, it } from 'vitest';
+import fs from 'node:fs';
+import path from 'node:path';
+
+// Checked on the source: the suite has no React Native renderer.
+const source = fs.readFileSync(path.resolve(__dirname, '../ParticipantInput.tsx'), 'utf8');
+
+describe('ParticipantInput wiring', () => {
+ it('offers no system autofill in the participant address field, as the composer\u2019s fields do', () => {
+ const input = source.slice(source.indexOf('', source.indexOf(' void;
+ onClose: () => void;
+}
+
+// The contact list's advanced filters (webmail contact-list.tsx): company,
+// job title, location, email domain, birthday month and has email / phone /
+// photo, each a three-way toggle (off, must have, must not have).
+export function ContactFiltersPanel({ filters, onChange, onClose }: Props) {
+ const c = useColors();
+ const styles = React.useMemo(() => makeStyles(c), [c]);
+ const t = useLocaleStore((s) => s.t);
+ const locale = useLocaleStore((s) => s.locale);
+ const monthNames = React.useMemo(() => {
+ const fmt = new Intl.DateTimeFormat(locale, { month: 'long' });
+ return Array.from({ length: 12 }, (_, i) => fmt.format(new Date(2000, i, 1)));
+ }, [locale]);
+ const set = (key: K, value: ContactListFilters[K]) =>
+ onChange({ ...filters, [key]: value });
+
+ const field = (key: 'organization' | 'jobTitle' | 'location' | 'emailDomain', label: string, placeholder: string) => (
+
+ {label}
+ set(key, v)}
+ placeholder={placeholder}
+ placeholderTextColor={c.textMuted}
+ accessibilityLabel={label}
+ autoCapitalize="none"
+ autoCorrect={false}
+ style={styles.input}
+ />
+
+ );
+
+ const toggle = (key: 'hasEmail' | 'hasPhone' | 'hasPhoto', label: string, icon: React.ReactNode) => {
+ const value: TriState = filters[key];
+ return (
+ set(key, cycleTri(value))}
+ style={[styles.chip, value === true && styles.chipOn, value === false && styles.chipNot]}
+ accessibilityRole="button"
+ accessibilityState={{ checked: value === null ? false : value === true ? true : 'mixed' }}
+ >
+ {icon}
+ {label}
+
+ );
+ };
+
+ return (
+
+
+ {t('contacts.filters.title', 'Advanced filters')}
+ {countActiveFilters(filters) > 0 && (
+ onChange(EMPTY_CONTACT_FILTERS)} hitSlop={6} accessibilityRole="button">
+ {t('contacts.filters.clear', 'Clear')}
+
+ )}
+
+
+
+
+
+ {field('organization', t('contacts.filters.organization', 'Company'), t('contacts.filters.organization_placeholder', 'e.g. Acme Corp'))}
+ {field('jobTitle', t('contacts.filters.job_title', 'Job title'), t('contacts.filters.job_title_placeholder', 'e.g. Designer'))}
+
+
+ {field('location', t('contacts.filters.location', 'Location'), t('contacts.filters.location_placeholder', 'City or country'))}
+ {field('emailDomain', t('contacts.filters.email_domain', 'Email domain'), t('contacts.filters.email_domain_placeholder', 'example.com'))}
+
+ {t('contacts.filters.birthday_month', 'Birthday in')}
+
+ set('birthdayMonth', null)}
+ style={[styles.chip, filters.birthdayMonth === null && styles.chipOn]}
+ accessibilityRole="radio"
+ accessibilityState={{ selected: filters.birthdayMonth === null }}
+ >
+ {t('contacts.filters.any_month', 'Any month')}
+
+ {monthNames.map((name, i) => {
+ const active = filters.birthdayMonth === i + 1;
+ return (
+ set('birthdayMonth', i + 1)}
+ style={[styles.chip, active && styles.chipOn]}
+ accessibilityRole="radio"
+ accessibilityState={{ selected: active }}
+ >
+ {name}
+
+ );
+ })}
+
+
+ {toggle('hasEmail', t('contacts.filters.has_email', 'Has email'), )}
+ {toggle('hasPhone', t('contacts.filters.has_phone', 'Has phone'), )}
+ {toggle('hasPhoto', t('contacts.filters.has_photo', 'Has photo'), )}
+
+
+ );
+}
+
+function makeStyles(c: ThemePalette) {
+ return StyleSheet.create({
+ panel: {
+ marginHorizontal: spacing.lg,
+ marginBottom: spacing.sm,
+ padding: spacing.md,
+ gap: spacing.sm,
+ borderRadius: radius.md,
+ borderWidth: 1,
+ borderColor: c.border,
+ backgroundColor: c.surface,
+ },
+ header: { flexDirection: 'row', alignItems: 'center', gap: spacing.md },
+ title: { ...typography.bodyMedium, color: c.text, flex: 1 },
+ clear: { ...typography.captionMedium, color: c.primary },
+ row: { flexDirection: 'row', gap: spacing.sm },
+ field: { flex: 1, gap: 2 },
+ label: { ...typography.caption, color: c.textMuted },
+ input: {
+ ...typography.body,
+ color: c.text,
+ borderWidth: 1,
+ borderColor: c.border,
+ borderRadius: radius.sm,
+ paddingHorizontal: spacing.sm,
+ paddingVertical: 6,
+ backgroundColor: c.background,
+ },
+ months: { gap: spacing.xs },
+ toggles: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs },
+ chip: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 4,
+ paddingHorizontal: spacing.sm,
+ paddingVertical: 4,
+ borderRadius: radius.full,
+ borderWidth: 1,
+ borderColor: c.border,
+ backgroundColor: c.background,
+ },
+ chipOn: { backgroundColor: c.primaryBg, borderColor: c.primary },
+ chipNot: { backgroundColor: c.surface },
+ chipText: { ...typography.caption, color: c.textMuted },
+ chipTextOn: { color: c.primary },
+ chipTextNot: { textDecorationLine: 'line-through' },
+ });
+}
diff --git a/src/components/contacts/ContactsSidebarDrawer.tsx b/src/components/contacts/ContactsSidebarDrawer.tsx
index 299c0bf3..6e47ecda 100644
--- a/src/components/contacts/ContactsSidebarDrawer.tsx
+++ b/src/components/contacts/ContactsSidebarDrawer.tsx
@@ -6,8 +6,9 @@ import { SafeAreaView } from 'react-native-safe-area-context';
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,
+ X, Users, Tag, BookUser, Inbox, ChevronDown, ChevronRight, Plus, Share2, Check, Trash2,
} from '../icons';
+import { toast } from '../../stores/toast-store';
import type { RootStackParamList } from '../../navigation/types';
import type { AddressBook } from '../../api/types';
import type { ContactCategory } from '../../stores/contacts-store';
@@ -46,6 +47,8 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
const contacts = useContactsStore((s) => s.contacts);
const addressBooks = useContactsStore((s) => s.addressBooks);
const renameKeyword = useContactsStore((s) => s.renameKeyword);
+ const removeKeyword = useContactsStore((s) => s.removeKeyword);
+ const addKeyword = useContactsStore((s) => s.addKeyword);
const totalCount = React.useMemo(
() => contacts.filter((c) => !isGroup(c)).length,
@@ -150,6 +153,31 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
}
};
+ // A category can be put back as it was, so it goes at once with an Undo
+ // (webmail 1.19 useDeleteCategory).
+ const deleteCategory = async (keyword: string) => {
+ if (renameBusy) return;
+ setRenameBusy(true);
+ let ids: string[];
+ try {
+ ids = await removeKeyword(keyword);
+ setRenaming(null);
+ } catch {
+ toast.error(t('contacts.category_delete_failed', "Couldn't delete the category"));
+ return;
+ } finally {
+ setRenameBusy(false);
+ }
+ toast.success(t('contacts.category_deleted', 'Category "{name}" deleted', { name: keyword }), {
+ action: {
+ label: t('contacts.undo', 'Undo'),
+ onPress: () => {
+ void addKeyword(keyword, ids).catch(() => toast.error(t('contacts.toast.error_update', 'Failed to update contact')));
+ },
+ },
+ });
+ };
+
const renderBook = (book: AddressBook & { count: number }) => (
+ { void deleteCategory(kw.keyword); }}
+ hitSlop={6}
+ style={styles.renameBtn}
+ accessibilityRole="button"
+ accessibilityLabel={t('contacts.delete_category', 'Delete category')}
+ >
+
+
setRenaming(null)}
hitSlop={6}
diff --git a/src/components/email/CalendarInvitationBanner.tsx b/src/components/email/CalendarInvitationBanner.tsx
index 52c94a1d..01f2fb1b 100644
--- a/src/components/email/CalendarInvitationBanner.tsx
+++ b/src/components/email/CalendarInvitationBanner.tsx
@@ -3,7 +3,7 @@ import { View, Text, StyleSheet, Pressable, ActivityIndicator } from 'react-nati
import { openExternalUrl } from '../../lib/open-url';
import {
CalendarPlus, Check, HelpCircle, X, MapPin, Video, Clock, CalendarDays, AlertTriangle,
- ShieldCheck, ShieldAlert, ChevronDown,
+ ShieldCheck, ShieldAlert, ChevronDown, Trash2,
} from '../icons';
import { format, isSameDay } from 'date-fns';
import type { Calendar, Email, CalendarEvent } from '../../api/types';
@@ -32,6 +32,13 @@ import { canCreateEventsIn } from '../../lib/calendar-editability';
import { getCalendarColor, timePattern } from '../../lib/calendar-utils';
import { getDateFnsLocale } from '../../lib/calendar-locale';
import { useCalendarSubscriptionsStore } from '../../stores/calendar-subscriptions-store';
+import {
+ canRemoveCancelled as canRemoveCancelledMeeting,
+ planCancellationRemoval,
+ removeCancelledMeeting,
+ sameRecurrenceId,
+ type CancellationRemoval,
+} from '../../lib/calendar-cancellation';
type BannerState = 'loading' | 'parsed' | 'done' | 'error';
type RsvpStatus = 'accepted' | 'tentative' | 'declined';
@@ -76,6 +83,8 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
const subscriptions = useCalendarSubscriptionsStore((s) => s.subscriptions);
const importEvents = useCalendarStore((s) => s.importEvents);
const rsvpEvent = useCalendarStore((s) => s.rsvpEvent);
+ const deleteEvent = useCalendarStore((s) => s.deleteEvent);
+ const updateEvent = useCalendarStore((s) => s.updateEvent);
const attachment = React.useMemo(() => findCalendarAttachment(email), [email]);
// Login address + identities + aliases, so invitations addressed to an
// alias still show the RSVP buttons. Only an invitation looks the aliases up.
@@ -97,6 +106,9 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
const [busy, setBusy] = React.useState(false);
const [calendarId, setCalendarId] = React.useState(null);
const [pickerOpen, setPickerOpen] = React.useState(false);
+ // What removing a cancelled meeting from the calendar takes, read from the
+ // events stored with its uid; null until known (webmail #1425).
+ const [cancelRemoval, setCancelRemoval] = React.useState(null);
React.useEffect(() => {
let cancelled = false;
@@ -139,6 +151,17 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
return () => { cancelled = true; };
}, [invitationKey, enabled, jmapAccountId]);
+ const isCancellation = state !== 'loading' && method === 'cancel';
+ React.useEffect(() => {
+ setCancelRemoval(null);
+ if (!isCancellation || !event?.uid) return;
+ let current = true;
+ findEventsByUid(event.uid)
+ .then((stored) => { if (current) setCancelRemoval(planCancellationRemoval(stored, event)); })
+ .catch(() => { /* no remove action then */ });
+ return () => { current = false; };
+ }, [isCancellation, event]);
+
// Import into the account's default calendar; never into a shared calendar,
// an iCal subscription (the next feed sync would delete the event) or a
// read-only one. The user can still pick another writable calendar.
@@ -255,6 +278,45 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
}
};
+ // A cancellation that fails authentication, or comes from someone other
+ // than the organizer without passing it, does not get to remove anything.
+ const senderAddress = email.from?.[0]?.email?.toLowerCase() ?? '';
+ const userIsSender = !!senderAddress && currentUserEmails.some((a) => a.toLowerCase() === senderAddress);
+ const canRemoveCancelled = canRemoveCancelledMeeting({
+ isCancellation, userIsSender, trustLevel: trust?.level, removal: cancelRemoval,
+ });
+ const cancellationOutdated = isCancellation && cancelRemoval?.kind === 'outdated';
+
+ const handleRemoveCancelled = async () => {
+ const cancel = event;
+ if (busy || !cancel.uid) return;
+ setBusy(true);
+ setNotice(null);
+ try {
+ const removal = await removeCancelledMeeting(cancel, {
+ findEventsByUid: (uid) => findEventsByUid(uid),
+ deleteEvent,
+ updateEvent,
+ });
+ if (removal.kind !== 'delete' && removal.kind !== 'exclude') {
+ setCancelRemoval(removal);
+ return;
+ }
+ // Occurrences of it in view go as well.
+ useCalendarStore.setState((s) => ({
+ events: s.events.filter((e) => e.uid !== cancel.uid
+ || (Boolean(cancel.recurrenceId) && !sameRecurrenceId(e, cancel))),
+ }));
+ setServerMatch(null);
+ setCancelRemoval({ kind: 'none' });
+ setNotice(t('calendar.invitation.removed_from_calendar', 'Removed from your calendar.'));
+ } catch {
+ setNotice(t('calendar.invitation.remove_error', 'Could not remove the event'));
+ } finally {
+ setBusy(false);
+ }
+ };
+
const trustColor = trust?.level === 'warning' ? c.error : trust?.level === 'caution' ? c.warning : c.success;
return (
@@ -270,6 +332,11 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
{method === 'cancel' && (
{t('calendar.invitation.cancelled', 'This event was cancelled')}
)}
+ {cancellationOutdated && (
+
+ {t('calendar.invitation.cancel_outdated', 'Your calendar has a newer version of this event than this cancellation.')}
+
+ )}
{method === 'reply' && (
{t('calendar.invitation.reply', 'A participant replied to this invitation')}
)}
@@ -360,7 +427,21 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
icon={}
disabled={busy} onPress={() => ensureImportedAndRsvp('declined')} c={c} styles={styles} />
>
- ) : !existing && method !== 'reply' ? (
+ ) : canRemoveCancelled ? (
+ { void handleRemoveCancelled(); }}
+ disabled={busy}
+ accessibilityRole="button"
+ >
+ {busy ? (
+
+ ) : (
+
+ )}
+ {t('calendar.invitation.remove_from_calendar', 'Remove from calendar')}
+
+ ) : !existing && method !== 'reply' && method !== 'cancel' ? (
{ void handleImport(); }}
@@ -521,6 +602,19 @@ function makeStyles(c: ThemePalette) {
backgroundColor: c.primary,
},
importBtnText: { ...typography.bodyMedium, color: c.primaryForeground },
+ removeBtn: {
+ flex: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: spacing.sm,
+ paddingVertical: spacing.sm,
+ borderRadius: radius.sm,
+ borderWidth: 1,
+ borderColor: c.errorBorder,
+ backgroundColor: c.background,
+ },
+ removeBtnText: { ...typography.bodyMedium, color: c.error },
warnRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs, marginTop: spacing.xs },
warnText: { ...typography.caption, color: c.warning },
});
diff --git a/src/components/filters/FilterRuleModal.tsx b/src/components/filters/FilterRuleModal.tsx
index 503cf800..7968b597 100644
--- a/src/components/filters/FilterRuleModal.tsx
+++ b/src/components/filters/FilterRuleModal.tsx
@@ -42,6 +42,8 @@ import type {
FilterActionType,
} from '../../lib/sieve/types';
import { SafeAreaModal } from '../SafeAreaModal';
+import { useFilterStore } from '../../stores/filter-store';
+import { forwardsOfEditedRule, ruleForwardLimit } from '../../lib/sieve/forward-limit';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
const ALL_FIELDS: FilterConditionField[] = ['from', 'to', 'cc', 'subject', 'header', 'size', 'body', 'attachment', 'all'];
@@ -72,12 +74,19 @@ interface FilterRuleModalProps {
mailboxes: Mailbox[];
/** The server can confine a rule to a period (Sieve "date" and "relational"). */
periodsSupported?: boolean;
+ /**
+ * The server's cap on forwards per message, and the forwards a message can
+ * have collected when it reaches this rule / still collect below it (see
+ * lib/sieve/forward-limit.ts). Left out: read from the filter list the
+ * Filters settings show.
+ */
+ forwardContext?: { maxRedirects: number | null | undefined; before: number; after: number };
onSave: (rule: FilterRule) => void;
onClose: () => void;
}
export function FilterRuleModal({
- visible, rule: editedRule, initialRule, mailboxes, periodsSupported = false, onSave, onClose,
+ visible, rule: editedRule, initialRule, mailboxes, periodsSupported = false, forwardContext, onSave, onClose,
}: FilterRuleModalProps) {
const c = useColors();
const styles = useMemo(() => makeStyles(c), [c]);
@@ -120,6 +129,25 @@ export function FilterRuleModal({
const mailboxTargets = useMemo(() => buildMailboxTargets(mailboxes), [mailboxes]);
+ // A message can collect only so many forwards on the server (#1155, #1208).
+ const listedRules = useFilterStore((s) => s.rules);
+ const listedForward = useFilterStore((s) => s.vacationForward);
+ const listedMaxRedirects = useFilterStore((s) => s.sieveCapabilities?.maxNumberRedirects);
+ const listedAround = useMemo(
+ () => forwardsOfEditedRule(listedRules, listedForward, editedRule?.id),
+ [listedRules, listedForward, editedRule?.id],
+ );
+ const forwardLimit = ruleForwardLimit(
+ { actions, stopProcessing },
+ forwardContext ? forwardContext.maxRedirects : listedMaxRedirects,
+ forwardContext ?? listedAround,
+ );
+ const ruleForwardCount = actions.filter((a) => a.type === 'forward').length;
+ const actionOptionsFor = (action: FilterAction) =>
+ action.type === 'forward' || forwardLimit.canAddForward
+ ? actionTypeOptions
+ : actionTypeOptions.filter((o) => o.value !== 'forward');
+
const fieldOptions = useMemo(
() => ALL_FIELDS.map((f) => ({ value: f, label: t(`settings.filters.condition_fields.${f}`, f) })),
[t],
@@ -407,7 +435,7 @@ export function FilterRuleModal({
+ {forwardLimit.unavailable && ruleForwardCount > 0 && (
+
+ {t('settings.filters.forward_unavailable', 'This server does not allow forwarding. Forward actions in filter rules have no effect.')}
+
+ )}
+ {forwardLimit.overLimit && (
+
+ {t('settings.filters.forward_limit', 'Forward limit per message on this server: {count}. Extra forwards are skipped.', { count: forwardLimit.limit ?? 0 })}
+
+ )}
setActions((prev) => [...prev, makeEmptyAction()])}
style={styles.addRow}
@@ -645,6 +683,7 @@ function makeStyles(c: ThemePalette) {
},
optionLabel: { ...typography.body, color: c.text, flex: 1 },
hint: { ...typography.caption, color: c.mutedForeground },
+ forwardWarning: { ...typography.caption, color: c.warning, marginTop: spacing.sm },
footer: {
flexDirection: 'row',
diff --git a/src/components/filters/QuickRuleSheet.tsx b/src/components/filters/QuickRuleSheet.tsx
index 316d96b4..94c1067a 100644
--- a/src/components/filters/QuickRuleSheet.tsx
+++ b/src/components/filters/QuickRuleSheet.tsx
@@ -5,7 +5,8 @@ import { MoveSheet } from '../MoveSheet';
import { FilterRuleModal } from './FilterRuleModal';
import type { Email, Mailbox } from '../../api/types';
import { getSieveAccountId, getSieveCapabilities } from '../../api/sieve';
-import { FiltersLoginChangedError, OpaqueFiltersError, updateAccountFilters } from '../../api/sieve-rules';
+import { FiltersLoginChangedError, OpaqueFiltersError, readAccountFilters, updateAccountFilters } from '../../api/sieve-rules';
+import { forwardsAround, inRunOrder } from '../../lib/sieve/forward-limit';
import { jmapClient } from '../../api/jmap-client';
import { servedLoginOf } from '../../lib/draft-login';
import { supportsPeriods } from '../../lib/sieve/period';
@@ -87,6 +88,26 @@ export function QuickRuleSheet({ visible, email, mailboxes, onClose }: QuickRule
const junk = React.useMemo(() => findJunkMailbox(mailboxes), [mailboxes]);
const periodsSupported = supportsPeriods(getSieveCapabilities()?.sieveExtensions);
+ // Where the new rule runs among the account's rules, for the server's
+ // redirect limit: at the top, behind the out of office forwarding
+ // (webmail quick-rule-host.tsx; lib/sieve/forward-limit.ts).
+ const editorOpen = visible && step.kind === 'editor';
+ const [forwards, setForwards] = React.useState({ before: 0, after: 0 });
+ React.useEffect(() => {
+ setForwards({ before: 0, after: 0 });
+ if (!editorOpen) return;
+ let current = true;
+ const accountId = openedIn.current.accountId ?? undefined;
+ readAccountFilters(accountId)
+ .then((parsed) => {
+ if (!current || parsed.isOpaque) return;
+ const runOrder = inRunOrder(parsed.rules, parsed.vacationForward);
+ setForwards(forwardsAround(runOrder, runOrder.length - parsed.rules.length, false));
+ })
+ .catch(() => undefined);
+ return () => { current = false; };
+ }, [editorOpen]);
+
const [sender] = subject.senders;
const senderLabel = subject.senders.length === 1
? (sender.name || sender.email)
@@ -248,6 +269,10 @@ export function QuickRuleSheet({ visible, email, mailboxes, onClose }: QuickRule
initialRule={step.kind === 'editor' ? step.rule : undefined}
mailboxes={mailboxes}
periodsSupported={periodsSupported}
+ forwardContext={{
+ maxRedirects: getSieveCapabilities(openedIn.current.accountId ?? undefined)?.maxNumberRedirects,
+ ...forwards,
+ }}
onSave={(rule) => void saveEditorRule(rule)}
onClose={finish}
/>
diff --git a/src/components/filters/SieveEditorSheet.tsx b/src/components/filters/SieveEditorSheet.tsx
index a62e1445..97337e49 100644
--- a/src/components/filters/SieveEditorSheet.tsx
+++ b/src/components/filters/SieveEditorSheet.tsx
@@ -11,6 +11,7 @@ import Button from '../Button';
import { useLocaleStore } from '../../stores/locale-store';
import { SafeAreaModal } from '../SafeAreaModal';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
+import { seedsOnRender } from '../../lib/sieve/editor-seed';
interface SieveEditorSheetProps {
visible: boolean;
@@ -30,14 +31,17 @@ export function SieveEditorSheet({ visible, content, onSave, onClose, onValidate
const [validationResult, setValidationResult] = useState<{ isValid: boolean; errors?: string[] } | null>(null);
const [showSaveWarning, setShowSaveWarning] = useState(false);
- // Re-seed the editor whenever it's (re)opened with fresh content. Keying the
- // editor on `content` while closed avoids a stale draft on the next open.
+ // Seed the editor when it opens, with the script as it is then. A new
+ // `content` while it is open (a store refresh) must not wipe what was
+ // typed (#1162).
+ const wasVisible = React.useRef(false);
React.useEffect(() => {
- if (visible) {
+ if (seedsOnRender(wasVisible.current, visible)) {
setScript(content);
setValidationResult(null);
setShowSaveWarning(false);
}
+ wasVisible.current = visible;
}, [visible, content]);
const lineCount = script.split('\n').length;
diff --git a/src/components/settings/VacationSettings.tsx b/src/components/settings/VacationSettings.tsx
index 87686feb..4af40e77 100644
--- a/src/components/settings/VacationSettings.tsx
+++ b/src/components/settings/VacationSettings.tsx
@@ -26,6 +26,9 @@ import {
parseLocalInput,
utcIsoToLocalInput,
} from '../../lib/vacation-dates';
+import { STALWART_VACATION_LIMITS, vacationOversize } from '../../lib/vacation-limits';
+import { jmapClient } from '../../api/jmap-client';
+import { getSieveCapabilities } from '../../api/sieve';
// Ported from the webmail's components/settings/vacation-settings.tsx. Dates
// are typed as local wall-clock time and sent as RFC 8621 UTCDate strings;
@@ -122,6 +125,11 @@ export function VacationSettings() {
// Only what was changed here is sent: the rest stays as the server has it,
// which another device may have changed since this one loaded.
const forwardChanged = forwardSettings !== undefined && !sameForward(forwardSettings, store.forward);
+ // Kept here, the message goes on through the filter rules, whose forwards
+ // share the server's limit with this one (#1208).
+ const forwardLimit = getSieveCapabilities(managedAccountId ?? undefined)?.maxNumberRedirects;
+ const forwardOverLimit = store.forwardAvailable && forwardEnabled && forwardKeep
+ && typeof forwardLimit === 'number' && forwardLimit > 0 && 1 + store.otherForwards > forwardLimit;
const fromParsed = parseLocalInput(fromDate);
const toParsed = parseLocalInput(toDate);
@@ -137,6 +145,22 @@ export function VacationSettings() {
return fromParsed < todayStart;
}, [fromParsed]);
+ // What a save sends, measured against the server's limits as you type.
+ const sizeLimits = jmapClient.hasAccountCapability('urn:stalwart:jmap', managedAccountId ?? undefined)
+ ? STALWART_VACATION_LIMITS
+ : null;
+ const oversize = useMemo(() => {
+ const sanitizedHtml = hasHtmlContent ? stripDangerousTags(htmlBody) : null;
+ const textBody = body.trim() || !sanitizedHtml ? body : htmlToPlainText(sanitizedHtml);
+ return vacationOversize({ subject: subject.trim(), textBody, htmlBody: sanitizedHtml }, sizeLimits);
+ }, [hasHtmlContent, htmlBody, body, subject, sizeLimits]);
+ const subjectTooLong = oversize.subject && sizeLimits
+ ? t('settings.vacation.warnings.subject_too_long', 'The subject is too long for the server (at most {max} bytes).', { max: sizeLimits.subject })
+ : null;
+ const bodyTooLong = oversize.body && sizeLimits
+ ? t('settings.vacation.warnings.body_too_long', 'The message is too long for the server (at most {max} bytes, formatting included).', { max: sizeLimits.body })
+ : null;
+
const warnings: string[] = [];
if (endBeforeStart) warnings.push(t('settings.vacation.warnings.end_before_start', 'End date must be after start date'));
if (startInPast) warnings.push(t('settings.vacation.warnings.start_in_past', 'Start date is in the past'));
@@ -155,7 +179,8 @@ export function VacationSettings() {
forwardChanged ||
audienceChanged;
- const canSave = hasChanges && !endBeforeStart && !formatError && !forwardInvalid && !store.isSaving;
+ const canSave = hasChanges && !endBeforeStart && !formatError && !forwardInvalid
+ && !oversize.subject && !oversize.body && !store.isSaving;
const handleSave = useCallback(async () => {
// Read the live editor DOM rather than trusting onChange state (issue #9).
@@ -342,8 +367,9 @@ export function VacationSettings() {
placeholder={t('settings.vacation.message.subject_placeholder', 'Out of Office')}
placeholderTextColor={c.mutedForeground}
accessibilityLabel={t('settings.vacation.message.subject_label', 'Subject')}
- style={styles.subjectInput}
+ style={[styles.subjectInput, oversize.subject && styles.inputInvalid]}
/>
+ {subjectTooLong && {subjectTooLong}}
{t('settings.vacation.message.body_label', 'Message Body')}
@@ -353,11 +379,12 @@ export function VacationSettings() {
onChangeText={setBody}
placeholder={t('settings.vacation.message.body_placeholder', 'Thank you for your email. I am currently out of the office and will respond when I return.')}
placeholderTextColor={c.mutedForeground}
- style={styles.bodyInput}
+ style={[styles.bodyInput, oversize.body && styles.inputInvalid]}
multiline
numberOfLines={6}
textAlignVertical="top"
/>
+ {bodyTooLong && {bodyTooLong}}
+ {forwardOverLimit && (
+
+ {t('settings.filters.forward_limit', 'Forward limit per message on this server: {count}. Extra forwards are skipped.', { count: forwardLimit })}
+
+ )}
>
)}
@@ -570,6 +602,8 @@ function makeStyles(c: ThemePalette) {
...typography.body,
},
inputInvalid: { borderColor: c.error },
+ fieldError: { ...typography.caption, color: c.error, marginTop: spacing.xs },
+ forwardWarning: { ...typography.caption, color: c.warning, paddingHorizontal: spacing.lg, paddingBottom: spacing.md },
subjectInput: {
paddingHorizontal: spacing.md,
paddingVertical: 6,
diff --git a/src/lib/__tests__/birthday-calendar-color.test.ts b/src/lib/__tests__/birthday-calendar-color.test.ts
new file mode 100644
index 00000000..8ea8c896
--- /dev/null
+++ b/src/lib/__tests__/birthday-calendar-color.test.ts
@@ -0,0 +1,11 @@
+import { describe, expect, it } from 'vitest';
+import { BIRTHDAY_CALENDAR_COLOR, createBirthdayCalendar } from '../birthday-calendar';
+import { useSettingsStore } from '../../stores/settings-store';
+
+describe('birthday calendar colour', () => {
+ it('takes the colour the user picked and keeps yellow until then', () => {
+ expect(createBirthdayCalendar('Birthdays', '#ef4444').color).toBe('#ef4444');
+ expect(createBirthdayCalendar('Birthdays').color).toBe(BIRTHDAY_CALENDAR_COLOR);
+ expect(useSettingsStore.getState().birthdayCalendarColor).toBe(BIRTHDAY_CALENDAR_COLOR);
+ });
+});
diff --git a/src/lib/__tests__/calendar-a11y.test.ts b/src/lib/__tests__/calendar-a11y.test.ts
new file mode 100644
index 00000000..413a9156
--- /dev/null
+++ b/src/lib/__tests__/calendar-a11y.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it, vi } from 'vitest';
+
+vi.hoisted(() => {
+ process.env.TZ = 'Europe/Berlin';
+});
+
+import { enUS } from 'date-fns/locale';
+import type { CalendarEvent } from '../../api/types';
+import { formatMessage } from '../../i18n/format';
+import { dayCellAccessibilityLabel, eventAccessibilityLabel } from '../calendar-a11y';
+import { useSettingsStore } from '../../stores/settings-store';
+
+const t = (_key: string, fallback?: string, params?: Record) =>
+ formatMessage(fallback ?? '', params, 'en');
+
+useSettingsStore.setState({ calendarTimeZone: 'auto' } as never);
+
+const event = (extra: Partial): CalendarEvent => ({
+ id: 'e1', uid: 'u1', title: 'Standup', calendarIds: { work: true },
+ start: '2026-11-02T09:00:00', duration: 'PT15M', timeZone: 'Europe/Berlin',
+ ...extra,
+} as CalendarEvent);
+
+describe('dayCellAccessibilityLabel', () => {
+ it('reads a month day cell as its full date and how many events it has', () => {
+ const day = new Date(2026, 10, 2);
+ expect(dayCellAccessibilityLabel(day, 3, t, enUS)).toMatch(/^Monday, Nov(ember)? 2, 2026, 3 events$/);
+ expect(dayCellAccessibilityLabel(day, 1, t, enUS)).toMatch(/, 1 event$/);
+ expect(dayCellAccessibilityLabel(day, 0, t, enUS)).toMatch(/^Monday, Nov(ember)? 2, 2026$/);
+ });
+});
+
+describe('eventAccessibilityLabel', () => {
+ const calendars = [{ id: 'work', name: 'Work' }];
+
+ it('reads an event block as title, date, time and calendar', () => {
+ expect(eventAccessibilityLabel(event({}), { t, locale: enUS, timeFormat: '24h', calendars }))
+ .toMatch(/^Standup, Monday, Nov(ember)? 2, 2026, 09:00 – 09:15, Work$/);
+ });
+
+ it('says all day for an all-day event and names a cancelled one', () => {
+ const allDay = event({ start: '2026-11-02T00:00:00', duration: 'P1D', showWithoutTime: true, timeZone: null as never });
+ expect(eventAccessibilityLabel(allDay, { t, locale: enUS, timeFormat: '24h' }))
+ .toMatch(/^Standup, Monday, Nov(ember)? 2, 2026, All day$/);
+ expect(eventAccessibilityLabel(event({ status: 'cancelled', title: '' }), { t, locale: enUS, timeFormat: '12h' }))
+ .toMatch(/^\(No title\), Monday, Nov(ember)? 2, 2026, 9:00 AM – 9:15 AM, Cancelled$/);
+ });
+});
diff --git a/src/lib/__tests__/calendar-cancellation.test.ts b/src/lib/__tests__/calendar-cancellation.test.ts
new file mode 100644
index 00000000..510a66e2
--- /dev/null
+++ b/src/lib/__tests__/calendar-cancellation.test.ts
@@ -0,0 +1,205 @@
+import { describe, expect, it, vi } from 'vitest';
+import { canRemoveCancelled, planCancellationRemoval, removeCancelledMeeting, type CancellationRemoval } from '../calendar-cancellation';
+import type { CalendarEvent } from '../../api/types';
+
+function stored(overrides: Partial): CalendarEvent {
+ return {
+ id: 'ev-1',
+ uid: 'meeting@example.com',
+ title: 'Weekly sync',
+ start: '2026-11-02T10:00:00',
+ timeZone: 'Europe/Berlin',
+ duration: 'PT1H',
+ sequence: 1,
+ recurrenceId: null,
+ recurrenceRules: null,
+ recurrenceOverrides: null,
+ ...overrides,
+ } as CalendarEvent;
+}
+
+const WEEKLY = [{ '@type': 'RecurrenceRule', frequency: 'weekly' }] as CalendarEvent['recurrenceRules'];
+
+describe('planCancellationRemoval', () => {
+ it('deletes every stored event of a cancelled meeting, and nothing else', () => {
+ const events = [
+ stored({ id: 'ev-1' }),
+ stored({ id: 'ev-2', recurrenceId: '2026-11-09T10:00:00' }),
+ stored({ id: 'other', uid: 'other@example.com' }),
+ ];
+ expect(planCancellationRemoval(events, { uid: 'meeting@example.com', sequence: 1 }))
+ .toEqual({ kind: 'delete', ids: ['ev-1', 'ev-2'] });
+ });
+
+ it('removes nothing when the calendar does not hold the meeting', () => {
+ expect(planCancellationRemoval([stored({ uid: 'other@example.com' })], { uid: 'meeting@example.com' }))
+ .toEqual({ kind: 'none' });
+ });
+
+ it('leaves the meeting when the calendar holds a newer version than the cancellation', () => {
+ expect(planCancellationRemoval([stored({ sequence: 3 })], { uid: 'meeting@example.com', sequence: 2 }))
+ .toEqual({ kind: 'outdated' });
+ });
+
+ it('applies a cancellation with the same sequence, as Stalwart leaves it after marking it cancelled', () => {
+ expect(planCancellationRemoval([stored({ sequence: 2, status: 'cancelled' })], { uid: 'meeting@example.com', sequence: 2 }))
+ .toEqual({ kind: 'delete', ids: ['ev-1'] });
+ });
+
+ it('excludes one cancelled occurrence on the series instead of deleting it', () => {
+ const series = stored({ recurrenceRules: WEEKLY, recurrenceOverrides: null });
+ expect(planCancellationRemoval([series], {
+ uid: 'meeting@example.com',
+ recurrenceId: '2026-11-09T10:00:00',
+ recurrenceIdTimeZone: 'Europe/Berlin',
+ sequence: 1,
+ })).toEqual({
+ kind: 'exclude',
+ id: 'ev-1',
+ patch: { recurrenceOverrides: { '2026-11-09T10:00:00': { excluded: true } } },
+ });
+ });
+
+ it('names the occurrence in the series time zone and keeps the other overrides', () => {
+ const series = stored({
+ recurrenceRules: WEEKLY,
+ recurrenceOverrides: { '2026-11-16T10:00:00': { title: 'Moved sync' } },
+ });
+ // 09:00 UTC is 10:00 in Berlin on 9 November.
+ expect(planCancellationRemoval([series], {
+ uid: 'meeting@example.com',
+ recurrenceId: '2026-11-09T09:00:00',
+ recurrenceIdTimeZone: 'Etc/UTC',
+ })).toEqual({
+ kind: 'exclude',
+ id: 'ev-1',
+ patch: { 'recurrenceOverrides/2026-11-09T10:00:00': { excluded: true } },
+ });
+ });
+
+ it('replaces the override Stalwart wrote for a cancelled occurrence', () => {
+ const series = stored({
+ recurrenceRules: WEEKLY,
+ recurrenceOverrides: { '2026-11-09T10:00:00': { status: 'cancelled', sequence: 1 } },
+ });
+ expect(planCancellationRemoval([series], {
+ uid: 'meeting@example.com',
+ recurrenceId: '2026-11-09T10:00:00',
+ recurrenceIdTimeZone: 'Europe/Berlin',
+ sequence: 1,
+ })).toEqual({
+ kind: 'exclude',
+ id: 'ev-1',
+ patch: { 'recurrenceOverrides/2026-11-09T10:00:00': { excluded: true } },
+ });
+ });
+
+ it('leaves an occurrence that was changed after the cancellation was sent', () => {
+ const series = stored({
+ recurrenceRules: WEEKLY,
+ recurrenceOverrides: { '2026-11-09T10:00:00': { title: 'Back on', sequence: 4 } },
+ });
+ expect(planCancellationRemoval([series], {
+ uid: 'meeting@example.com',
+ recurrenceId: '2026-11-09T10:00:00',
+ recurrenceIdTimeZone: 'Europe/Berlin',
+ sequence: 3,
+ })).toEqual({ kind: 'outdated' });
+ });
+
+ it('removes nothing for an occurrence already taken out of the series', () => {
+ const series = stored({
+ recurrenceRules: WEEKLY,
+ recurrenceOverrides: { '2026-11-09T10:00:00': { excluded: true } as Partial },
+ });
+ expect(planCancellationRemoval([series], {
+ uid: 'meeting@example.com',
+ recurrenceId: '2026-11-09T10:00:00',
+ recurrenceIdTimeZone: 'Europe/Berlin',
+ })).toEqual({ kind: 'none' });
+ });
+
+ it('does not write the whole overrides map when the series was read without it', () => {
+ const series = stored({ recurrenceRules: WEEKLY, recurrenceOverrides: undefined });
+ expect(planCancellationRemoval([series], {
+ uid: 'meeting@example.com',
+ recurrenceId: '2026-11-09T10:00:00',
+ recurrenceIdTimeZone: 'Europe/Berlin',
+ })).toEqual({ kind: 'none' });
+ });
+
+ it('deletes an occurrence the calendar holds on its own', () => {
+ const occurrence = stored({ id: 'occ', recurrenceId: '2026-11-09T10:00:00', recurrenceIdTimeZone: 'Europe/Berlin' });
+ expect(planCancellationRemoval([occurrence], {
+ uid: 'meeting@example.com',
+ recurrenceId: '2026-11-09T10:00:00',
+ recurrenceIdTimeZone: 'Europe/Berlin',
+ sequence: 1,
+ })).toEqual({ kind: 'delete', ids: ['occ'] });
+ });
+});
+
+describe('canRemoveCancelled', () => {
+ const removal: CancellationRemoval = { kind: 'delete', ids: ['ev-1'] };
+ const base = { isCancellation: true, userIsSender: false, trustLevel: 'trusted', removal };
+
+ it('offers removal for a trusted cancellation of a meeting the calendar holds', () => {
+ expect(canRemoveCancelled(base)).toBe(true);
+ expect(canRemoveCancelled({ ...base, trustLevel: 'caution' })).toBe(true);
+ expect(canRemoveCancelled({ ...base, removal: { kind: 'exclude', id: 'ev-1', patch: {} } })).toBe(true);
+ });
+
+ it('does not let a cancellation that fails the trust check, or the user\'s own, remove anything', () => {
+ expect(canRemoveCancelled({ ...base, trustLevel: 'warning' })).toBe(false);
+ expect(canRemoveCancelled({ ...base, userIsSender: true })).toBe(false);
+ expect(canRemoveCancelled({ ...base, isCancellation: false })).toBe(false);
+ });
+
+ it('offers nothing for an outdated cancellation or one with nothing left to remove', () => {
+ expect(canRemoveCancelled({ ...base, removal: { kind: 'outdated' } })).toBe(false);
+ expect(canRemoveCancelled({ ...base, removal: { kind: 'none' } })).toBe(false);
+ expect(canRemoveCancelled({ ...base, removal: null })).toBe(false);
+ });
+});
+
+describe('removeCancelledMeeting', () => {
+ it('deletes the stored events without telling anyone, planned on the events as they are now', async () => {
+ const deleteEvent = vi.fn().mockResolvedValue(undefined);
+ const updateEvent = vi.fn().mockResolvedValue(undefined);
+ const findEventsByUid = vi.fn().mockResolvedValue([stored({ id: 'ev-1' }), stored({ id: 'ev-2', recurrenceId: '2026-11-09T10:00:00' })]);
+ const done = await removeCancelledMeeting({ uid: 'meeting@example.com', sequence: 1 }, { findEventsByUid, deleteEvent, updateEvent });
+ expect(done).toEqual({ kind: 'delete', ids: ['ev-1', 'ev-2'] });
+ expect(findEventsByUid).toHaveBeenCalledWith('meeting@example.com');
+ expect(deleteEvent.mock.calls).toEqual([
+ ['ev-1', { sendSchedulingMessages: false }],
+ ['ev-2', { sendSchedulingMessages: false }],
+ ]);
+ expect(updateEvent).not.toHaveBeenCalled();
+ });
+
+ it('excludes one occurrence on the series', async () => {
+ const deleteEvent = vi.fn();
+ const updateEvent = vi.fn().mockResolvedValue(undefined);
+ const findEventsByUid = vi.fn().mockResolvedValue([stored({ recurrenceRules: WEEKLY })]);
+ await removeCancelledMeeting(
+ { uid: 'meeting@example.com', recurrenceId: '2026-11-09T10:00:00', recurrenceIdTimeZone: 'Europe/Berlin' },
+ { findEventsByUid, deleteEvent, updateEvent },
+ );
+ expect(updateEvent).toHaveBeenCalledWith(
+ 'ev-1',
+ { recurrenceOverrides: { '2026-11-09T10:00:00': { excluded: true } } },
+ { sendSchedulingMessages: false },
+ );
+ expect(deleteEvent).not.toHaveBeenCalled();
+ });
+
+ it('changes nothing when the calendar has a newer version by now', async () => {
+ const deleteEvent = vi.fn();
+ const updateEvent = vi.fn();
+ const findEventsByUid = vi.fn().mockResolvedValue([stored({ sequence: 5 })]);
+ expect(await removeCancelledMeeting({ uid: 'meeting@example.com', sequence: 2 }, { findEventsByUid, deleteEvent, updateEvent }))
+ .toEqual({ kind: 'outdated' });
+ expect(deleteEvent).not.toHaveBeenCalled();
+ expect(updateEvent).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/lib/__tests__/calendar-custom-color.test.ts b/src/lib/__tests__/calendar-custom-color.test.ts
new file mode 100644
index 00000000..e8f534bb
--- /dev/null
+++ b/src/lib/__tests__/calendar-custom-color.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from 'vitest';
+import { customColorGrid, normalizeHexColor } from '../calendar-custom-color';
+
+describe('normalizeHexColor', () => {
+ it('takes a typed hex colour in its usual spellings', () => {
+ expect(normalizeHexColor('#1A2B3C')).toBe('#1a2b3c');
+ expect(normalizeHexColor('1a2b3c')).toBe('#1a2b3c');
+ expect(normalizeHexColor(' #abc ')).toBe('#aabbcc');
+ });
+
+ it('refuses what is no hex colour', () => {
+ for (const bad of ['', '#12345', 'red', '#ggg000', '#1234567']) expect(normalizeHexColor(bad)).toBeNull();
+ });
+});
+
+describe('customColorGrid', () => {
+ it('offers distinct hex colours in rows of twelve', () => {
+ const grid = customColorGrid();
+ expect(grid).toHaveLength(4);
+ const all = grid.flat();
+ expect(all.every((c) => /^#[0-9a-f]{6}$/.test(c))).toBe(true);
+ expect(new Set(all).size).toBe(all.length);
+ expect(grid[1][0]).toBe('#c32222');
+ });
+});
diff --git a/src/lib/__tests__/calendar-editor-discard.test.ts b/src/lib/__tests__/calendar-editor-discard.test.ts
new file mode 100644
index 00000000..8c2e5292
--- /dev/null
+++ b/src/lib/__tests__/calendar-editor-discard.test.ts
@@ -0,0 +1,71 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+const alert = vi.fn();
+vi.mock('react-native', () => ({ Alert: { alert: (...args: unknown[]) => alert(...args) } }));
+
+import { askBeforeDiscard, editorFormChanged, editorFormSnapshot } from '../calendar-editor-discard';
+import { emptyTaskEditor, taskEditorChanged, taskEditorFromTask } from '../task-editor';
+
+const t = (_key: string, fallback?: string) => fallback ?? '';
+
+type Button = { text: string; style?: string; onPress?: () => void };
+const buttons = (): Button[] => alert.mock.calls.at(-1)![2] as Button[];
+
+beforeEach(() => alert.mockReset());
+
+describe('askBeforeDiscard', () => {
+ it('closes at once when nothing was changed', () => {
+ const close = vi.fn();
+ askBeforeDiscard(false, t, close);
+ expect(close).toHaveBeenCalledTimes(1);
+ expect(alert).not.toHaveBeenCalled();
+ });
+
+ it('asks before it drops changes, and closes only on Discard', () => {
+ const close = vi.fn();
+ const keep = vi.fn();
+ askBeforeDiscard(true, t, close, keep);
+ expect(close).not.toHaveBeenCalled();
+ expect(alert.mock.calls[0][0]).toBe('Discard changes?');
+ const [keepButton, discardButton] = buttons();
+ expect(keepButton.text).toBe('Keep editing');
+ keepButton.onPress?.();
+ expect(keep).toHaveBeenCalledTimes(1);
+ expect(close).not.toHaveBeenCalled();
+ expect(discardButton.style).toBe('destructive');
+ discardButton.onPress?.();
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('editor form changes', () => {
+ const form = (title: string, start = new Date('2026-11-02T10:00:00Z')) =>
+ editorFormSnapshot({ title, start, attendees: [{ email: 'a@example.com' }] });
+
+ it('counts a typed title or a moved start as a change, the same form as none', () => {
+ const baseline = form('Lunch');
+ expect(editorFormChanged(baseline, form('Lunch'))).toBe(false);
+ expect(editorFormChanged(baseline, form('Lunch with Ana'))).toBe(true);
+ expect(editorFormChanged(baseline, form('Lunch', new Date('2026-11-02T11:00:00Z')))).toBe(true);
+ });
+
+ it('counts nothing before the form was seeded', () => {
+ expect(editorFormChanged(null, form('Lunch'))).toBe(false);
+ });
+});
+
+describe('taskEditorChanged', () => {
+ it('tells a typed title, note, due date or priority from the task as opened', () => {
+ const opened = taskEditorFromTask({ id: 't1', title: 'Pay rent', calendarIds: { c1: true } } as never, 'c1');
+ expect(taskEditorChanged(opened, { ...opened })).toBe(false);
+ expect(taskEditorChanged(opened, { ...opened, title: 'Pay rent today' })).toBe(true);
+ expect(taskEditorChanged(opened, { ...opened, description: 'IBAN' })).toBe(true);
+ expect(taskEditorChanged(opened, { ...opened, due: new Date('2026-11-03T00:00:00Z') })).toBe(true);
+ expect(taskEditorChanged(opened, { ...opened, priority: 'high' })).toBe(true);
+ });
+
+ it('compares due dates by their instant', () => {
+ const a = { ...emptyTaskEditor('c1'), due: new Date('2026-11-03T09:00:00Z') };
+ expect(taskEditorChanged(a, { ...a, due: new Date('2026-11-03T09:00:00Z') })).toBe(false);
+ });
+});
diff --git a/src/lib/__tests__/calendar-event-note.test.ts b/src/lib/__tests__/calendar-event-note.test.ts
new file mode 100644
index 00000000..94f35539
--- /dev/null
+++ b/src/lib/__tests__/calendar-event-note.test.ts
@@ -0,0 +1,15 @@
+import { describe, expect, it } from 'vitest';
+import { appendEventNote } from '../calendar-event-note';
+
+describe('appendEventNote', () => {
+ const now = new Date(2026, 10, 2, 9, 30);
+
+ it('adds the note under a time stamp below what the description holds', () => {
+ expect(appendEventNote('Agenda', 'Bring the slides', now)).toBe('Agenda\n\n--- 2026-11-02 09:30 ---\nBring the slides');
+ });
+
+ it('starts an empty description with the stamped note', () => {
+ expect(appendEventNote('', 'Room 4', now)).toBe('--- 2026-11-02 09:30 ---\nRoom 4');
+ expect(appendEventNote(undefined, 'Room 4', now)).toBe('--- 2026-11-02 09:30 ---\nRoom 4');
+ });
+});
diff --git a/src/lib/__tests__/calendar-participant-suggestions.test.ts b/src/lib/__tests__/calendar-participant-suggestions.test.ts
new file mode 100644
index 00000000..64482631
--- /dev/null
+++ b/src/lib/__tests__/calendar-participant-suggestions.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest';
+import { participantSuggestions, participantsFromSuggestion } from '../calendar-participant-suggestions';
+import type { RecipientSuggestion } from '../../stores/contacts-store';
+
+const all: RecipientSuggestion[] = [
+ { name: 'Team Alpha', email: '', group: { id: 'g1', memberCount: 2 } },
+ { name: 'Ana', email: 'ana@example.com' },
+ { name: 'Recent Ben', email: 'ben@example.com' },
+];
+const getAutocomplete = (q: string) => all.filter((s) => (s.name + s.email).toLowerCase().includes(q.toLowerCase()) || q === 'a');
+const members = { g1: [{ name: 'Ana', email: 'ana@example.com' }, { name: 'Cem', email: 'cem@example.com' }] } as Record;
+const getGroupRecipients = (id: string) => members[id] ?? [];
+
+describe('participantSuggestions', () => {
+ it('offers contacts, groups and recent recipients, not the people already invited', () => {
+ const shown = participantSuggestions('an', new Set(['ben@example.com']), () => all, getGroupRecipients);
+ expect(shown.map((s) => s.name)).toEqual(['Team Alpha', 'Ana']);
+ });
+
+ it('drops a group whose members are all invited, and waits for two letters', () => {
+ const shown = participantSuggestions('team', new Set(['ana@example.com', 'cem@example.com']), getAutocomplete, getGroupRecipients);
+ expect(shown).toEqual([]);
+ expect(participantSuggestions('t', new Set(), getAutocomplete, getGroupRecipients)).toEqual([]);
+ });
+});
+
+describe('participantsFromSuggestion', () => {
+ it('invites the members of a picked group who are not invited yet', () => {
+ expect(participantsFromSuggestion(all[0], new Set(['ana@example.com']), getGroupRecipients))
+ .toEqual([{ name: 'Cem', email: 'cem@example.com' }]);
+ expect(participantsFromSuggestion(all[2], new Set(), getGroupRecipients))
+ .toEqual([{ name: 'Recent Ben', email: 'ben@example.com' }]);
+ });
+});
diff --git a/src/lib/__tests__/calendar-scheduling-denied.test.ts b/src/lib/__tests__/calendar-scheduling-denied.test.ts
new file mode 100644
index 00000000..1388301f
--- /dev/null
+++ b/src/lib/__tests__/calendar-scheduling-denied.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it, vi } from 'vitest';
+
+vi.mock('react-native', () => ({ Alert: { alert: vi.fn() } }));
+
+import { JMAPSetError } from '../../api/jmap-result';
+import { saveOfferingWithoutInvitations, schedulingDeniedReason } from '../calendar-scheduling-denied';
+
+const t = (_k: string, fallback?: string) => fallback ?? '';
+// As api/calendar throws Stalwart 0.16.21+'s refusal of the invitations.
+const denied = () => new JMAPSetError('forbidden', 'Failed to update event e1: forbidden – Scheduling not allowed');
+
+describe('schedulingDeniedReason', () => {
+ it('tells a refusal of the invitations from other failures', () => {
+ expect(schedulingDeniedReason(denied(), true)).toBe('Scheduling not allowed');
+ expect(schedulingDeniedReason(denied(), false)).toBeNull();
+ expect(schedulingDeniedReason(new JMAPSetError('invalidProperties', 'x'), true)).toBeNull();
+ expect(schedulingDeniedReason(new Error('offline'), true)).toBeNull();
+ });
+});
+
+describe('saveOfferingWithoutInvitations', () => {
+ it('saves again without the invitations when the user agrees', async () => {
+ const save = vi.fn().mockRejectedValueOnce(denied()).mockResolvedValueOnce(undefined);
+ const ask = vi.fn().mockResolvedValue(true);
+ expect(await saveOfferingWithoutInvitations(save, true, t, ask)).toBe(true);
+ expect(save.mock.calls).toEqual([[true], [false]]);
+ expect(ask).toHaveBeenCalledWith('Scheduling not allowed', t);
+ });
+
+ it('keeps the editor open when the user declines, and passes other errors on', async () => {
+ const save = vi.fn().mockRejectedValue(denied());
+ expect(await saveOfferingWithoutInvitations(save, true, t, vi.fn().mockResolvedValue(false))).toBe(false);
+ expect(save).toHaveBeenCalledTimes(1);
+ const failing = vi.fn().mockRejectedValue(new Error('offline'));
+ await expect(saveOfferingWithoutInvitations(failing, true, t, vi.fn())).rejects.toThrow('offline');
+ });
+});
diff --git a/src/lib/__tests__/contact-list-filters.test.ts b/src/lib/__tests__/contact-list-filters.test.ts
new file mode 100644
index 00000000..1c426ef4
--- /dev/null
+++ b/src/lib/__tests__/contact-list-filters.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from 'vitest';
+import type { ContactCard } from '../../api/types';
+import { countActiveFilters, cycleTri, EMPTY_CONTACT_FILTERS, matchesContactFilters } from '../contact-list-filters';
+
+const ana = {
+ id: 'a', name: { full: 'Ana' },
+ emails: { e: { address: 'ana@acme.example' } },
+ organizations: { o: { name: 'Acme Corp', units: [{ name: 'Design' }] } },
+ titles: { t: { name: 'Designer' } },
+ addresses: { h: { components: [{ kind: 'locality', value: 'Berlin' }] } },
+ anniversaries: { b: { kind: 'birth', date: '1990-03-14' } },
+} as unknown as ContactCard;
+const ben = { id: 'b', name: { full: 'Ben' }, phones: { p: { number: '+49 1' } } } as unknown as ContactCard;
+
+const only = (f: Partial) => [ana, ben]
+ .filter((c) => matchesContactFilters(c, { ...EMPTY_CONTACT_FILTERS, ...f }))
+ .map((c) => c.id);
+
+describe('contact list filters', () => {
+ it('keeps every contact without filters', () => {
+ expect(only({})).toEqual(['a', 'b']);
+ });
+
+ it('filters by company or unit, job title, location, email domain and birthday month', () => {
+ expect(only({ organization: 'design' })).toEqual(['a']);
+ expect(only({ jobTitle: 'design' })).toEqual(['a']);
+ expect(only({ location: 'berl' })).toEqual(['a']);
+ expect(only({ emailDomain: '@acme' })).toEqual(['a']);
+ expect(only({ birthdayMonth: 3 })).toEqual(['a']);
+ expect(only({ birthdayMonth: 4 })).toEqual([]);
+ });
+
+ it('filters by having, or not having, an email or a phone', () => {
+ expect(only({ hasEmail: true })).toEqual(['a']);
+ expect(only({ hasEmail: false })).toEqual(['b']);
+ expect(only({ hasPhone: true })).toEqual(['b']);
+ });
+
+ it('counts the filters that are set and cycles a toggle off, on, inverted', () => {
+ expect(countActiveFilters({ ...EMPTY_CONTACT_FILTERS, location: 'x', hasPhoto: false })).toBe(2);
+ expect([cycleTri(null), cycleTri(true), cycleTri(false)]).toEqual([true, false, null]);
+ });
+});
diff --git a/src/lib/__tests__/recurrence-dst-gap.test.ts b/src/lib/__tests__/recurrence-dst-gap.test.ts
index e568091e..9ad8f8a2 100644
--- a/src/lib/__tests__/recurrence-dst-gap.test.ts
+++ b/src/lib/__tests__/recurrence-dst-gap.test.ts
@@ -38,3 +38,45 @@ describe('daily recurrence across a DST gap', () => {
expect(out.map((o) => o.start.slice(11, 16))).toEqual(Array(7).fill('00:00'));
});
});
+
+describe("a daily series in another zone on the device's DST day", () => {
+ // Berlin skips 02:00-03:00 on 28 March 2027; New York changed on 14 March.
+ it("keeps the series' 02:30 as recurrence id and time on the night the device skips it", () => {
+ process.env.TZ = 'Europe/Berlin';
+ const out = expandRecurringEvents(
+ [daily('2027-03-20T02:30:00', false, 'America/New_York')],
+ '2027-03-27T00:00:00', '2027-03-30T00:00:00',
+ );
+ const day = out.find((o) => o.recurrenceId?.startsWith('2027-03-28'));
+ expect(day?.recurrenceId).toBe('2027-03-28T02:30:00');
+ expect(day?.start).toBe('2027-03-28T02:30:00');
+ expect(day?.id).toBe('E1:2027-03-28T02:30:00');
+ // 02:30 in New York (EDT, -4) is 06:30 UTC.
+ expect(day?.utcStart).toBe('2027-03-28T06:30:00.000Z');
+ expect(out.map((o) => o.recurrenceId)).toEqual([
+ '2027-03-27T02:30:00', '2027-03-28T02:30:00', '2027-03-29T02:30:00',
+ ]);
+ });
+
+ it('applies the override stored for that night', () => {
+ process.env.TZ = 'Europe/Berlin';
+ const event = {
+ ...daily('2027-03-20T02:30:00', false, 'America/New_York'),
+ recurrenceOverrides: { '2027-03-28T02:30:00': { title: 'Moved' } },
+ } as CalendarEvent;
+ const out = expandRecurringEvents([event], '2027-03-28T00:00:00', '2027-03-29T00:00:00');
+ expect(out).toHaveLength(1);
+ expect(out[0].title).toBe('Moved');
+ });
+
+ it('gives the same occurrences whichever zone the device is in', () => {
+ const run = (tz: string) => {
+ process.env.TZ = tz;
+ return expandRecurringEvents(
+ [daily('2027-03-10T02:30:00', false, 'America/New_York')],
+ '2027-03-10T00:00:00', '2027-04-01T00:00:00',
+ ).map((o) => [o.recurrenceId, o.utcStart]);
+ };
+ expect(run('Europe/Berlin')).toEqual(run('America/New_York'));
+ });
+});
diff --git a/src/lib/__tests__/shared-calendar-colors.test.ts b/src/lib/__tests__/shared-calendar-colors.test.ts
new file mode 100644
index 00000000..2c306f1a
--- /dev/null
+++ b/src/lib/__tests__/shared-calendar-colors.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from 'vitest';
+import { autoAssignSharedCalendarColors, sharedCalendarColorKey } from '../calendar-utils';
+
+describe('autoAssignSharedCalendarColors (#1414)', () => {
+ const own = { id: 'c1', color: '#3b82f6' };
+ const coloured = { id: 'b:c2', originalId: 'c2', accountId: 'b', isShared: true, color: '#10b981' };
+ const plain = { id: 'b:c3', originalId: 'c3', accountId: 'b', isShared: true };
+
+ it('keeps the server colour of a shared calendar and colours only one without', () => {
+ const assigned = autoAssignSharedCalendarColors([own, coloured, plain], {});
+ expect(Object.keys(assigned)).toEqual([sharedCalendarColorKey(plain)]);
+ const color = assigned[sharedCalendarColorKey(plain)].toLowerCase();
+ expect(['#3b82f6', '#10b981']).not.toContain(color);
+ });
+
+ it('leaves a calendar the viewer coloured alone', () => {
+ expect(autoAssignSharedCalendarColors([plain], { [sharedCalendarColorKey(plain)]: '#ef4444' })).toEqual({});
+ });
+});
diff --git a/src/lib/__tests__/vacation-limits.test.ts b/src/lib/__tests__/vacation-limits.test.ts
new file mode 100644
index 00000000..ccaf346c
--- /dev/null
+++ b/src/lib/__tests__/vacation-limits.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest';
+import { STALWART_VACATION_LIMITS, utf8Length, vacationOversize } from '../vacation-limits';
+
+describe('vacationOversize', () => {
+ const limits = STALWART_VACATION_LIMITS;
+
+ it('flags a subject of 512 bytes and a body of 2048 bytes, not one byte less', () => {
+ expect(vacationOversize({ subject: 'a'.repeat(511), textBody: 'b'.repeat(2047), htmlBody: null }, limits))
+ .toEqual({ subject: false, body: false });
+ expect(vacationOversize({ subject: 'a'.repeat(512), textBody: 'b'.repeat(2048), htmlBody: null }, limits))
+ .toEqual({ subject: true, body: true });
+ });
+
+ it('counts bytes, not characters, and the HTML part with its formatting', () => {
+ // 256 umlauts are 512 bytes.
+ expect(vacationOversize({ subject: 'ä'.repeat(256), textBody: '', htmlBody: null }, limits).subject).toBe(true);
+ expect(vacationOversize({ subject: '', textBody: 'short', htmlBody: `${'x'.repeat(2045)}
` }, limits).body).toBe(true);
+ });
+
+ it('says nothing for a server without known limits', () => {
+ expect(vacationOversize({ subject: 'a'.repeat(9000), textBody: '', htmlBody: null }, null))
+ .toEqual({ subject: false, body: false });
+ });
+
+ it('measures UTF-8 like the server', () => {
+ expect(utf8Length('aä€😀')).toBe(1 + 2 + 3 + 4);
+ });
+});
diff --git a/src/lib/birthday-calendar.ts b/src/lib/birthday-calendar.ts
index e8a568f1..1ff8e577 100644
--- a/src/lib/birthday-calendar.ts
+++ b/src/lib/birthday-calendar.ts
@@ -6,11 +6,11 @@ export const BIRTHDAY_CALENDAR_ID = '__birthday-calendar__';
export const BIRTHDAY_CALENDAR_COLOR = '#eab308'; // yellow
// A read-only virtual calendar holding generated birthday events.
-export function createBirthdayCalendar(name = 'Birthdays'): Calendar {
+export function createBirthdayCalendar(name = 'Birthdays', color?: string): Calendar {
return {
id: BIRTHDAY_CALENDAR_ID,
name,
- color: BIRTHDAY_CALENDAR_COLOR,
+ color: color || BIRTHDAY_CALENDAR_COLOR,
sortOrder: 999,
isSubscribed: true,
isVisible: true,
diff --git a/src/lib/calendar-a11y.ts b/src/lib/calendar-a11y.ts
new file mode 100644
index 00000000..0a55b64a
--- /dev/null
+++ b/src/lib/calendar-a11y.ts
@@ -0,0 +1,61 @@
+import { format, isSameDay } from 'date-fns';
+import type { Locale } from 'date-fns';
+import type { Calendar, CalendarEvent } from '../api/types';
+import { getEventDisplayEndDate, getEventStartDate, getPrimaryCalendarId, timePattern } from './calendar-utils';
+import { isDeclinedByUser } from './calendar-participants';
+import { formatCalendarDate } from './date-format';
+
+type Translate = (key: string, fallback?: string, params?: Record) => string;
+
+// What a screen reader says for the calendar grid (webmail 0026abba4,
+// 19e0acde9): a day cell by its full date and how many events it has, an
+// event by its title, date, time, calendar and state.
+
+/** "Monday, November 2, 2026, 3 events" for a month day cell. */
+export function dayCellAccessibilityLabel(day: Date, eventCount: number, t: Translate, locale: Locale): string {
+ const date = formatCalendarDate(day, locale.code, 'longWeekdayMonthDayYear');
+ if (eventCount === 0) return date;
+ return `${date}, ${t('calendar.a11y.event_count', '{count, plural, one {# event} other {# events}}', { count: eventCount })}`;
+}
+
+/** "Standup, Monday, November 2, 2026, 09:00 – 09:15, Work, Cancelled" for an event block. */
+export function eventAccessibilityLabel(
+ event: CalendarEvent,
+ opts: {
+ t: Translate;
+ locale: Locale;
+ timeFormat: '12h' | '24h' | undefined;
+ calendars?: Pick[];
+ currentUserEmails?: string[];
+ },
+): string {
+ const { t, locale } = opts;
+ const parts = [event.title || t('calendar.events.no_title', '(No title)')];
+ const start = getEventStartDate(event);
+ const end = getEventDisplayEndDate(event);
+ const valid = (d: Date) => !isNaN(d.getTime());
+ if (valid(start)) {
+ const date = formatCalendarDate(start, locale.code, 'longWeekdayMonthDayYear');
+ if (event.showWithoutTime) {
+ const last = valid(end) && !isSameDay(start, end)
+ ? ` – ${formatCalendarDate(end, locale.code, 'longWeekdayMonthDayYear')}`
+ : '';
+ parts.push(date + last, t('calendar.events.all_day', 'All day'));
+ } else {
+ const time = timePattern(opts.timeFormat);
+ let range = format(start, time, { locale });
+ if (valid(end) && end.getTime() > start.getTime()) {
+ range += isSameDay(start, end)
+ ? ` – ${format(end, time, { locale })}`
+ : ` – ${formatCalendarDate(end, locale.code, 'longWeekdayMonthDayYear')} ${format(end, time, { locale })}`;
+ }
+ parts.push(date, range);
+ }
+ }
+ const calendarId = getPrimaryCalendarId(event);
+ const calendar = calendarId ? opts.calendars?.find((c) => c.id === calendarId) : undefined;
+ if (calendar?.name) parts.push(calendar.name);
+ if (event.status === 'cancelled') parts.push(t('calendar.detail.cancelled', 'Cancelled'));
+ else if (isDeclinedByUser(event, opts.currentUserEmails)) parts.push(t('calendar.participants.declined', 'Declined'));
+ return parts.join(', ');
+}
diff --git a/src/lib/calendar-cancellation.ts b/src/lib/calendar-cancellation.ts
new file mode 100644
index 00000000..3ec71af7
--- /dev/null
+++ b/src/lib/calendar-cancellation.ts
@@ -0,0 +1,129 @@
+import type { CalendarEvent } from '../api/types';
+import { buildFallbackExcludePatch } from './recurrence-instances';
+import { formatWallClock, isValidTimeZone, localDateTimeToInstant } from './calendar-timezone';
+
+// Ported from the webmail's lib/calendar-cancellation.ts (#1425).
+
+/**
+ * `b`'s recurrence id as a local date-time in the zone of `a`'s, each read in
+ * its own zone (`recurrenceIdTimeZone`, else the event's `timeZone`). Without
+ * a usable zone on either side it is taken as it is.
+ */
+function recurrenceIdInZoneOf(a: Partial, b: Partial): string | null {
+ if (!b.recurrenceId) return null;
+ const zoneA = a.recurrenceIdTimeZone ?? a.timeZone ?? null;
+ const zoneB = b.recurrenceIdTimeZone ?? b.timeZone ?? null;
+ const local = b.recurrenceId.slice(0, 19);
+ if (!zoneA || !zoneB || !isValidTimeZone(zoneA) || !isValidTimeZone(zoneB)) return local;
+ const instant = localDateTimeToInstant(local, zoneB);
+ return instant ? formatWallClock(instant, zoneA) : local;
+}
+
+/** Whether two events name the same occurrence: their recurrence ids at the same moment, each in its own zone. */
+export function sameRecurrenceId(a: Partial, b: Partial): boolean {
+ if (!a.recurrenceId || !b.recurrenceId) return false;
+ return a.recurrenceId.slice(0, 19) === recurrenceIdInZoneOf(a, b);
+}
+
+/**
+ * What removing a cancelled meeting from the calendar takes:
+ * - `delete`: destroy these stored events (the series, or an occurrence
+ * stored on its own).
+ * - `exclude`: write `patch` on the series `id`, taking one occurrence out.
+ * - `outdated`: the calendar holds a newer version than the cancellation.
+ * - `none`: nothing in the calendar to remove.
+ */
+export type CancellationRemoval =
+ | { kind: 'delete'; ids: string[] }
+ | { kind: 'exclude'; id: string; patch: Record }
+ | { kind: 'outdated' }
+ | { kind: 'none' };
+
+/**
+ * How to remove what an iTIP CANCEL (RFC 5546 3.2.5) names from `stored`,
+ * the events stored with its uid. Stalwart applies a cancellation itself but
+ * only marks the event, or the occurrence, STATUS:CANCELLED and keeps it.
+ * A cancellation without a recurrence id is for the whole meeting; one with
+ * a recurrence id is for that occurrence only. A cancellation with a lower
+ * SEQUENCE than the event it names is older than the event and is not
+ * applied, as Stalwart does not apply it either.
+ */
+export function planCancellationRemoval(
+ stored: CalendarEvent[],
+ cancel: Partial,
+): CancellationRemoval {
+ if (!cancel.uid) return { kind: 'none' };
+ const sameUid = stored.filter((event) => event.uid === cancel.uid);
+ const cancelSequence = cancel.sequence ?? 0;
+ const isNewer = (sequence: number | null | undefined) => (sequence ?? 0) > cancelSequence;
+
+ if (!cancel.recurrenceId) {
+ if (sameUid.length === 0) return { kind: 'none' };
+ if (sameUid.some((event) => isNewer(event.sequence))) return { kind: 'outdated' };
+ return { kind: 'delete', ids: sameUid.map((event) => event.id) };
+ }
+
+ const ownEvent = sameUid.find((event) => sameRecurrenceId(event, cancel));
+ if (ownEvent) {
+ return isNewer(ownEvent.sequence) ? { kind: 'outdated' } : { kind: 'delete', ids: [ownEvent.id] };
+ }
+
+ const series = sameUid.find((event) => !event.recurrenceId && (event.recurrenceRules?.length ?? 0) > 0);
+ // Overrides not read: a whole-map write would drop the ones there are.
+ if (!series || series.recurrenceOverrides === undefined) return { kind: 'none' };
+ const key = recurrenceIdInZoneOf(series, cancel);
+ if (!key) return { kind: 'none' };
+ const override = series.recurrenceOverrides?.[key] as (Partial & { excluded?: boolean }) | undefined;
+ if (override?.excluded) return { kind: 'none' };
+ if (override && isNewer(override.sequence)) return { kind: 'outdated' };
+ const patch = buildFallbackExcludePatch({
+ id: series.id,
+ start: '',
+ recurrenceId: key,
+ recurrenceOverrides: series.recurrenceOverrides,
+ });
+ return patch ? { kind: 'exclude', id: series.id, patch } : { kind: 'none' };
+}
+
+/**
+ * Whether the banner offers "Remove from calendar": a cancellation the user
+ * didn't send, that doesn't fail the invitation trust check (authentication,
+ * sender not the organizer), for a meeting the calendar still holds.
+ */
+export function canRemoveCancelled(input: {
+ isCancellation: boolean;
+ userIsSender: boolean;
+ trustLevel: string | null | undefined;
+ removal: CancellationRemoval | null;
+}): boolean {
+ return input.isCancellation
+ && !input.userIsSender
+ && input.trustLevel !== 'warning'
+ && (input.removal?.kind === 'delete' || input.removal?.kind === 'exclude');
+}
+
+export interface CancellationActions {
+ findEventsByUid: (uid: string) => Promise;
+ deleteEvent: (id: string, options: { sendSchedulingMessages: boolean }) => Promise;
+ updateEvent: (id: string, patch: Record, options: { sendSchedulingMessages: boolean }) => Promise;
+}
+
+/**
+ * Remove what `cancel` names from the calendar, planned again on the events
+ * as they are now: the calendar may have changed since the mail was opened.
+ * No scheduling messages: the organizer cancelled it, there is no one to
+ * tell. Returns the plan it carried out (`none`/`outdated`: nothing done).
+ */
+export async function removeCancelledMeeting(
+ cancel: Partial,
+ actions: CancellationActions,
+): Promise {
+ if (!cancel.uid) return { kind: 'none' };
+ const removal = planCancellationRemoval(await actions.findEventsByUid(cancel.uid), cancel);
+ if (removal.kind === 'delete') {
+ for (const id of removal.ids) await actions.deleteEvent(id, { sendSchedulingMessages: false });
+ } else if (removal.kind === 'exclude') {
+ await actions.updateEvent(removal.id, removal.patch, { sendSchedulingMessages: false });
+ }
+ return removal;
+}
diff --git a/src/lib/calendar-custom-color.ts b/src/lib/calendar-custom-color.ts
new file mode 100644
index 00000000..2217b414
--- /dev/null
+++ b/src/lib/calendar-custom-color.ts
@@ -0,0 +1,29 @@
+// Custom calendar colours (native #90): a hex field and a grid of shades
+// under the presets, where the webmail offers a colour input
+// (components/settings/calendar-management-settings.tsx).
+
+/** `input` as "#rrggbb" (from "#abc", "abc", "#AABBCC"…), or null when it is no hex colour. */
+export function normalizeHexColor(input: string): string | null {
+ const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(input.trim());
+ if (!m) return null;
+ const hex = m[1].length === 3 ? m[1].split('').map((ch) => ch + ch).join('') : m[1];
+ return `#${hex.toLowerCase()}`;
+}
+
+function hslToHex(h: number, s: number, l: number): string {
+ const a = s * Math.min(l, 1 - l);
+ const f = (n: number) => {
+ const k = (n + h / 30) % 12;
+ const v = l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
+ return Math.round(v * 255).toString(16).padStart(2, '0');
+ };
+ return `#${f(0)}${f(8)}${f(4)}`;
+}
+
+/** Rows of shades (dark, mid, light) over twelve hues, then a grey row. */
+export function customColorGrid(): string[][] {
+ const hues = Array.from({ length: 12 }, (_, i) => i * 30);
+ const rows = [0.3, 0.45, 0.65].map((l) => hues.map((h) => hslToHex(h, 0.7, l)));
+ rows.push(Array.from({ length: 12 }, (_, i) => hslToHex(0, 0, 0.1 + (i * 0.8) / 11)));
+ return rows;
+}
diff --git a/src/lib/calendar-editor-discard.ts b/src/lib/calendar-editor-discard.ts
new file mode 100644
index 00000000..f824a81b
--- /dev/null
+++ b/src/lib/calendar-editor-discard.ts
@@ -0,0 +1,43 @@
+import { Alert } from 'react-native';
+
+type Translate = (key: string, fallback?: string) => string;
+
+/**
+ * The editor's form as one comparable string. Dates compare by their
+ * instant (JSON renders them as ISO strings); the fields are compared in
+ * the order given, which the editor keeps fixed.
+ */
+export function editorFormSnapshot(fields: Record): string {
+ return JSON.stringify(fields);
+}
+
+/** Whether the form holds changes against `baseline`, the form it opened with (null: not seeded yet). */
+export function editorFormChanged(baseline: string | null, current: string): boolean {
+ return baseline !== null && baseline !== current;
+}
+
+/**
+ * Run `discard` at once when nothing would be lost, else after the user
+ * confirms "Discard"; "Keep editing" runs `keep` (webmail 13f9f27c4: the
+ * event and task editors ask before X, Cancel or Escape drop their changes).
+ */
+export function askBeforeDiscard(
+ dirty: boolean,
+ t: Translate,
+ discard: () => void,
+ keep?: () => void,
+): void {
+ if (!dirty) {
+ discard();
+ return;
+ }
+ Alert.alert(
+ t('calendar.discard.title', 'Discard changes?'),
+ t('calendar.discard.message', "Your changes haven't been saved."),
+ [
+ { text: t('calendar.discard.keep', 'Keep editing'), style: 'cancel', onPress: keep },
+ { text: t('calendar.discard.confirm', 'Discard'), style: 'destructive', onPress: discard },
+ ],
+ { cancelable: true, onDismiss: keep },
+ );
+}
diff --git a/src/lib/calendar-event-note.ts b/src/lib/calendar-event-note.ts
new file mode 100644
index 00000000..67dc4577
--- /dev/null
+++ b/src/lib/calendar-event-note.ts
@@ -0,0 +1,10 @@
+import { format } from 'date-fns';
+
+/**
+ * The event's description with a quick note appended under a time stamp
+ * (webmail calendar-app handleSaveNoteFromDetail): "--- 2026-11-02 09:30 ---".
+ */
+export function appendEventNote(description: string | null | undefined, note: string, now: Date): string {
+ const stamp = `--- ${format(now, 'yyyy-MM-dd HH:mm')} ---`;
+ return description ? `${description}\n\n${stamp}\n${note}` : `${stamp}\n${note}`;
+}
diff --git a/src/lib/calendar-participant-suggestions.ts b/src/lib/calendar-participant-suggestions.ts
new file mode 100644
index 00000000..f0a67c63
--- /dev/null
+++ b/src/lib/calendar-participant-suggestions.ts
@@ -0,0 +1,42 @@
+import type { EmailAddress } from '../api/types';
+import type { RecipientSuggestion } from '../stores/contacts-store';
+
+/**
+ * What the event's participant field suggests for `query`: the composer's
+ * autocomplete (contacts, contact groups and recent Sent recipients, webmail
+ * participant-input.tsx), without the people already invited and groups
+ * whose members all are.
+ */
+export function participantSuggestions(
+ query: string,
+ invited: ReadonlySet,
+ getAutocomplete: (query: string, limit?: number) => RecipientSuggestion[],
+ getGroupRecipients: (groupId: string) => EmailAddress[],
+ limit = 6,
+): RecipientSuggestion[] {
+ const q = query.trim();
+ if (q.length < 2) return [];
+ return getAutocomplete(q, 16)
+ .filter((s) => (s.group
+ ? getGroupRecipients(s.group.id).some((m) => !invited.has(m.email.toLowerCase()))
+ : !invited.has(s.email.toLowerCase())))
+ .slice(0, limit);
+}
+
+/** The people a picked suggestion invites: the person, or the group's members not yet invited. */
+export function participantsFromSuggestion(
+ suggestion: RecipientSuggestion,
+ invited: ReadonlySet,
+ getGroupRecipients: (groupId: string) => EmailAddress[],
+): { name: string; email: string }[] {
+ const people = suggestion.group
+ ? getGroupRecipients(suggestion.group.id).map((m) => ({ name: m.name ?? '', email: m.email }))
+ : [{ name: suggestion.name, email: suggestion.email }];
+ const seen = new Set(invited);
+ return people.filter((p) => {
+ const key = p.email.trim().toLowerCase();
+ if (!key || seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ });
+}
diff --git a/src/lib/calendar-scheduling-denied.ts b/src/lib/calendar-scheduling-denied.ts
new file mode 100644
index 00000000..57a992b7
--- /dev/null
+++ b/src/lib/calendar-scheduling-denied.ts
@@ -0,0 +1,56 @@
+import { Alert } from 'react-native';
+import { JMAPMethodError } from '../api/jmap-result';
+
+type Translate = (key: string, fallback?: string, params?: Record) => string;
+
+/**
+ * Why the server refused a save because of the invitations it would send, or
+ * null for any other failure. Stalwart 0.16.21+ fails the whole
+ * CalendarEvent/set with `forbidden` when the account may not send
+ * scheduling messages; the same change without them can still be saved
+ * (webmail lib/jmap/scheduling-error.ts).
+ */
+export function schedulingDeniedReason(err: unknown, sentScheduling: boolean): string | null {
+ if (!sentScheduling || !(err instanceof JMAPMethodError) || err.type !== 'forbidden') return null;
+ // The thrower's message carries the server's description after the type.
+ const description = err.message.split(' – ').slice(1).join(' – ').trim();
+ return description || err.message || 'forbidden';
+}
+
+/** Ask whether to save the event without its invitations; resolves true for "Save without invitations". */
+export function askSaveWithoutInvitations(reason: string, t: Translate): Promise {
+ return new Promise((resolve) => {
+ Alert.alert(
+ t('calendar.notifications.invitations_denied_title', "Invitations can't be sent"),
+ t('calendar.notifications.invitations_denied', 'The server refused to send the invitations: {reason}. Save the event without sending them?', { reason }),
+ [
+ { text: t('common.cancel', 'Cancel'), style: 'cancel', onPress: () => resolve(false) },
+ { text: t('calendar.notifications.save_without_invitations', 'Save without invitations'), onPress: () => resolve(true) },
+ ],
+ { cancelable: true, onDismiss: () => resolve(false) },
+ );
+ });
+}
+
+/**
+ * Save with the invitations `send` asks for; when the server refuses to send
+ * them, ask and save again without. Resolves false when the user kept the
+ * editor open instead.
+ */
+export async function saveOfferingWithoutInvitations(
+ save: (send: boolean) => Promise | void,
+ send: boolean,
+ t: Translate,
+ ask: (reason: string, t: Translate) => Promise = askSaveWithoutInvitations,
+): Promise {
+ try {
+ await save(send);
+ return true;
+ } catch (err) {
+ const reason = schedulingDeniedReason(err, send);
+ if (reason === null) throw err;
+ if (!(await ask(reason, t))) return false;
+ await save(false);
+ return true;
+ }
+}
diff --git a/src/lib/calendar-utils.ts b/src/lib/calendar-utils.ts
index 9cbb7cbc..5afdedeb 100644
--- a/src/lib/calendar-utils.ts
+++ b/src/lib/calendar-utils.ts
@@ -617,6 +617,38 @@ export function pickUnusedCalendarColor(usedColors: Iterable): string {
* via the `colorIsLocalOverride` flag (see getEventColor). Personal calendars
* are passed through untouched.
*/
+/**
+ * A distinct colour for each shared calendar that has neither a colour from
+ * the server nor one the viewer picked, so several shared calendars don't
+ * collide on the default colour. A shared calendar the server gives a colour
+ * keeps it until the viewer picks their own (webmail #1414). Returns only the
+ * new overrides, keyed by sharedCalendarColorKey().
+ */
+export function autoAssignSharedCalendarColors(
+ calendars: Pick[],
+ overrides: Record,
+): Record {
+ // Every colour already on screen: personal and server colours plus the
+ // existing shared overrides.
+ const used = new Set();
+ for (const cal of calendars) {
+ if (cal.color) used.add(cal.color.toLowerCase());
+ }
+ for (const color of Object.values(overrides)) {
+ if (color) used.add(color.toLowerCase());
+ }
+ const assigned: Record = {};
+ for (const cal of calendars) {
+ if (!cal.isShared || cal.color) continue;
+ const key = sharedCalendarColorKey(cal);
+ if (overrides[key] || assigned[key]) continue;
+ const color = pickUnusedCalendarColor(used);
+ used.add(color.toLowerCase());
+ assigned[key] = color;
+ }
+ return assigned;
+}
+
export function applySharedCalendarColors(
calendars: Calendar[],
overrides: Record,
diff --git a/src/lib/contact-list-filters.ts b/src/lib/contact-list-filters.ts
new file mode 100644
index 00000000..2f214b83
--- /dev/null
+++ b/src/lib/contact-list-filters.ts
@@ -0,0 +1,115 @@
+import type { AnniversaryDate, ContactCard } from '../api/types';
+import { hasContactPhoto } from './contact-utils';
+
+// The contact list's advanced filters (webmail components/contacts/contact-list.tsx).
+
+export type TriState = boolean | null;
+
+export interface ContactListFilters {
+ organization: string;
+ jobTitle: string;
+ location: string;
+ emailDomain: string;
+ birthdayMonth: number | null;
+ hasEmail: TriState;
+ hasPhone: TriState;
+ hasPhoto: TriState;
+}
+
+export const EMPTY_CONTACT_FILTERS: ContactListFilters = {
+ organization: '',
+ jobTitle: '',
+ location: '',
+ emailDomain: '',
+ birthdayMonth: null,
+ hasEmail: null,
+ hasPhone: null,
+ hasPhoto: null,
+};
+
+/** Off, then "must have", then "must not have". */
+export function cycleTri(v: TriState): TriState {
+ return v === null ? true : v === true ? false : null;
+}
+
+export function countActiveFilters(f: ContactListFilters): number {
+ let n = 0;
+ if (f.organization.trim()) n++;
+ if (f.jobTitle.trim()) n++;
+ if (f.location.trim()) n++;
+ if (f.emailDomain.trim()) n++;
+ if (f.birthdayMonth !== null) n++;
+ if (f.hasEmail !== null) n++;
+ if (f.hasPhone !== null) n++;
+ if (f.hasPhoto !== null) n++;
+ return n;
+}
+
+function matchTri(actual: boolean, filter: TriState): boolean {
+ return filter === null || actual === filter;
+}
+
+function anniversaryMonth(date: AnniversaryDate): number | null {
+ if (typeof date === 'string') {
+ const iso = date.match(/^(\d{4})-(\d{2})/);
+ if (iso) return parseInt(iso[2], 10);
+ const partial = date.match(/^--(\d{2})/);
+ if (partial) return parseInt(partial[1], 10);
+ return null;
+ }
+ if ('month' in date && date.month) return date.month;
+ if ('utc' in date && date.utc) {
+ const d = new Date(date.utc);
+ return isNaN(d.getTime()) ? null : d.getMonth() + 1;
+ }
+ return null;
+}
+
+/** Whether `contact` passes every filter that is set. */
+export function matchesContactFilters(contact: ContactCard, filters: ContactListFilters): boolean {
+ const emails = contact.emails ? Object.values(contact.emails) : [];
+ const phones = contact.phones ? Object.values(contact.phones) : [];
+ if (!matchTri(emails.length > 0, filters.hasEmail)) return false;
+ if (!matchTri(phones.length > 0, filters.hasPhone)) return false;
+ if (!matchTri(hasContactPhoto(contact), filters.hasPhoto)) return false;
+
+ const org = filters.organization.trim().toLowerCase();
+ if (org) {
+ const orgs = contact.organizations ? Object.values(contact.organizations) : [];
+ if (!orgs.some((o) => o.name?.toLowerCase().includes(org) || o.units?.some((u) => u.name?.toLowerCase().includes(org)))) {
+ return false;
+ }
+ }
+
+ const job = filters.jobTitle.trim().toLowerCase();
+ if (job) {
+ const titles = contact.titles ? Object.values(contact.titles) : [];
+ if (!titles.some((ti) => ti.name?.toLowerCase().includes(job))) return false;
+ }
+
+ const loc = filters.location.trim().toLowerCase();
+ if (loc) {
+ const addresses = contact.addresses ? Object.values(contact.addresses) : [];
+ const match = addresses.some((a) => {
+ const parts = [a.full, a.fullAddress, a.locality, a.region, a.country, a.postcode, a.street,
+ ...(a.components ?? []).map((comp) => comp.value)];
+ return parts.some((p) => !!p && p.toLowerCase().includes(loc));
+ });
+ if (!match) return false;
+ }
+
+ const domain = filters.emailDomain.trim().toLowerCase().replace(/^@/, '');
+ if (domain) {
+ const match = emails.some((e) => {
+ const at = e.address?.toLowerCase().split('@');
+ return !!at && at.length > 1 && at[1].includes(domain);
+ });
+ if (!match) return false;
+ }
+
+ if (filters.birthdayMonth !== null) {
+ const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries) : [];
+ if (!anniversaries.some((a) => a.kind === 'birth' && anniversaryMonth(a.date) === filters.birthdayMonth)) return false;
+ }
+ return true;
+}
diff --git a/src/lib/recurrence-expansion.ts b/src/lib/recurrence-expansion.ts
index fae990ec..04d3d15f 100644
--- a/src/lib/recurrence-expansion.ts
+++ b/src/lib/recurrence-expansion.ts
@@ -97,6 +97,59 @@ export function seriesOccursAt(
return (rules ?? []).some((rule) => generateDates(from, rule, at, until).some((date) => date.getTime() === at.getTime()));
}
+/**
+ * Dates stand for wall-clock times through their device-local fields. A
+ * wall time the device's clock skips (its own DST gap) can't be one: the
+ * Date moves past the gap. Such a Date carries the wall time it stands for
+ * here, so a series in another zone keeps its times on the device's DST day.
+ */
+const intendedWall = new WeakMap();
+
+/** The LocalDateTime `date` stands for. */
+function wallString(date: Date): string {
+ return intendedWall.get(date) ?? format(date, "yyyy-MM-dd'T'HH:mm:ss");
+}
+
+/** A Date for the LocalDateTime `wall` ("yyyy-MM-ddTHH:mm:ss"), remembering it when the device's clock skips it. */
+function dateForWall(wall: string): Date {
+ const date = parseISO(wall);
+ const wallMs = localDateTimeAsUtc(wall);
+ if (!isNaN(date.getTime()) && wallMs !== null && wallAsUtc(date) !== wallMs) {
+ intendedWall.set(date, utcFieldsString(wallMs));
+ }
+ return date;
+}
+
+/** The UTC fields of `ms` as a LocalDateTime. */
+function utcFieldsString(ms: number): string {
+ const w = new Date(ms);
+ const pad = (n: number, len = 2) => String(n).padStart(len, '0');
+ return `${pad(w.getUTCFullYear(), 4)}-${pad(w.getUTCMonth() + 1)}-${pad(w.getUTCDate())}`
+ + `T${pad(w.getUTCHours())}:${pad(w.getUTCMinutes())}:${pad(w.getUTCSeconds())}`;
+}
+
+/** The wall-clock fields of a LocalDateTime as a UTC timestamp, read from its digits. */
+function localDateTimeAsUtc(value: string): number | null {
+ const m = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(value);
+ if (!m) return null;
+ return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4] ?? 0), Number(m[5] ?? 0), Number(m[6] ?? 0));
+}
+
+/**
+ * The wall time `y-m-d h:mi:s` names in `timeZone`: itself, or past the gap
+ * when that zone's clock skips it (as a device-local Date would move). Null
+ * for an unknown zone.
+ */
+function wallInZone(
+ fields: [number, number, number, number, number, number],
+ timeZone: string,
+): string | null {
+ const formatter = getTzFormatter(timeZone);
+ if (!formatter) return null;
+ const instant = zonedWallMsToUtc(Date.UTC(...fields), formatter);
+ return utcFieldsString(wallClockAsUtcTimestamp(formatter, instant));
+}
+
function occurrenceDateKey(master: CalendarEvent, date: Date): string {
return master.showWithoutTime ? format(date, 'yyyy-MM-dd') : date.toISOString();
}
@@ -106,8 +159,10 @@ function expandEvent(
rangeStart: Date,
rangeEnd: Date,
): CalendarEvent[] {
- const eventStart = parseISO(master.start);
+ const eventStart = master.showWithoutTime ? parseISO(master.start) : dateForWall(master.start.slice(0, 19));
if (isNaN(eventStart.getTime())) return [];
+ // Daily occurrences are built on the event's own clock (see wallInZone).
+ const zone = !master.showWithoutTime && master.timeZone ? master.timeZone : undefined;
const rules = master.recurrenceRules || [];
const overrides = master.recurrenceOverrides || {};
@@ -120,13 +175,13 @@ function expandEvent(
for (const exRule of master.excludedRecurrenceRules || []) {
// includeStartDate=false: "the series start is always an occurrence"
// applies to recurrence rules, not to exclusion rules.
- for (const date of generateDates(eventStart, exRule, rangeStart, rangeEnd, false)) {
+ for (const date of generateDates(eventStart, exRule, rangeStart, rangeEnd, false, undefined, zone)) {
excludedDates.add(occurrenceDateKey(master, date));
}
}
for (const rule of rules) {
- const dates = generateDates(eventStart, rule, rangeStart, rangeEnd);
+ const dates = generateDates(eventStart, rule, rangeStart, rangeEnd, true, undefined, zone);
for (const date of dates) {
const dateKey = occurrenceDateKey(master, date);
@@ -136,7 +191,7 @@ function expandEvent(
const recurrenceId = master.showWithoutTime
? format(date, "yyyy-MM-dd'T'00:00:00")
- : format(date, "yyyy-MM-dd'T'HH:mm:ss");
+ : wallString(date);
const override = overrides[recurrenceId] as
| (Partial & { excluded?: boolean })
@@ -153,7 +208,7 @@ function expandEvent(
for (const [recurrenceId, rawOverride] of Object.entries(overrides)) {
const override = rawOverride as Partial & { excluded?: boolean };
if (override.excluded) continue;
- const overrideDate = parseISO(recurrenceId);
+ const overrideDate = master.showWithoutTime ? parseISO(recurrenceId) : dateForWall(recurrenceId.slice(0, 19));
if (isNaN(overrideDate.getTime())) continue;
// Overrides take precedence over excludedRecurrenceRules (they re-add
@@ -288,12 +343,16 @@ function wallClockAsUtcTimestamp(formatter: Intl.DateTimeFormat, instant: number
export function zonedWallTimeToUtc(wall: Date, timeZone: string): Date | null {
const formatter = getTzFormatter(timeZone);
if (!formatter) return null;
- const wallMs = wallAsUtc(wall);
+ return new Date(zonedWallMsToUtc(wallAsUtc(wall), formatter));
+}
+
+/** The instant of the wall clock `wallMs` (its fields read as UTC) in the formatter's zone. */
+function zonedWallMsToUtc(wallMs: number, formatter: Intl.DateTimeFormat): number {
let guess = wallMs;
for (let i = 0; i < 2; i++) {
guess = wallMs - (wallClockAsUtcTimestamp(formatter, guess) - guess);
}
- return new Date(guess);
+ return guess;
}
function createOccurrence(
@@ -304,7 +363,7 @@ function createOccurrence(
): CalendarEvent {
const startStr = master.showWithoutTime
? format(date, "yyyy-MM-dd'T'00:00:00")
- : format(date, "yyyy-MM-dd'T'HH:mm:ss");
+ : wallString(date);
// The occurrence as the rule generates it, with its override applied as a
// patch: a moved or resized occurrence has its own start, time zone and
@@ -340,7 +399,10 @@ function createOccurrence(
// occurrences on the other side of a DST transition in that zone keep
// their wall time. Reusing the master's fixed UTC offset (the fallback
// below) would shift them by the DST delta.
- const zoned = occurrence.timeZone ? zonedWallTimeToUtc(wall, occurrence.timeZone) : null;
+ // From the digits: a wall time the device's clock skips is still this one.
+ const formatter = occurrence.timeZone ? getTzFormatter(occurrence.timeZone) : null;
+ const wallMs = localDateTimeAsUtc(start);
+ const zoned = formatter && wallMs !== null ? new Date(zonedWallMsToUtc(wallMs, formatter)) : null;
if (zoned) {
utcStart = zoned.toISOString();
if (durationMs !== null) {
@@ -430,6 +492,8 @@ function generateDates(
rangeEnd: Date,
includeStartDate = true,
maxOccurrences = 500,
+ // The series' time zone: daily occurrences are built on its clock.
+ timeZone?: string,
): Date[] {
const rule = addImplicitByX(rawRule, eventStart);
const dates: Date[] = [];
@@ -465,7 +529,7 @@ function generateDates(
break;
}
- const candidates = generateCandidatesForPeriod(current, rule, eventStart);
+ const candidates = generateCandidatesForPeriod(current, rule, eventStart, timeZone);
const filtered = rule.bySetPosition?.length
? applyBySetPosition(candidates, rule.bySetPosition)
@@ -509,6 +573,7 @@ function generateCandidatesForPeriod(
periodStart: Date,
rule: RecurrenceRule,
eventStart: Date,
+ timeZone?: string,
): Date[] {
const freq = rule.frequency;
let candidates: Date[];
@@ -523,17 +588,27 @@ function generateCandidatesForPeriod(
case 'weekly':
candidates = expandWeekly(periodStart, rule, eventStart);
break;
- case 'daily':
+ case 'daily': {
// The start's wall-clock time on this day. The period anchor is
// advanced day by day and keeps whatever time a DST gap pushed it to
// (02:30 -> 03:30, or midnight -> 01:00 for an all-day event), so using
// it made every later day fail the implicit byHour and the series
// stopped at the gap. Mirrors the webmail (34fb9d247).
- candidates = [new Date(
+ // With a time zone, on that zone's clock: the device's own DST gap
+ // must not move an occurrence of a series in another zone.
+ const time = timeZone ? /T(\d{2}):(\d{2}):(\d{2})/.exec(wallString(eventStart)) : null;
+ const zoned = time && timeZone
+ ? wallInZone([
+ periodStart.getFullYear(), periodStart.getMonth(), periodStart.getDate(),
+ Number(time[1]), Number(time[2]), Number(time[3]),
+ ], timeZone)
+ : null;
+ candidates = [zoned ? dateForWall(zoned) : new Date(
periodStart.getFullYear(), periodStart.getMonth(), periodStart.getDate(),
eventStart.getHours(), eventStart.getMinutes(), eventStart.getSeconds(), eventStart.getMilliseconds(),
)];
break;
+ }
case 'hourly':
case 'minutely':
case 'secondly':
diff --git a/src/lib/sieve/__tests__/editor-seed.test.ts b/src/lib/sieve/__tests__/editor-seed.test.ts
new file mode 100644
index 00000000..66c7652f
--- /dev/null
+++ b/src/lib/sieve/__tests__/editor-seed.test.ts
@@ -0,0 +1,11 @@
+import { describe, expect, it } from 'vitest';
+import { seedsOnRender } from '../editor-seed';
+
+describe('seedsOnRender', () => {
+ it('seeds the Sieve editor when it opens, not when the script is refreshed while it is open (#1162)', () => {
+ expect(seedsOnRender(false, true)).toBe(true);
+ expect(seedsOnRender(true, true)).toBe(false);
+ expect(seedsOnRender(true, false)).toBe(false);
+ expect(seedsOnRender(false, false)).toBe(false);
+ });
+});
diff --git a/src/lib/sieve/__tests__/forward-limit-editor.test.ts b/src/lib/sieve/__tests__/forward-limit-editor.test.ts
new file mode 100644
index 00000000..91d21007
--- /dev/null
+++ b/src/lib/sieve/__tests__/forward-limit-editor.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from 'vitest';
+import type { FilterAction, FilterRule, VacationForward } from '../types';
+import { forwardsOfEditedRule, ruleForwardLimit, rulesForwardLimit } from '../forward-limit';
+
+const forward: FilterAction = { type: 'forward', value: 'x@example.com' };
+const rule = (id: string, actions: FilterAction[], extra: Partial = {}): FilterRule => ({
+ id,
+ name: id,
+ enabled: true,
+ matchType: 'all',
+ conditions: [{ field: 'from', comparator: 'contains', value: 'a' }],
+ actions,
+ stopProcessing: false,
+ ...extra,
+});
+const deputy: VacationForward = { enabled: true, to: 'deputy@example.com', keepCopy: true } as VacationForward;
+
+describe('ruleForwardLimit', () => {
+ it('warns when one message would collect more forwards than the server runs (Stalwart: 1)', () => {
+ const state = ruleForwardLimit({ actions: [forward], stopProcessing: false }, 1, { before: 1, after: 0 });
+ expect(state).toEqual({ limit: 1, overLimit: true, unavailable: false, canAddForward: false });
+ });
+
+ it('lets a rule that stops ignore the forwards below it', () => {
+ expect(ruleForwardLimit({ actions: [forward], stopProcessing: true }, 1, { before: 0, after: 3 }).overLimit).toBe(false);
+ expect(ruleForwardLimit({ actions: [forward], stopProcessing: false }, 1, { before: 0, after: 3 }).overLimit).toBe(true);
+ });
+
+ it('offers a forward while there is room and none on a server without forwarding', () => {
+ expect(ruleForwardLimit({ actions: [{ type: 'move', value: 'A' }], stopProcessing: false }, 1, { before: 0, after: 0 }).canAddForward).toBe(true);
+ const off = ruleForwardLimit({ actions: [forward], stopProcessing: false }, 0, { before: 0, after: 0 });
+ expect(off).toMatchObject({ limit: null, overLimit: false, unavailable: true, canAddForward: false });
+ });
+
+ it('says nothing without a cap from the server', () => {
+ expect(ruleForwardLimit({ actions: [forward, forward], stopProcessing: false }, undefined, { before: 5, after: 5 }))
+ .toEqual({ limit: null, overLimit: false, unavailable: false, canAddForward: true });
+ });
+});
+
+describe('forwardsOfEditedRule', () => {
+ const rules = [rule('a', [forward]), rule('b', [{ type: 'move', value: 'A' }]), rule('ext', [forward], { origin: 'external' })];
+
+ it('counts the forwards above and below the edited rule, the out of office forwarding first', () => {
+ expect(forwardsOfEditedRule(rules, deputy, 'b')).toEqual({ before: 2, after: 1 });
+ expect(forwardsOfEditedRule(rules, null, 'a')).toEqual({ before: 0, after: 1 });
+ });
+
+ it('puts a new rule below Bulwark\'s own rules, above the external ones', () => {
+ expect(forwardsOfEditedRule(rules, null, undefined)).toEqual({ before: 1, after: 1 });
+ });
+});
+
+describe('rulesForwardLimit', () => {
+ it('flags rules whose forwards add up past the cap, and forwards a server ignores', () => {
+ const rules = [rule('a', [forward]), rule('b', [forward])];
+ expect(rulesForwardLimit(rules, null, 1)).toEqual({ tooMany: true, ignored: false });
+ expect(rulesForwardLimit(rules, null, 2)).toEqual({ tooMany: false, ignored: false });
+ expect(rulesForwardLimit([rules[0]], deputy, 1)).toEqual({ tooMany: true, ignored: false });
+ expect(rulesForwardLimit(rules, null, 0)).toEqual({ tooMany: false, ignored: true });
+ });
+});
diff --git a/src/lib/sieve/__tests__/forward-limit-vacation.test.ts b/src/lib/sieve/__tests__/forward-limit-vacation.test.ts
new file mode 100644
index 00000000..9e7b78b1
--- /dev/null
+++ b/src/lib/sieve/__tests__/forward-limit-vacation.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from 'vitest';
+import type { FilterAction, FilterRule, VacationForward } from '../types';
+import { generateScript } from '../generator';
+import { VACATION_FORWARD_MARKER } from '../vacation-forward';
+import { forwardsAround, inRunOrder, ruleForwards, ruleStops, worstCaseForwards } from '../forward-limit';
+
+const forward: FilterAction = { type: 'forward', value: 'office@example.com' };
+const rule = (actions: FilterAction[], stopProcessing = false): FilterRule => ({
+ id: `r-${Math.random()}`,
+ name: 'R',
+ enabled: true,
+ matchType: 'all',
+ conditions: [{ field: 'from', comparator: 'contains', value: 'a' }],
+ actions,
+ stopProcessing,
+});
+const vacationForward = (keepCopy: boolean, enabled = true): VacationForward => ({
+ enabled,
+ to: 'deputy@example.com',
+ keepCopy,
+});
+
+describe('the out of office forwarding in the run order', () => {
+ const rules = [rule([forward])];
+
+ it('goes ahead of the rules, and stops unless it keeps a copy', () => {
+ expect(inRunOrder(rules, vacationForward(true))).toEqual([
+ { enabled: true, actions: [{ type: 'forward', value: 'deputy@example.com' }], stopProcessing: false },
+ ...rules,
+ ]);
+ expect(inRunOrder(rules, vacationForward(false))[0].stopProcessing).toBe(true);
+ });
+
+ it('is left out while it is off', () => {
+ expect(inRunOrder(rules, vacationForward(true, false))).toBe(rules);
+ expect(inRunOrder(rules, null)).toBe(rules);
+ expect(inRunOrder(rules, undefined)).toBe(rules);
+ });
+
+ it('agrees with the script: one redirect ahead of every rule, and a stop unless a copy is kept', () => {
+ for (const keepCopy of [true, false]) {
+ const script = generateScript(rules, undefined, { vacationForward: vacationForward(keepCopy) });
+ expect(script.indexOf(VACATION_FORWARD_MARKER)).toBeGreaterThan(-1);
+ expect(script.indexOf(VACATION_FORWARD_MARKER)).toBeLessThan(script.indexOf('# Rule:'));
+ const lines = script.split(VACATION_FORWARD_MARKER)[1].split('# Rule:')[0].split('\n').map((line) => line.trim());
+ const [ahead] = inRunOrder([], vacationForward(keepCopy));
+ expect(lines.filter((line) => line.startsWith('redirect ')).length, `keepCopy ${keepCopy}`).toBe(ruleForwards(ahead));
+ expect(lines.includes('stop;'), `keepCopy ${keepCopy}`).toBe(ruleStops(ahead));
+ }
+ });
+
+ it('adds its forward to every rule only when it keeps a copy', () => {
+ // A new rule goes behind Bulwark's rules, here behind the one rule.
+ expect(forwardsAround(inRunOrder(rules, vacationForward(true)), 2, false)).toEqual({ before: 2, after: 0 });
+ expect(forwardsAround(inRunOrder(rules, vacationForward(false)), 2, false)).toEqual({ before: 1, after: 0 });
+ expect(worstCaseForwards(inRunOrder(rules, vacationForward(true)))).toBe(2);
+ expect(worstCaseForwards(inRunOrder(rules, vacationForward(false)))).toBe(1);
+ });
+});
diff --git a/src/lib/sieve/__tests__/forward-limit.test.ts b/src/lib/sieve/__tests__/forward-limit.test.ts
new file mode 100644
index 00000000..bb896ec7
--- /dev/null
+++ b/src/lib/sieve/__tests__/forward-limit.test.ts
@@ -0,0 +1,162 @@
+import { describe, expect, it } from 'vitest';
+import type { FilterAction, FilterRule } from '../types';
+import { generateScript } from '../generator';
+import { forwardsAround, ruleForwards, ruleStops, worstCaseForwards } from '../forward-limit';
+
+const forward: FilterAction = { type: 'forward', value: 'x@example.com' };
+const rule = (actions: FilterAction[], stopProcessing = false, enabled = true): FilterRule => ({
+ id: `r-${Math.random()}`,
+ name: 'R',
+ enabled,
+ matchType: 'all',
+ conditions: [{ field: 'from', comparator: 'contains', value: 'a' }],
+ actions,
+ stopProcessing,
+});
+
+// "Forward to a colleague, delete silently, stop processing", and a rule for
+// every other message that forwards too.
+const vendor = rule([forward, { type: 'discard' }], true);
+const everyoneElse = rule([forward]);
+
+describe('the forwards of one rule', () => {
+ it('counts those ahead of a Stop action', () => {
+ expect(ruleForwards(rule([forward, forward]))).toBe(2);
+ expect(ruleForwards(rule([forward, { type: 'stop' }, forward]))).toBe(1);
+ expect(ruleForwards(rule([{ type: 'mark_read' }]))).toBe(0);
+ });
+
+ it('knows a rule stops by its checkbox or a Stop action', () => {
+ expect(ruleStops(rule([forward], true))).toBe(true);
+ expect(ruleStops(rule([forward, { type: 'stop' }]))).toBe(true);
+ // Delete silently and Reject alone do not end the script.
+ expect(ruleStops(rule([forward, { type: 'discard' }]))).toBe(false);
+ expect(ruleStops(rule([{ type: 'reject', value: 'no' }]))).toBe(false);
+ });
+});
+
+describe('the most forwards one message can collect', () => {
+ it('stays at one when a rule that forwards stops ahead of another that forwards', () => {
+ expect(worstCaseForwards([vendor, everyoneElse])).toBe(1);
+ });
+
+ it('is two once the rule for every message comes first', () => {
+ // A message for the colleague meets both: the second forward would be skipped.
+ expect(worstCaseForwards([everyoneElse, vendor])).toBe(2);
+ });
+
+ it('adds up the rules that let a message go on', () => {
+ expect(worstCaseForwards([everyoneElse, rule([forward]), rule([forward])])).toBe(3);
+ expect(worstCaseForwards([rule([forward], true), rule([forward, forward], true)])).toBe(2);
+ });
+
+ it('leaves out rules that are off', () => {
+ expect(worstCaseForwards([rule([forward], false, false), vendor])).toBe(1);
+ });
+
+ it('is none without forwards', () => {
+ expect(worstCaseForwards([])).toBe(0);
+ expect(worstCaseForwards([rule([{ type: 'mark_read' }], true)])).toBe(0);
+ });
+});
+
+describe('the forwards around one rule', () => {
+ it('takes nothing from a rule above that stops, for a new rule below it', () => {
+ expect(forwardsAround([vendor], 1, false)).toEqual({ before: 0, after: 0 });
+ });
+
+ it('takes the forwards of a rule above that lets messages go on', () => {
+ const going = rule([forward, { type: 'discard' }]);
+ expect(forwardsAround([going], 1, false)).toEqual({ before: 1, after: 0 });
+ });
+
+ it('takes the worst case below an edited rule, without the rule itself', () => {
+ expect(forwardsAround([vendor, everyoneElse], 0, true)).toEqual({ before: 0, after: 1 });
+ expect(forwardsAround([everyoneElse, vendor], 1, true)).toEqual({ before: 1, after: 0 });
+ });
+
+ it('puts a new rule at the index, keeping the rule there below it', () => {
+ expect(forwardsAround([everyoneElse, vendor], 1, false)).toEqual({ before: 1, after: 1 });
+ });
+});
+
+describe('the count and the script', () => {
+ it('agree on the forwards a rule runs and whether it stops', () => {
+ const variants: FilterAction[][] = [
+ [forward],
+ [forward, { type: 'discard' }],
+ [forward, { type: 'reject', value: 'no' }],
+ [forward, { type: 'move', value: 'Archive' }],
+ [{ type: 'mark_read' }, forward, forward],
+ [forward, { type: 'stop' }, forward],
+ [{ type: 'stop' }, forward],
+ ];
+ for (const actions of variants) {
+ for (const stopProcessing of [false, true]) {
+ const r = rule(actions, stopProcessing);
+ const lines = generateScript([r]).split('# Rule:')[1].split('\n').map((line) => line.trim());
+ const stop = lines.indexOf('stop;');
+ const runs = stop === -1 ? lines : lines.slice(0, stop);
+ const label = `${actions.map((a) => a.type).join(', ')}${stopProcessing ? ', stop processing' : ''}`;
+ expect(runs.filter((line) => line.startsWith('redirect ')).length, label).toBe(ruleForwards(r));
+ expect(stop !== -1, label).toBe(ruleStops(r));
+ }
+ }
+ });
+});
+
+describe('against every set of rules a message can match', () => {
+ // A message that matches the rules in `matched`: the forwards it collects,
+ // and the rules that run for it.
+ function walk(rules: FilterRule[], matched: Set): { forwards: number; ran: Set } {
+ let forwards = 0;
+ const ran = new Set();
+ for (let i = 0; i < rules.length; i++) {
+ if (!rules[i].enabled || !matched.has(i)) continue;
+ ran.add(i);
+ forwards += ruleForwards(rules[i]);
+ if (ruleStops(rules[i])) break;
+ }
+ return { forwards, ran };
+ }
+ // The most forwards one message can collect; with `runs`, one the rule
+ // there runs for.
+ function mostCollected(rules: FilterRule[], runs?: number): number {
+ let most = 0;
+ for (let mask = 0; mask < 1 << rules.length; mask++) {
+ const matched = new Set(rules.map((_, i) => i).filter((i) => mask & (1 << i)));
+ const { forwards, ran } = walk(rules, matched);
+ if (runs === undefined || ran.has(runs)) most = Math.max(most, forwards);
+ }
+ return most;
+ }
+
+ // Fixed seed: the same rule lists on every run.
+ let seed = 7;
+ const random = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
+ const pool: FilterAction[][] = [
+ [], [forward], [forward, forward], [forward, { type: 'discard' }],
+ [forward, { type: 'stop' }], [{ type: 'stop' }, forward], [{ type: 'mark_read' }],
+ ];
+ const lists = Array.from({ length: 300 }, () =>
+ Array.from({ length: Math.floor(random() * 7) }, () =>
+ rule(pool[Math.floor(random() * pool.length)], random() < 0.4, random() < 0.8)));
+
+ it('finds the most forwards one message can collect', () => {
+ for (const rules of lists) expect(worstCaseForwards(rules)).toBe(mostCollected(rules));
+ });
+
+ it('finds the most a message the rule runs for can collect, for rules in place and new ones', () => {
+ for (const rules of lists) {
+ for (let i = 0; i < rules.length; i++) {
+ const r = rules[i];
+ if (!r.enabled) continue;
+ const { before, after } = forwardsAround(rules, i, true);
+ expect(before + ruleForwards(r) + (ruleStops(r) ? 0 : after)).toBe(mostCollected(rules, i));
+ // The same rule about to be put there, rather than there already.
+ const others = rules.filter((_, j) => j !== i);
+ expect(forwardsAround(others, i, false)).toEqual({ before, after });
+ }
+ }
+ });
+});
diff --git a/src/lib/sieve/editor-seed.ts b/src/lib/sieve/editor-seed.ts
new file mode 100644
index 00000000..86f20276
--- /dev/null
+++ b/src/lib/sieve/editor-seed.ts
@@ -0,0 +1,8 @@
+/**
+ * Whether an editor sheet takes its content afresh: only when it opens. A
+ * new `content` while it is open (a store refresh) must not replace what the
+ * user typed (#1162).
+ */
+export function seedsOnRender(wasVisible: boolean, visible: boolean): boolean {
+ return visible && !wasVisible;
+}
diff --git a/src/lib/sieve/forward-limit.ts b/src/lib/sieve/forward-limit.ts
new file mode 100644
index 00000000..fa16ab42
--- /dev/null
+++ b/src/lib/sieve/forward-limit.ts
@@ -0,0 +1,136 @@
+import type { FilterRule, VacationForward } from './types';
+
+// Ported from the webmail's lib/filters/forward-limit.ts (#1155, #1208).
+
+/*
+ * A server lets one message trigger only so many redirects
+ * (maxNumberRedirects, 1 for user scripts on Stalwart by default) and skips
+ * the ones over that. The rules run in order, and a rule that stops ("Stop
+ * processing", or the Stop action) ends the script for the messages it
+ * matches: the forwards of the rules below it never add to its own. What
+ * counts is the most forwards a single message can collect, not how many
+ * forwards there are.
+ */
+
+type RuleForwards = Pick;
+
+/** The forwards of a rule that run: those ahead of a Stop action. */
+export function ruleForwards(rule: Pick): number {
+ let forwards = 0;
+ for (const action of rule.actions) {
+ if (action.type === 'stop') break;
+ if (action.type === 'forward') forwards++;
+ }
+ return forwards;
+}
+
+/** The rule ends the script for the messages it matches. */
+export function ruleStops(rule: Pick): boolean {
+ return rule.stopProcessing || rule.actions.some((action) => action.type === 'stop');
+}
+
+/** The most forwards one message can collect going through `rules`, in their order. */
+export function worstCaseForwards(rules: RuleForwards[]): number {
+ // From the rules so far that let the message go on.
+ let open = 0;
+ let worst = 0;
+ for (const rule of rules) {
+ if (!rule.enabled) continue;
+ const forwards = ruleForwards(rule);
+ if (ruleStops(rule)) worst = Math.max(worst, open + forwards);
+ else open += forwards;
+ }
+ return Math.max(worst, open);
+}
+
+/**
+ * For the rule at `index` of `rules` (in the order they run): the forwards a
+ * message can have collected when it reaches the rule, and the most it can
+ * still collect below it when the rule lets it go on. `replaces`: the rule
+ * is at `index` already, rather than about to be put there.
+ */
+export function forwardsAround(
+ rules: RuleForwards[],
+ index: number,
+ replaces: boolean,
+): { before: number; after: number } {
+ // A rule above that stops lets only the messages it does not match go on,
+ // so its forwards never meet this rule's.
+ const before = rules
+ .slice(0, index)
+ .filter((rule) => rule.enabled && !ruleStops(rule))
+ .reduce((n, rule) => n + ruleForwards(rule), 0);
+ const after = worstCaseForwards(rules.slice(replaces ? index + 1 : index));
+ return { before, after };
+}
+
+/**
+ * The rules in the order the script runs them: forwarding from the out of
+ * office card goes ahead of every rule, and ends the script unless it keeps
+ * a copy here.
+ */
+export function inRunOrder(rules: RuleForwards[], forward: VacationForward | null | undefined): RuleForwards[] {
+ if (!forward?.enabled) return rules;
+ return [{ enabled: true, actions: [{ type: 'forward', value: forward.to }], stopProcessing: !forward.keepCopy }, ...rules];
+}
+
+// ── App additions: what the rule editor and the rule list show ──
+
+type ListedRule = RuleForwards & Pick;
+
+/**
+ * forwardsAround for the rule the editor has open in the filter list: the
+ * edited rule where it is, a new one below Bulwark's own rules (where
+ * addRule puts it), behind the out of office forwarding.
+ */
+export function forwardsOfEditedRule(
+ rules: ListedRule[],
+ forward: VacationForward | null | undefined,
+ editedRuleId: string | undefined,
+): { before: number; after: number } {
+ const runOrder = inRunOrder(rules, forward);
+ const editedIndex = editedRuleId ? rules.findIndex((r) => r.id === editedRuleId) : -1;
+ const own = rules.filter((r) => r.origin !== 'external' && r.origin !== 'opaque').length;
+ return forwardsAround(runOrder, runOrder.length - rules.length + (editedIndex >= 0 ? editedIndex : own), editedIndex >= 0);
+}
+
+export interface RuleForwardLimit {
+ /** The server's cap on forwards per message, when it has one above 0. */
+ limit: number | null;
+ /** One message can collect more forwards than the cap with this rule. */
+ overLimit: boolean;
+ /** The server runs no forward at all (a cap of 0). */
+ unavailable: boolean;
+ /** Whether another action may become a forward. */
+ canAddForward: boolean;
+}
+
+/** What the rule editor says about the server's redirect cap for the rule as edited. */
+export function ruleForwardLimit(
+ rule: Pick,
+ maxRedirects: number | null | undefined,
+ around: { before: number; after: number },
+): RuleForwardLimit {
+ const count = ruleForwards(rule);
+ const others = around.before + (ruleStops(rule) ? 0 : around.after);
+ const limit = typeof maxRedirects === 'number' && maxRedirects > 0 ? maxRedirects : null;
+ const unavailable = maxRedirects === 0;
+ return {
+ limit,
+ overLimit: limit !== null && count + others > limit,
+ unavailable,
+ canAddForward: !unavailable && (limit === null || count + others < limit),
+ };
+}
+
+/** What the rule list says: the rules' forwards over the cap, or ignored by a server without forwarding. */
+export function rulesForwardLimit(
+ rules: RuleForwards[],
+ forward: VacationForward | null | undefined,
+ maxRedirects: number | null | undefined,
+): { tooMany: boolean; ignored: boolean } {
+ return {
+ tooMany: typeof maxRedirects === 'number' && maxRedirects > 0 && worstCaseForwards(inRunOrder(rules, forward)) > maxRedirects,
+ ignored: maxRedirects === 0 && worstCaseForwards(rules) > 0,
+ };
+}
diff --git a/src/lib/task-editor.ts b/src/lib/task-editor.ts
index 8933c8a0..6171a76d 100644
--- a/src/lib/task-editor.ts
+++ b/src/lib/task-editor.ts
@@ -7,6 +7,7 @@ import {
getEffectiveTimeZone,
isValidTimeZone,
} from './calendar-timezone';
+import { editorFormSnapshot } from './calendar-editor-discard';
export type PriorityLevel = 'none' | 'high' | 'medium' | 'low';
@@ -44,6 +45,15 @@ export function emptyTaskEditor(calendarId: string): TaskEditorState {
return { id: null, title: '', description: '', due: null, withTime: false, priority: 'none', calendarId };
}
+/** Whether the editor holds changes against the task it was opened on (or the empty form). */
+export function taskEditorChanged(baseline: TaskEditorState, current: TaskEditorState): boolean {
+ const key = (e: TaskEditorState) => editorFormSnapshot({
+ id: e.id, title: e.title, description: e.description, due: e.due?.getTime() ?? null,
+ withTime: e.withTime, priority: e.priority, calendarId: e.calendarId,
+ });
+ return key(baseline) !== key(current);
+}
+
export function taskEditorFromTask(
task: Pick,
fallbackCalendarId: string,
diff --git a/src/lib/vacation-limits.ts b/src/lib/vacation-limits.ts
new file mode 100644
index 00000000..bd084126
--- /dev/null
+++ b/src/lib/vacation-limits.ts
@@ -0,0 +1,29 @@
+// Stalwart refuses a vacation subject of 512 bytes or more and a text or
+// HTML body of 2048 bytes or more, answering only "Field could not be set"
+// (webmail components/settings/vacation-settings.tsx).
+export const STALWART_VACATION_LIMITS = { subject: 511, body: 2047 } as const;
+
+export type VacationLimits = { subject: number; body: number };
+
+/** The UTF-8 length of `value` in bytes. */
+export function utf8Length(value: string): number {
+ let bytes = 0;
+ for (const ch of value) {
+ const code = ch.codePointAt(0)!;
+ bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4;
+ }
+ return bytes;
+}
+
+/** Which parts of the reply as a save sends it are over the server's limits; none without limits. */
+export function vacationOversize(
+ reply: { subject: string; textBody: string; htmlBody: string | null },
+ limits: VacationLimits | null,
+): { subject: boolean; body: boolean } {
+ if (!limits) return { subject: false, body: false };
+ return {
+ subject: utf8Length(reply.subject) > limits.subject,
+ body: utf8Length(reply.textBody) > limits.body
+ || (reply.htmlBody !== null && utf8Length(reply.htmlBody) > limits.body),
+ };
+}
diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx
index 51c5c672..d769e6ec 100644
--- a/src/screens/CalendarScreen.tsx
+++ b/src/screens/CalendarScreen.tsx
@@ -42,6 +42,8 @@ import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'
import { useColors } from '../theme/colors';
import { CalendarAccountChangedError, calendarOperationScope, useCalendarStore } from '../stores/calendar-store';
import { useSettingsStore } from '../stores/settings-store';
+import { appendEventNote } from '../lib/calendar-event-note';
+import { toast } from '../stores/toast-store';
import { MonthView } from '../components/calendar/MonthView';
import { MonthScrollView } from '../components/calendar/MonthScrollView';
import { WeekView } from '../components/calendar/WeekView';
@@ -87,7 +89,7 @@ import {
getEventStartDate,
getPrimaryCalendarId,
getTaskDueDate,
- pickUnusedCalendarColor,
+ autoAssignSharedCalendarColors,
sharedCalendarColorKey,
reopenedEdit,
} from '../lib/calendar-utils';
@@ -165,6 +167,7 @@ export default function CalendarScreen() {
const calendarFreeScroll = useSettingsStore((s) => s.calendarFreeScroll);
const calendarTimeFormat = useSettingsStore((s) => s.calendarTimeFormat);
const showBirthdayCalendar = useSettingsStore((s) => s.showBirthdayCalendar);
+ const birthdayCalendarColor = useSettingsStore((s) => s.birthdayCalendarColor);
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
@@ -308,34 +311,18 @@ export default function CalendarScreen() {
[storeCalendars, sharedCalendarColors],
);
- // Auto-assign a random, not-yet-used palette color to any freshly shared
- // calendar so multiple shared calendars don't collide on one color. Runs
- // once per calendar (guarded by the presence of an existing key), and the
- // user can still overwrite it from the sidebar.
+ // Auto-assign a not-yet-used palette color to any freshly shared calendar
+ // without a server colour, so multiple shared calendars don't collide on
+ // one color (#1414: one with a server colour keeps it). Runs once per
+ // calendar, and the user can still overwrite it from the sidebar.
React.useEffect(() => {
- const missing = storeCalendars.filter(
- (cal) => cal.isShared && !sharedCalendarColors[sharedCalendarColorKey(cal)],
- );
- if (missing.length === 0) return;
- // Seed "used" with personal calendar colors plus already-assigned shared
- // overrides so the picks stay distinct from what's already on screen.
- const used = new Set();
- for (const cal of storeCalendars) {
- if (!cal.isShared && cal.color) used.add(cal.color.toLowerCase());
- }
- for (const color of Object.values(sharedCalendarColors)) {
- if (color) used.add(color.toLowerCase());
- }
- for (const cal of missing) {
- const color = pickUnusedCalendarColor(used);
- used.add(color.toLowerCase());
- setSharedCalendarColor(sharedCalendarColorKey(cal), color);
- }
+ const assigned = autoAssignSharedCalendarColors(storeCalendars, sharedCalendarColors);
+ for (const [key, color] of Object.entries(assigned)) setSharedCalendarColor(key, color);
}, [storeCalendars, sharedCalendarColors, setSharedCalendarColor]);
const allCalendars = React.useMemo(
- () => (showBirthdayCalendar ? [...displayCalendars, createBirthdayCalendar()] : displayCalendars),
- [displayCalendars, showBirthdayCalendar],
+ () => (showBirthdayCalendar ? [...displayCalendars, createBirthdayCalendar(undefined, birthdayCalendarColor)] : displayCalendars),
+ [displayCalendars, showBirthdayCalendar, birthdayCalendarColor],
);
// Tasks with a due date are overlaid on the grid as chips (webmail's
// showTasksOnCalendar); tapping one opens the tasks sheet on that task.
@@ -834,6 +821,22 @@ export default function CalendarScreen() {
[deleteTask, t],
);
+ // A quick note from the detail sheet goes under the description with a
+ // time stamp (webmail's handleSaveNoteFromDetail).
+ const handleSaveNoteFromDetail = React.useCallback(
+ async (event: CalendarEvent, note: string) => {
+ const description = appendEventNote(event.description, note, displayNow());
+ try {
+ await updateEvent(event.id, { description });
+ setDetailEvent((current) => (current?.id === event.id ? { ...current, description } : current));
+ toast.success(t('calendar.detail.note_saved', 'Note saved'));
+ } catch (err) {
+ reportError(err);
+ }
+ },
+ [updateEvent, reportError, t],
+ );
+
// Clone the event one day later and open it in the editor (webmail's
// handleDuplicateFromDetail).
const handleDuplicateFromDetail = React.useCallback(
@@ -900,6 +903,10 @@ export default function CalendarScreen() {
const handleSetCalendarColor = React.useCallback(
(cal: Calendar, color: string) => {
+ if (cal.id === BIRTHDAY_CALENDAR_ID) {
+ useSettingsStore.getState().updateSetting('birthdayCalendarColor', color);
+ return;
+ }
if (cal.isShared) {
// Per-viewer recolor (#345): the owner's colour is left alone.
setSharedCalendarColor(sharedCalendarColorKey(cal), color);
@@ -1192,6 +1199,7 @@ export default function CalendarScreen() {
onDuplicate={(ev) => { if (!isReadOnlyEvent(ev)) void handleDuplicateFromDetail(ev); }}
onExport={handleExportFromDetail}
onCopyLink={handleCopyLink}
+ onSaveNote={handleSaveNoteFromDetail}
onRsvp={async (ev, participantId, status) => {
// An answer on one occurrence of a series asks whether it covers
// just that occurrence or the whole series (webmail #1086).
diff --git a/src/screens/ContactsScreen.tsx b/src/screens/ContactsScreen.tsx
index f43e2be0..dc33e4a1 100644
--- a/src/screens/ContactsScreen.tsx
+++ b/src/screens/ContactsScreen.tsx
@@ -20,8 +20,12 @@ import { useBackWhileFocused } from '../lib/use-back-while-focused';
import { listBackStep } from '../lib/list-back';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import {
- Search, Plus, UserCircle, X, Menu, Trash2, Tag, FolderInput, Upload, CheckSquare, Share2,
+ Search, Plus, UserCircle, X, Menu, Trash2, Tag, FolderInput, Upload, CheckSquare, Share2, Filter,
} from '../components/icons';
+import { ContactFiltersPanel } from '../components/contacts/ContactFiltersPanel';
+import {
+ countActiveFilters, EMPTY_CONTACT_FILTERS, matchesContactFilters, type ContactListFilters,
+} from '../lib/contact-list-filters';
import type { RootStackParamList } from '../navigation/types';
import type { ContactCard } from '../api/types';
import {
@@ -137,6 +141,9 @@ export default function ContactsScreen({ onOpenContact, activeContactId = null }
const sortByLastName = useSettingsStore((s) => s.sortContactsByLastName);
const [searchQuery, setSearchQuery] = React.useState('');
+ const [filtersOpen, setFiltersOpen] = React.useState(false);
+ const [filters, setFilters] = React.useState(EMPTY_CONTACT_FILTERS);
+ const activeFilterCount = countActiveFilters(filters);
// The company directory shows in place of the list (#1253). Not a stored
// category: it is the server's list, not the user's contacts.
const [showDirectory, setShowDirectory] = React.useState(false);
@@ -201,9 +208,11 @@ export default function ContactsScreen({ onOpenContact, activeContactId = null }
);
const visible = React.useMemo(() => {
- if (!searchQuery) return sorted;
- return sorted.filter((c) => matchesContactSearch(c, searchQuery));
- }, [sorted, searchQuery]);
+ // The filters sit under the search field and apply while it is open.
+ const filtered = searchActive && activeFilterCount > 0 ? sorted.filter((c) => matchesContactFilters(c, filters)) : sorted;
+ if (!searchQuery) return filtered;
+ return filtered.filter((c) => matchesContactSearch(c, searchQuery));
+ }, [sorted, searchQuery, filters, activeFilterCount, searchActive]);
const sections = React.useMemo(() => {
if (groupByLetter) return groupContacts(visible, sortByLastName);
@@ -538,8 +547,20 @@ export default function ContactsScreen({ onOpenContact, activeContactId = null }
)}
+ setFiltersOpen((v) => !v)}
+ hitSlop={8}
+ accessibilityRole="button"
+ accessibilityLabel={t('contacts.filters.toggle', 'Filters')}
+ accessibilityState={{ expanded: filtersOpen }}
+ >
+ 0 ? c.primary : c.textMuted} />
+
)}
+ {searchActive && filtersOpen && (
+ setFiltersOpen(false)} />
+ )}
{!selectionMode && (
{
- if (isFolder(row) || isSymlink(row) || !fileNodeAccess(row, allNodes).duplicate) return;
+ // A folder is copied with everything in it (webmail copyFileNode).
+ if (isSymlink(row) || !fileNodeAccess(row, allNodes).duplicate) return;
setBusyId(row.id);
try {
const siblings = new Set(
allNodes.filter((n) => (n.parentId ?? null) === (row.parentId ?? null)).map((n) => n.name),
);
- await copyFileNode(row, row.parentId ?? null, getUniqueName(row.name, siblings));
+ await copyFileTree(row, allNodes, row.parentId ?? null, getUniqueName(row.name, siblings));
await loadFiles('refresh');
} catch (e) {
Alert.alert(t('files.duplicate_error', 'Failed to duplicate'), fileErrorMessage(e, t));
@@ -1641,7 +1642,7 @@ function ActionsSheet({
{t('files.move_to', 'Move to…')}
) : null}
- {access.duplicate && isFile ? (
+ {access.duplicate && !isSymlink(target) ? (
onDuplicate(target)}>
{t('files.duplicate', 'Duplicate')}
diff --git a/src/stores/__tests__/calendar-store.test.ts b/src/stores/__tests__/calendar-store.test.ts
index ab33af99..fbc32912 100644
--- a/src/stores/__tests__/calendar-store.test.ts
+++ b/src/stores/__tests__/calendar-store.test.ts
@@ -656,6 +656,49 @@ describe('calendar-store', () => {
});
});
+ describe('updateTask in shared calendars (#1204)', () => {
+ const calendars = [
+ { id: 'own', name: 'Own' },
+ { id: 'acc-2:shared', originalId: 'shared', accountId: 'acc-2', isShared: true, name: 'Team' },
+ { id: 'acc-2:other', originalId: 'other', accountId: 'acc-2', isShared: true, name: 'Team 2' },
+ ] as any[];
+
+ it('moves a task within a shared account under the raw calendar id', async () => {
+ useCalendarStore.setState({
+ calendars,
+ tasks: [{ id: 'acc-2:t1', originalId: 't1', accountId: 'acc-2', calendarIds: { 'acc-2:shared': true } } as any],
+ });
+ mockUpdateEvent.mockResolvedValue(undefined);
+ await useCalendarStore.getState().updateTask('acc-2:t1', { calendarIds: { 'acc-2:other': true } });
+ expect(mockUpdateEvent).toHaveBeenCalledWith('t1', { calendarIds: { other: true } }, undefined, 'acc-2');
+ expect(mockCreateEvent).not.toHaveBeenCalled();
+ });
+
+ it('moves an own task into a shared calendar by making it there and removing the original', async () => {
+ useCalendarStore.setState({
+ calendars,
+ tasks: [{ id: 't1', calendarIds: { own: true }, title: 'Pay' } as any],
+ });
+ mockGetEvents.mockResolvedValue([{
+ id: 't1', '@type': 'Task', uid: 'u-1', title: 'Pay', calendarIds: { own: true }, sequence: 2, created: 'x',
+ }]);
+ mockCreateEvent.mockResolvedValue({ id: 'n1' });
+ mockDeleteEvents.mockResolvedValue(undefined);
+ await useCalendarStore.getState().updateTask('t1', { calendarIds: { 'acc-2:shared': true }, title: 'Pay rent' });
+
+ expect(mockUpdateEvent).not.toHaveBeenCalled();
+ const [created, calendarId, , account] = mockCreateEvent.mock.calls[0];
+ expect(calendarId).toBe('shared');
+ expect(account).toBe('acc-2');
+ expect(created).toMatchObject({ '@type': 'Task', uid: 'u-1', title: 'Pay rent' });
+ expect(created).not.toHaveProperty('id');
+ expect(created).not.toHaveProperty('sequence');
+ expect(mockDeleteEvents).toHaveBeenCalledWith(['t1'], undefined, 'acc-1');
+ // Created first: a failed create leaves the task where it was.
+ expect(mockCreateEvent.mock.invocationCallOrder[0]).toBeLessThan(mockDeleteEvents.mock.invocationCallOrder[0]);
+ });
+ });
+
describe('deleteEvent', () => {
it('should remove event from list', async () => {
useCalendarStore.setState({
@@ -669,6 +712,22 @@ describe('calendar-store', () => {
expect(useCalendarStore.getState().events[0].id).toBe('ev2');
});
+ it('sends no scheduling message for a meeting with attendees when asked not to (a cancelled one)', async () => {
+ const participants = {
+ o: { roles: { owner: true }, calendarAddress: 'mailto:boss@example.com' },
+ me: { roles: { attendee: true }, calendarAddress: 'mailto:me@example.com' },
+ };
+ useCalendarStore.setState({ events: [{ id: 'ev1', participants } as any] });
+ mockDeleteEvents.mockResolvedValue(undefined);
+
+ await useCalendarStore.getState().deleteEvent('ev1', { sendSchedulingMessages: false });
+ expect(mockDeleteEvents).toHaveBeenLastCalledWith(['ev1'], false, 'acc-1');
+
+ useCalendarStore.setState({ events: [{ id: 'ev2', participants } as any] });
+ await useCalendarStore.getState().deleteEvent('ev2');
+ expect(mockDeleteEvents).toHaveBeenLastCalledWith(['ev2'], true, 'acc-1');
+ });
+
it('should drop every expanded occurrence of a destroyed master and refetch', async () => {
useCalendarStore.setState({
calendars: [{ id: 'cal-1', name: 'Personal' } as any],
diff --git a/src/stores/__tests__/contacts-store.test.ts b/src/stores/__tests__/contacts-store.test.ts
index c4b78a27..b922bd35 100644
--- a/src/stores/__tests__/contacts-store.test.ts
+++ b/src/stores/__tests__/contacts-store.test.ts
@@ -591,6 +591,32 @@ describe('contacts-store', () => {
});
});
+ describe('deleting a category (removeKeyword / addKeyword)', () => {
+ it('takes the category off every card that has it and puts it back on those on Undo', async () => {
+ useContactsStore.setState({
+ contacts: [
+ card('c1', { keywords: { Gone: true, Keep: true } }),
+ card('c2', { keywords: { Gone: true } }),
+ card('c3', { keywords: { Other: true } }),
+ ],
+ selectedCategory: { type: 'keyword', keyword: 'Gone' },
+ });
+ mockUpdateContact.mockResolvedValue(undefined);
+ const ids = await useContactsStore.getState().removeKeyword('Gone');
+ expect(ids).toEqual(['c1', 'c2']);
+ expect(mockUpdateContact).toHaveBeenCalledWith('c1', { keywords: { Keep: true } }, undefined);
+ expect(mockUpdateContact).toHaveBeenCalledWith('c2', { keywords: {} }, undefined);
+ expect(mockUpdateContact).toHaveBeenCalledTimes(2);
+ expect(useContactsStore.getState().selectedCategory).toEqual({ type: 'all' });
+
+ mockUpdateContact.mockClear();
+ await useContactsStore.getState().addKeyword('Gone', ids);
+ expect(mockUpdateContact).toHaveBeenCalledWith('c1', { keywords: { Keep: true, Gone: true } }, undefined);
+ expect(mockUpdateContact).toHaveBeenCalledWith('c2', { keywords: { Gone: true } }, undefined);
+ expect(useContactsStore.getState().contacts.find((c) => c.id === 'c3')?.keywords).toEqual({ Other: true });
+ });
+ });
+
describe('autocomplete', () => {
it('merges contacts, groups and recent recipients without duplicates', async () => {
useContactsStore.setState({
diff --git a/src/stores/__tests__/filter-store.test.ts b/src/stores/__tests__/filter-store.test.ts
index 89109e43..dbfd1610 100644
--- a/src/stores/__tests__/filter-store.test.ts
+++ b/src/stores/__tests__/filter-store.test.ts
@@ -346,6 +346,17 @@ describe('readVacationFilters', () => {
});
});
+ it('counts the most forwards the filter rules give one message, for the limit of the forwarding', async () => {
+ api.getSieveCapabilities.mockReturnValue({ ...WITH_INCLUDE, sieveExtensions: STALWART_EXTENSIONS });
+ api.getSieveScripts.mockResolvedValue([{ id: 's1', name: 'filters', blobId: 'b1', isActive: true }]);
+ const forward = { type: 'forward' as const, value: 'x@example.org' };
+ api.getSieveScriptContent.mockResolvedValue(generateScript([
+ makeRule({ id: 'a', actions: [forward] }),
+ makeRule({ id: 'b', actions: [forward] }),
+ ]));
+ expect((await readVacationFilters()).otherForwards).toBe(2);
+ });
+
it('sets up forwarding in the vacation period, creating the script when there is none', async () => {
api.getSieveCapabilities.mockReturnValue({ ...WITH_INCLUDE, sieveExtensions: STALWART_EXTENSIONS });
api.getSieveScripts.mockResolvedValue([{ id: 'v1', name: 'vacation', blobId: 'bv', isActive: true }]);
@@ -362,3 +373,46 @@ describe('readVacationFilters', () => {
expect(content).toContain(' redirect :copy "backup@example.org";');
});
});
+
+describe('deleteRuleNow', () => {
+ const a = makeRule({ id: 'a', name: 'A' });
+ const b = makeRule({ id: 'b', name: 'B' });
+
+ it('deletes and saves at once, and Undo puts the rule back where it was and saves again', async () => {
+ serveScript([a, b]);
+ await useFilterStore.getState().selectAccount(null);
+ api.updateSieveScript.mockResolvedValue(undefined);
+
+ const undo = await useFilterStore.getState().deleteRuleNow('a');
+ expect(useFilterStore.getState().rules.map((r) => r.id)).toEqual(['b']);
+ expect(api.updateSieveScript).toHaveBeenCalledTimes(1);
+ expect(api.updateSieveScript.mock.calls[0][1]).not.toContain('"A"');
+
+ expect(await undo!()).toBe(true);
+ expect(useFilterStore.getState().rules.map((r) => r.id)).toEqual(['a', 'b']);
+ expect(api.updateSieveScript).toHaveBeenCalledTimes(2);
+ expect(api.updateSieveScript.mock.calls[1][1]).toContain('A');
+ // Once undone there is nothing left to undo.
+ expect(await undo!()).toBe(false);
+ expect(api.updateSieveScript).toHaveBeenCalledTimes(2);
+ });
+
+ it('keeps the rule when the save fails', async () => {
+ serveScript([a, b]);
+ await useFilterStore.getState().selectAccount(null);
+ api.updateSieveScript.mockRejectedValueOnce(new Error('down'));
+ await expect(useFilterStore.getState().deleteRuleNow('a')).rejects.toThrow('down');
+ expect(useFilterStore.getState().rules.map((r) => r.id)).toEqual(['a', 'b']);
+ });
+
+ it("does not undo into another account's filters", async () => {
+ serveScript([a, b]);
+ await useFilterStore.getState().selectAccount(null);
+ api.updateSieveScript.mockResolvedValue(undefined);
+ const undo = await useFilterStore.getState().deleteRuleNow('a');
+ serveScript([b]);
+ await useFilterStore.getState().selectAccount('team');
+ expect(await undo!()).toBe(false);
+ expect(api.updateSieveScript).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/stores/__tests__/vacation-store.test.ts b/src/stores/__tests__/vacation-store.test.ts
index 42149389..00230cdb 100644
--- a/src/stores/__tests__/vacation-store.test.ts
+++ b/src/stores/__tests__/vacation-store.test.ts
@@ -18,7 +18,7 @@ vi.mock('../../api/sieve', () => ({ isSieveSupported: vi.fn(() => false) }));
const NO_FILTERS = {
includesVacation: false, forward: null, forwardAvailable: false,
- audience: null, audienceAvailable: false, notRunning: false,
+ audience: null, audienceAvailable: false, notRunning: false, otherForwards: 0,
};
vi.mock('../filter-store', () => ({
diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts
index cfedee56..46fca24a 100644
--- a/src/stores/calendar-store.ts
+++ b/src/stores/calendar-store.ts
@@ -263,7 +263,7 @@ export interface CalendarState {
changes: Partial | Record,
options?: { sendSchedulingMessages?: boolean },
) => Promise;
- deleteEvent: (id: string) => Promise;
+ deleteEvent: (id: string, options?: { sendSchedulingMessages?: boolean }) => Promise;
// Resolve an expanded occurrence (or a master) to its master event, read
// from the server: a copy the app holds may be older.
getMasterEvent: (event: CalendarEvent) => Promise;
@@ -1210,7 +1210,7 @@ export const useCalendarStore = create()(
}
},
- deleteEvent: async (id) => {
+ deleteEvent: async (id, options) => {
// Deleting an occurrence removes just that one: through its synthetic
// id, or by excluding it on the base event it was expanded from.
const scope = writeScope();
@@ -1218,7 +1218,8 @@ export const useCalendarStore = create()(
const { storeEvent, realId, accountId } = target;
const touchesSeries = (!!storeEvent && isRecurringSeriesMember(storeEvent))
|| hasServerOccurrencesOf(get().events, realId, accountId);
- const schedule = hasSchedulingParticipants(storeEvent) ? true : undefined;
+ const schedule = options?.sendSchedulingMessages
+ ?? (hasSchedulingParticipants(storeEvent) ? true : undefined);
if (target.isOccurrence && storeEvent) {
await destroyOccurrence(storeEvent, realId, schedule, writeAccount(scope, accountId), scope);
assertInScope(scope);
@@ -1539,7 +1540,46 @@ export const useCalendarStore = create()(
const scope = writeScope();
const task = get().tasks.find((t) => t.id === id);
const realId = task?.originalId || id;
- await apiUpdateEvent(realId, changes, undefined, writeAccount(scope, task?.accountId));
+ const sourceAccount = task?.accountId ?? undefined;
+ // Store calendar ids (namespaced for a shared account) back to the raw
+ // ids of the account that owns the calendar, which may be another one
+ // than the task's (webmail #1204).
+ const patch: Partial = { ...changes };
+ let destAccount = sourceAccount;
+ if (changes.calendarIds) {
+ const remapped: Record = {};
+ for (const [calId, on] of Object.entries(changes.calendarIds)) {
+ const cal = get().calendars.find((c) => c.id === calId);
+ remapped[cal?.originalId || calId] = on;
+ if (on && cal) destAccount = cal.accountId ?? undefined;
+ }
+ patch.calendarIds = remapped;
+ }
+ if (destAccount !== sourceAccount) {
+ // JMAP has no move across accounts: create the task in the calendar's
+ // account, then destroy the original. Creating first means a failed
+ // create leaves the task where it was.
+ const [original] = await fetchEvents([realId], writeAccount(scope, sourceAccount));
+ if (!original) throw new Error('Task not found');
+ assertInScope(scope);
+ const {
+ id: _id, created: _created, updated: _updated, sequence: _sequence, isOrigin: _isOrigin,
+ utcStart: _utcStart, utcEnd: _utcEnd, calendarIds: _calendarIds, ...rest
+ } = original as CalendarEvent & Record;
+ const destCalendarId = Object.keys(patch.calendarIds ?? {})[0];
+ const { calendarIds: _patchCalendars, ...fields } = patch;
+ await apiCreateEvent(
+ { ...rest, ...fields, '@type': 'Task' } as Partial,
+ destCalendarId,
+ undefined,
+ writeAccount(scope, destAccount),
+ );
+ await apiDeleteEvents([realId], undefined, writeAccount(scope, sourceAccount));
+ // Under a new id in another account: reload rather than patch.
+ if (inScope(scope)) await get().fetchTasks();
+ return;
+ }
+ await apiUpdateEvent(realId, patch, undefined, writeAccount(scope, sourceAccount));
// Saved; the next account's tasks can carry the same ids.
if (!inScope(scope)) return;
set({ tasks: get().tasks.map((t) => (t.id === id ? { ...t, ...changes } : t)) });
diff --git a/src/stores/contacts-store.ts b/src/stores/contacts-store.ts
index 23d086d7..5992fcbf 100644
--- a/src/stores/contacts-store.ts
+++ b/src/stores/contacts-store.ts
@@ -132,6 +132,13 @@ export interface ContactsState {
removeContactKeyword: (id: string, keyword: string) => Promise;
addKeywordToContacts: (ids: string[], keyword: string) => Promise;
renameKeyword: (oldKeyword: string, newKeyword: string) => Promise;
+ /**
+ * Delete a category: take it off every contact that has it. Resolves to
+ * the contacts it came off, for an Undo (webmail 1.19 removeKeyword).
+ */
+ removeKeyword: (keyword: string) => Promise;
+ /** Put a category back on these contacts (the Undo of removeKeyword). */
+ addKeyword: (keyword: string, contactIds: string[]) => Promise;
moveContactsToAddressBook: (ids: string[], addressBookId: string) => Promise;
// Groups
@@ -742,6 +749,31 @@ export const useContactsStore = create()(
}
},
+ removeKeyword: async (keyword) => {
+ const kw = keyword.trim();
+ if (!kw) return [];
+ const done: string[] = [];
+ for (const contact of get().contacts.filter((c) => c.keywords?.[kw])) {
+ const { [kw]: _gone, ...rest } = contact.keywords || {};
+ // Through the normal update, so each contact goes to its own account.
+ await get().updateContact(contact.id, { keywords: rest });
+ done.push(contact.id);
+ }
+ const category = get().selectedCategory;
+ if (category.type === 'keyword' && category.keyword === kw) get().setSelectedCategory({ type: 'all' });
+ return done;
+ },
+
+ addKeyword: async (keyword, contactIds) => {
+ const kw = keyword.trim();
+ if (!kw) return;
+ for (const id of contactIds) {
+ const contact = get().contacts.find((c) => c.id === id);
+ if (!contact || contact.keywords?.[kw]) continue;
+ await get().updateContact(id, { keywords: { ...(contact.keywords || {}), [kw]: true } });
+ }
+ },
+
moveContactsToAddressBook: async (ids, addressBookId) => {
const target = bookTarget(addressBookId);
const primaryAccountId = getContactsAccountId();
diff --git a/src/stores/filter-store.ts b/src/stores/filter-store.ts
index 09237068..d153a7bb 100644
--- a/src/stores/filter-store.ts
+++ b/src/stores/filter-store.ts
@@ -7,6 +7,7 @@ import { generateScript, supportsSpamGuard, VACATION_SCRIPT_NAME } from '../lib/
import { supportsPeriods } from '../lib/sieve/period';
import { isValidVacationForward, withVacationPeriod } from '../lib/sieve/vacation-forward';
import { isValidVacationAudience, normalizeVacationAudience } from '../lib/sieve/vacation-audience';
+import { worstCaseForwards } from '../lib/sieve/forward-limit';
import {
createSieveScript,
getSieveAccountId,
@@ -56,6 +57,14 @@ interface FilterStore {
addRule: (rule: FilterRule) => void;
updateRule: (ruleId: string, updates: Partial) => void;
deleteRule: (ruleId: string) => void;
+ /**
+ * Delete a rule and save at once, as the webmail does with an Undo (R3).
+ * Resolves to the undo, or null when there is no such rule; throws, with
+ * the rules put back, when the save fails. The undo puts the rules back as
+ * they were and saves again; it resolves false when there is nothing to
+ * undo any more (the rule is back, or another account's filters show).
+ */
+ deleteRuleNow: (ruleId: string) => Promise<(() => Promise) | null>;
reorderRules: (ruleIds: string[]) => void;
toggleRule: (ruleId: string) => void;
setRawScript: (content: string) => void;
@@ -237,6 +246,31 @@ export const useFilterStore = create()((set, get) => ({
}));
},
+ deleteRuleNow: async (ruleId) => {
+ const before = get().rules;
+ const account = get().selectedAccountId;
+ if (!before.some((r) => r.id === ruleId && r.origin !== 'external' && r.origin !== 'opaque')) return null;
+ get().deleteRule(ruleId);
+ try {
+ await get().saveFilters();
+ } catch (error) {
+ set({ rules: before });
+ throw error;
+ }
+ return async () => {
+ if (get().selectedAccountId !== account || get().rules.some((r) => r.id === ruleId)) return false;
+ const current = get().rules;
+ set({ rules: before });
+ try {
+ await get().saveFilters();
+ } catch (error) {
+ set({ rules: current });
+ throw error;
+ }
+ return true;
+ };
+ },
+
reorderRules: (ruleIds) => {
// Only reorder bulwark rules; external rules always stay at the end in
// their original order.
@@ -344,6 +378,12 @@ export interface VacationFilters {
* client), or no script runs at all. Saving the card sets it right.
*/
notRunning: boolean;
+ /**
+ * The most forwards the filter rules let one message collect (see
+ * worstCaseForwards). Forwarding that keeps a copy runs ahead of them, so
+ * it shares the server's redirect limit with these.
+ */
+ otherForwards: number;
}
/** See VacationFilters.notRunning. */
@@ -381,6 +421,7 @@ export async function readVacationFilters(accountId?: string): Promise