mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 08:07:11 +00:00
Merge branch 'cu-calendar' into catch-up-2026-11
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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/<view>/<date>?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`.
|
||||
|
||||
@@ -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/<principal>` 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/<id>[/edit]`, `/contacts/new`)** — `P3` — `missing`
|
||||
- [ ] **Contact deep links (`/contacts/<id>[/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/<id>` opens the contact since 5802cae; `/contacts/new` and `/contacts/<id>/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/<id>` to `ContactDetail { contactId }` and every other contacts path to the tab; it never opens `ContactForm`.
|
||||
|
||||
@@ -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/<folder>` 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/<folder>` 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string, unknown>[] = [];
|
||||
const destroys: string[][] = [];
|
||||
mockRequest.mockImplementation(async (calls: [string, Record<string, any>, 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<string, Record<string, unknown>>)[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');
|
||||
|
||||
@@ -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<T>(fn: () => Promise<T>): Promise<T> {
|
||||
(await singleton()).restoreSnapshot(other!.snapshot());
|
||||
useCalendarStore.getState().reset();
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
await connectSingleton();
|
||||
}
|
||||
}
|
||||
|
||||
function removeAsOther(cancel: Partial<CalendarEvent>) {
|
||||
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<string, any>[];
|
||||
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)', () => {
|
||||
|
||||
@@ -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<ContactCard>, bookId);
|
||||
const two = await createContact({
|
||||
'@type': 'Card', version: '1.0', kind: 'individual', name: { full: `Live Cat Two ${RUN}` },
|
||||
keywords: { [tag]: true },
|
||||
} as Partial<ContactCard>, 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<ContactCard>, book.id);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ?? '' };
|
||||
|
||||
+26
-6
@@ -1250,23 +1250,43 @@ export async function createCalendar(
|
||||
const accountId = targetAccountId || jmapClient.accountId;
|
||||
const props: Record<string, unknown> = { 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<string, Calendar>;
|
||||
notCreated?: Record<string, { description?: string; type?: string }>;
|
||||
}>(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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<FileNode> {
|
||||
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<string, FileNode[]>();
|
||||
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<string>([node.id]);
|
||||
const create = async (source: FileNode, name: string, parent: string | null, level: number): Promise<FileNode> => {
|
||||
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<void> => {
|
||||
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
|
||||
|
||||
@@ -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<ParseResult> {
|
||||
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
|
||||
|
||||
@@ -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<string>(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<string | null>(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 (
|
||||
<Pressable
|
||||
key={col}
|
||||
onPress={() => 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
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<View style={styles.hexRow}>
|
||||
<View style={[styles.hexPreview, { backgroundColor: hexInvalid ? 'transparent' : color }]} />
|
||||
<TextInput
|
||||
value={hexInput}
|
||||
onChangeText={(v) => {
|
||||
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]}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={() => setMoreColors((v) => !v)}
|
||||
style={styles.moreColors}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ expanded: moreColors }}
|
||||
>
|
||||
<Text style={styles.moreColorsText}>
|
||||
{moreColors
|
||||
? t('calendar.management.fewer_colors', 'Fewer colors')
|
||||
: t('calendar.management.more_colors', 'More colors')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{hexInvalid && (
|
||||
<Text style={styles.error}>
|
||||
{t('calendar.management.color_invalid', 'Enter a color as # and six hex digits, such as #3b82f6.')}
|
||||
</Text>
|
||||
)}
|
||||
{moreColors && (
|
||||
<View style={styles.grid}>
|
||||
{CUSTOM_COLOR_GRID.map((row, r) => (
|
||||
<View key={r} style={styles.gridRow}>
|
||||
{row.map((col) => {
|
||||
const active = color.toLowerCase() === col;
|
||||
return (
|
||||
<Pressable
|
||||
key={col}
|
||||
onPress={() => pickColor(col)}
|
||||
style={[styles.gridSwatch, { backgroundColor: col }, active && styles.swatchActive]}
|
||||
accessibilityRole="radio"
|
||||
accessibilityLabel={col}
|
||||
accessibilityState={{ selected: active }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
<Text style={styles.label}>{t('calendar.management.description', 'Description')}</Text>
|
||||
<TextInput
|
||||
value={description}
|
||||
@@ -282,7 +353,7 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
|
||||
<Button
|
||||
size="sm"
|
||||
onPress={() => { void handleSave(); }}
|
||||
disabled={!name.trim() || saving}
|
||||
disabled={!name.trim() || saving || hexInvalid}
|
||||
icon={saving ? <ActivityIndicator size="small" color={c.primaryForeground} /> : 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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -54,6 +54,8 @@ interface TimedEventBlockProps {
|
||||
/** Position and size in the day column. */
|
||||
style: StyleProp<ViewStyle>;
|
||||
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 (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel ?? title}
|
||||
style={[
|
||||
styles.block,
|
||||
corners('vertical', continuesBefore, continuesAfter, EVENT_RADIUS),
|
||||
@@ -114,6 +119,8 @@ interface AllDayEventBarProps {
|
||||
/** Position and size in the all-day strip. */
|
||||
style: StyleProp<ViewStyle>;
|
||||
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 (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel ?? title}
|
||||
onPress={onPress}
|
||||
style={[
|
||||
styles.bar,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
@@ -82,6 +83,8 @@ interface EventDetailSheetProps {
|
||||
// Copy the meeting link; only offered when the event has one.
|
||||
onCopyLink?: (event: CalendarEvent, link: string) => void;
|
||||
onRsvp?: (event: CalendarEvent, participantId: string, status: RsvpStatus) => void | Promise<void>;
|
||||
/** Append a quick note to the description (webmail's "Add note"); offered where the event can be edited. */
|
||||
onSaveNote?: (event: CalendarEvent, note: string) => Promise<void>;
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<View style={styles.noteBlock}>
|
||||
<TextInput
|
||||
value={noteText}
|
||||
onChangeText={setNoteText}
|
||||
placeholder={t('calendar.detail.add_note', 'Add note')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
accessibilityLabel={t('calendar.detail.add_note', 'Add note')}
|
||||
style={styles.noteInput}
|
||||
multiline
|
||||
autoFocus
|
||||
/>
|
||||
<View style={styles.noteButtons}>
|
||||
<Pressable onPress={() => { setNoteText(''); setNoteOpen(false); }} style={styles.noteButton} accessibilityRole="button">
|
||||
<Text style={styles.noteCancel}>{t('calendar.form.cancel', 'Cancel')}</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => { void saveNote(); }}
|
||||
disabled={!noteText.trim() || noteBusy}
|
||||
style={[styles.noteButton, (!noteText.trim() || noteBusy) && { opacity: 0.5 }]}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Text style={styles.noteSave}>{t('calendar.detail.save_note', 'Save note')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<Pressable onPress={() => setNoteOpen(true)} style={styles.noteToggle} accessibilityRole="button">
|
||||
<AlignLeft size={16} color={c.textMuted} />
|
||||
<Text style={styles.noteToggleText}>{t('calendar.detail.add_note', 'Add note')}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
{recurrence && (
|
||||
<DetailRow
|
||||
icon={<Repeat size={16} color={c.textMuted} />}
|
||||
@@ -419,7 +484,7 @@ export function EventDetailSheet({
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{(canEdit || onDuplicate || onExport || (onCopyLink && videoUri)) && (
|
||||
{(
|
||||
<View style={styles.actions}>
|
||||
{onEdit && canEdit && (
|
||||
<ActionButton
|
||||
@@ -442,6 +507,11 @@ export function EventDetailSheet({
|
||||
onPress={() => onExport(event)}
|
||||
/>
|
||||
)}
|
||||
<ActionButton
|
||||
icon={<Copy size={18} color={c.text} />}
|
||||
label={t('calendar.events.copy_title_short', 'Title')}
|
||||
onPress={copyTitle}
|
||||
/>
|
||||
{onCopyLink && videoUri && (
|
||||
<ActionButton
|
||||
icon={<Link2 size={18} color={c.text} />}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<string | null>(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}
|
||||
>
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']} onLayout={onContainerLayout}>
|
||||
<View style={styles.header}>
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
onPress={requestClose}
|
||||
hitSlop={8}
|
||||
style={styles.headerBtn}
|
||||
accessibilityRole="button"
|
||||
|
||||
@@ -28,6 +28,7 @@ import { dayIndexIn, monthChipShowsTime, monthKeyOf, monthMask } from '../../lib
|
||||
import { isInactiveEvent } from '../../lib/calendar-participants';
|
||||
import { eventBlockColors } from '../../lib/event-colors';
|
||||
import { useCalendarLocale } from '../../lib/calendar-locale';
|
||||
import { dayCellAccessibilityLabel } from '../../lib/calendar-a11y';
|
||||
import { displayNow } from '../../lib/calendar-timezone';
|
||||
|
||||
type WeekStart = 0 | 1 | 6;
|
||||
@@ -153,6 +154,7 @@ function MonthWeekRowInner({
|
||||
onLongPressDate,
|
||||
}: MonthWeekRowProps) {
|
||||
const c = useColors();
|
||||
const { t } = useCalendarLocale();
|
||||
const { width } = useWindowDimensions();
|
||||
const chipTime = monthChipShowsTime(true, width / 7);
|
||||
if (variant === 'full') {
|
||||
@@ -201,6 +203,9 @@ function MonthWeekRowInner({
|
||||
style={[styles.dayCell, showTimeInMonthView && styles.dayCellTall]}
|
||||
onPress={() => onSelectDate(d)}
|
||||
onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={dayCellAccessibilityLabel(d, dayEvents.length, t, locale)}
|
||||
accessibilityState={{ selected }}
|
||||
>
|
||||
<View style={[
|
||||
styles.dayNumber,
|
||||
@@ -321,6 +326,7 @@ function FullWeekRow({
|
||||
colors: ThemePalette;
|
||||
}) {
|
||||
const { width } = useWindowDimensions();
|
||||
const { t } = useCalendarLocale();
|
||||
const showTime = monthChipShowsTime(showTimeInMonthView, width / 7);
|
||||
return (
|
||||
<View style={[styles.fullWeekRow, { height }]}>
|
||||
@@ -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)}
|
||||
>
|
||||
<View style={styles.fullNumberWrap}>
|
||||
<View style={[styles.fullNumber, today && styles.fullNumberToday]}>
|
||||
|
||||
@@ -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) => (
|
||||
<Pressable
|
||||
key={s.email}
|
||||
key={s.group ? `group:${s.group.id}` : s.email}
|
||||
style={({ pressed }) => [
|
||||
styles.suggestionRow,
|
||||
pressed && styles.suggestionRowPressed,
|
||||
]}
|
||||
onPress={() => addParticipant(s.email, s.name)}
|
||||
onPress={() => pickSuggestion(s)}
|
||||
>
|
||||
{s.name && <Text style={styles.suggestionName}>{s.name}</Text>}
|
||||
<Text style={styles.suggestionEmail}>{s.email}</Text>
|
||||
{!!s.name && <Text style={styles.suggestionName}>{s.name}</Text>}
|
||||
<Text style={styles.suggestionEmail}>
|
||||
{s.group
|
||||
? t('calendar.participants.group_members', '{count, plural, one {# member} other {# members}}', { count: s.group.memberCount })
|
||||
: s.email}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
@@ -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<TaskFilter>('all');
|
||||
const [editor, setEditor] = React.useState<TaskEditorState>(() => emptyTaskEditor(defaultCalendarId));
|
||||
// The task as the editor was opened on it, to tell typed changes.
|
||||
const [baseline, setBaseline] = React.useState<TaskEditorState>(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 (
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={requestClose}>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={styles.overlayPress} onPress={onClose} />
|
||||
<Pressable style={styles.overlayPress} onPress={requestClose} />
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }], ...sheetInsets }]}>
|
||||
@@ -250,7 +269,7 @@ export function TasksSheet({
|
||||
<ListChecks size={20} color={c.text} />
|
||||
<Text style={styles.headerTitle}>{t('calendar.tasks.label', 'Tasks')}</Text>
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
onPress={requestClose}
|
||||
style={styles.closeBtn}
|
||||
hitSlop={10}
|
||||
accessibilityRole="button"
|
||||
@@ -359,7 +378,7 @@ export function TasksSheet({
|
||||
</ScrollView>
|
||||
)}
|
||||
{editor.id && (
|
||||
<Pressable onPress={resetEditor} style={styles.cancelEdit}>
|
||||
<Pressable onPress={() => askBeforeDiscard(dirty, t, resetEditor)} style={styles.cancelEdit}>
|
||||
<Text style={styles.cancelEditText}>{t('calendar.form.cancel', 'Cancel')}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
@@ -410,9 +429,11 @@ export function TasksSheet({
|
||||
<Pressable
|
||||
style={styles.taskText}
|
||||
onPress={() => {
|
||||
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);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<View style={styles.taskTitleRow}>
|
||||
|
||||
@@ -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}%` }}
|
||||
/>
|
||||
|
||||
@@ -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}%` }}
|
||||
/>
|
||||
|
||||
@@ -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('<TextInput'), source.indexOf('/>', source.indexOf('<TextInput')));
|
||||
expect(input).toMatch(/autoComplete="off"/);
|
||||
expect(input).toMatch(/importantForAutofill="no"/);
|
||||
expect(input).toMatch(/textContentType="none"/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TextInput, Pressable, ScrollView, StyleSheet } from 'react-native';
|
||||
import { Mail, Phone, Image as ImageIcon, X } from '../icons';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import {
|
||||
countActiveFilters,
|
||||
cycleTri,
|
||||
EMPTY_CONTACT_FILTERS,
|
||||
type ContactListFilters,
|
||||
type TriState,
|
||||
} from '../../lib/contact-list-filters';
|
||||
|
||||
interface Props {
|
||||
filters: ContactListFilters;
|
||||
onChange: (filters: ContactListFilters) => 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 = <K extends keyof ContactListFilters>(key: K, value: ContactListFilters[K]) =>
|
||||
onChange({ ...filters, [key]: value });
|
||||
|
||||
const field = (key: 'organization' | 'jobTitle' | 'location' | 'emailDomain', label: string, placeholder: string) => (
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
<TextInput
|
||||
value={filters[key]}
|
||||
onChangeText={(v) => set(key, v)}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={c.textMuted}
|
||||
accessibilityLabel={label}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
style={styles.input}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
const toggle = (key: 'hasEmail' | 'hasPhone' | 'hasPhoto', label: string, icon: React.ReactNode) => {
|
||||
const value: TriState = filters[key];
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => 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}
|
||||
<Text style={[styles.chipText, value === true && styles.chipTextOn, value === false && styles.chipTextNot]}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.panel}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{t('contacts.filters.title', 'Advanced filters')}</Text>
|
||||
{countActiveFilters(filters) > 0 && (
|
||||
<Pressable onPress={() => onChange(EMPTY_CONTACT_FILTERS)} hitSlop={6} accessibilityRole="button">
|
||||
<Text style={styles.clear}>{t('contacts.filters.clear', 'Clear')}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable onPress={onClose} hitSlop={8} accessibilityRole="button" accessibilityLabel={t('contacts.filters.close', 'Close')}>
|
||||
<X size={16} color={c.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
{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'))}
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
{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'))}
|
||||
</View>
|
||||
<Text style={styles.label}>{t('contacts.filters.birthday_month', 'Birthday in')}</Text>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.months}>
|
||||
<Pressable
|
||||
onPress={() => set('birthdayMonth', null)}
|
||||
style={[styles.chip, filters.birthdayMonth === null && styles.chipOn]}
|
||||
accessibilityRole="radio"
|
||||
accessibilityState={{ selected: filters.birthdayMonth === null }}
|
||||
>
|
||||
<Text style={[styles.chipText, filters.birthdayMonth === null && styles.chipTextOn]}>{t('contacts.filters.any_month', 'Any month')}</Text>
|
||||
</Pressable>
|
||||
{monthNames.map((name, i) => {
|
||||
const active = filters.birthdayMonth === i + 1;
|
||||
return (
|
||||
<Pressable
|
||||
key={name}
|
||||
onPress={() => set('birthdayMonth', i + 1)}
|
||||
style={[styles.chip, active && styles.chipOn]}
|
||||
accessibilityRole="radio"
|
||||
accessibilityState={{ selected: active }}
|
||||
>
|
||||
<Text style={[styles.chipText, active && styles.chipTextOn]}>{name}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
<View style={styles.toggles}>
|
||||
{toggle('hasEmail', t('contacts.filters.has_email', 'Has email'), <Mail size={14} color={c.textMuted} />)}
|
||||
{toggle('hasPhone', t('contacts.filters.has_phone', 'Has phone'), <Phone size={14} color={c.textMuted} />)}
|
||||
{toggle('hasPhoto', t('contacts.filters.has_photo', 'Has photo'), <ImageIcon size={14} color={c.textMuted} />)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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' },
|
||||
});
|
||||
}
|
||||
@@ -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 }) => (
|
||||
<CategoryRow
|
||||
key={book.id}
|
||||
@@ -271,6 +299,15 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
|
||||
>
|
||||
<Check size={16} color={c.primary} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => { void deleteCategory(kw.keyword); }}
|
||||
hitSlop={6}
|
||||
style={styles.renameBtn}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('contacts.delete_category', 'Delete category')}
|
||||
>
|
||||
<Trash2 size={16} color={c.error} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => setRenaming(null)}
|
||||
hitSlop={6}
|
||||
|
||||
@@ -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<string | null>(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<CancellationRemoval | null>(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' && (
|
||||
<Text style={styles.cancelled}>{t('calendar.invitation.cancelled', 'This event was cancelled')}</Text>
|
||||
)}
|
||||
{cancellationOutdated && (
|
||||
<Text style={styles.subtitle}>
|
||||
{t('calendar.invitation.cancel_outdated', 'Your calendar has a newer version of this event than this cancellation.')}
|
||||
</Text>
|
||||
)}
|
||||
{method === 'reply' && (
|
||||
<Text style={styles.subtitle}>{t('calendar.invitation.reply', 'A participant replied to this invitation')}</Text>
|
||||
)}
|
||||
@@ -360,7 +427,21 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
|
||||
icon={<X size={15} color={currentStatus === 'declined' ? c.textInverse : c.error} />}
|
||||
disabled={busy} onPress={() => ensureImportedAndRsvp('declined')} c={c} styles={styles} />
|
||||
</>
|
||||
) : !existing && method !== 'reply' ? (
|
||||
) : canRemoveCancelled ? (
|
||||
<Pressable
|
||||
style={[styles.removeBtn, busy && { opacity: 0.5 }]}
|
||||
onPress={() => { void handleRemoveCancelled(); }}
|
||||
disabled={busy}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
{busy ? (
|
||||
<ActivityIndicator size="small" color={c.error} />
|
||||
) : (
|
||||
<Trash2 size={16} color={c.error} />
|
||||
)}
|
||||
<Text style={styles.removeBtnText}>{t('calendar.invitation.remove_from_calendar', 'Remove from calendar')}</Text>
|
||||
</Pressable>
|
||||
) : !existing && method !== 'reply' && method !== 'cancel' ? (
|
||||
<Pressable
|
||||
style={[styles.importBtn, (busy || !targetCalendar) && { opacity: 0.5 }]}
|
||||
onPress={() => { 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 },
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
<Select
|
||||
value={action.type}
|
||||
onChange={(v) => updateAction(index, { type: v as FilterActionType })}
|
||||
options={actionTypeOptions}
|
||||
options={actionOptionsFor(action)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Pressable
|
||||
@@ -489,6 +517,16 @@ export function FilterRuleModal({
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{forwardLimit.unavailable && ruleForwardCount > 0 && (
|
||||
<Text style={styles.forwardWarning}>
|
||||
{t('settings.filters.forward_unavailable', 'This server does not allow forwarding. Forward actions in filter rules have no effect.')}
|
||||
</Text>
|
||||
)}
|
||||
{forwardLimit.overLimit && (
|
||||
<Text style={styles.forwardWarning}>
|
||||
{t('settings.filters.forward_limit', 'Forward limit per message on this server: {count}. Extra forwards are skipped.', { count: forwardLimit.limit ?? 0 })}
|
||||
</Text>
|
||||
)}
|
||||
<Pressable
|
||||
onPress={() => 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',
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 && <Text style={styles.fieldError}>{subjectTooLong}</Text>}
|
||||
</View>
|
||||
<View style={styles.bodyBlock}>
|
||||
<Text style={styles.bodyLabel}>{t('settings.vacation.message.body_label', 'Message Body')}</Text>
|
||||
@@ -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 && <Text style={styles.fieldError}>{bodyTooLong}</Text>}
|
||||
</View>
|
||||
<SettingItem
|
||||
label={t('settings.vacation.message.html_label', 'Formatted message (HTML)')}
|
||||
@@ -466,6 +493,11 @@ export function VacationSettings() {
|
||||
accessibilityLabel={t('settings.vacation.forward.keep_label', 'Keep in inbox')}
|
||||
/>
|
||||
</SettingItem>
|
||||
{forwardOverLimit && (
|
||||
<Text style={styles.forwardWarning}>
|
||||
{t('settings.filters.forward_limit', 'Forward limit per message on this server: {count}. Extra forwards are skipped.', { count: forwardLimit })}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SettingsSection>
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, string | number>) =>
|
||||
formatMessage(fallback ?? '', params, 'en');
|
||||
|
||||
useSettingsStore.setState({ calendarTimeZone: 'auto' } as never);
|
||||
|
||||
const event = (extra: Partial<CalendarEvent>): 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$/);
|
||||
});
|
||||
});
|
||||
@@ -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>): 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<CalendarEvent> },
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<string, { name: string; email: string }[]>;
|
||||
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' }]);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<typeof EMPTY_CONTACT_FILTERS>) => [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]);
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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: `<p>${'x'.repeat(2045)}</p>` }, 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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, string | number>) => 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<Calendar, 'id' | 'name'>[];
|
||||
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(', ');
|
||||
}
|
||||
@@ -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<CalendarEvent>, b: Partial<CalendarEvent>): 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<CalendarEvent>, b: Partial<CalendarEvent>): 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<string, unknown> }
|
||||
| { 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<CalendarEvent>,
|
||||
): 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<CalendarEvent> & { 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<CalendarEvent[]>;
|
||||
deleteEvent: (id: string, options: { sendSchedulingMessages: boolean }) => Promise<void>;
|
||||
updateEvent: (id: string, patch: Record<string, unknown>, options: { sendSchedulingMessages: boolean }) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<CalendarEvent>,
|
||||
actions: CancellationActions,
|
||||
): Promise<CancellationRemoval> {
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, unknown>): 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 },
|
||||
);
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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<string>,
|
||||
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<string>,
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Alert } from 'react-native';
|
||||
import { JMAPMethodError } from '../api/jmap-result';
|
||||
|
||||
type Translate = (key: string, fallback?: string, params?: Record<string, string | number>) => 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<boolean> {
|
||||
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> | void,
|
||||
send: boolean,
|
||||
t: Translate,
|
||||
ask: (reason: string, t: Translate) => Promise<boolean> = askSaveWithoutInvitations,
|
||||
): Promise<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -617,6 +617,38 @@ export function pickUnusedCalendarColor(usedColors: Iterable<string>): 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<Calendar, 'id' | 'color' | 'isShared' | 'accountId' | 'originalId'>[],
|
||||
overrides: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
// Every colour already on screen: personal and server colours plus the
|
||||
// existing shared overrides.
|
||||
const used = new Set<string>();
|
||||
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<string, string> = {};
|
||||
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<string, string>,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<Date, string>();
|
||||
|
||||
/** 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<CalendarEvent> & { excluded?: boolean })
|
||||
@@ -153,7 +208,7 @@ function expandEvent(
|
||||
for (const [recurrenceId, rawOverride] of Object.entries(overrides)) {
|
||||
const override = rawOverride as Partial<CalendarEvent> & { 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':
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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> = {}): 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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<number>): { forwards: number; ran: Set<number> } {
|
||||
let forwards = 0;
|
||||
const ran = new Set<number>();
|
||||
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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<FilterRule, 'enabled' | 'actions' | 'stopProcessing'>;
|
||||
|
||||
/** The forwards of a rule that run: those ahead of a Stop action. */
|
||||
export function ruleForwards(rule: Pick<FilterRule, 'actions'>): 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<FilterRule, 'actions' | 'stopProcessing'>): 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<FilterRule, 'id' | 'origin'>;
|
||||
|
||||
/**
|
||||
* 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<FilterRule, 'actions' | 'stopProcessing'>,
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<CalendarEvent, 'id' | 'title' | 'description' | 'due' | 'timeZone' | 'showWithoutTime' | 'priority' | 'calendarIds'>,
|
||||
fallbackCalendarId: string,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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<string>();
|
||||
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).
|
||||
|
||||
@@ -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<ContactListFilters>(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<Section[]>(() => {
|
||||
if (groupByLetter) return groupContacts(visible, sortByLastName);
|
||||
@@ -538,8 +547,20 @@ export default function ContactsScreen({ onOpenContact, activeContactId = null }
|
||||
<X size={16} color={c.textMuted} />
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable
|
||||
onPress={() => setFiltersOpen((v) => !v)}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('contacts.filters.toggle', 'Filters')}
|
||||
accessibilityState={{ expanded: filtersOpen }}
|
||||
>
|
||||
<Filter size={16} color={activeFilterCount > 0 ? c.primary : c.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
{searchActive && filtersOpen && (
|
||||
<ContactFiltersPanel filters={filters} onChange={setFilters} onClose={() => setFiltersOpen(false)} />
|
||||
)}
|
||||
|
||||
{!selectionMode && (
|
||||
<ScrollView
|
||||
|
||||
@@ -47,7 +47,7 @@ function loadDocumentPicker(): DocumentPickerModule | null {
|
||||
}
|
||||
|
||||
import {
|
||||
copyFileNode, createFolder, deleteFileNodes, getAllFileNodesAcrossAccounts,
|
||||
copyFileTree, createFolder, deleteFileNodes, getAllFileNodesAcrossAccounts,
|
||||
getFileNameRules, getFileNodeDownloadUrl, getMaxSizeUpload, isFolder,
|
||||
isSymlink, listFolder, moveFileNode, renameFileNode, supportsSharing, uploadFileBatch,
|
||||
} from '../api/files';
|
||||
@@ -661,13 +661,14 @@ export default function FilesScreen() {
|
||||
}, [renameTarget, renameValue, loadFiles, checkName, t]);
|
||||
|
||||
const duplicateFile = useCallback(async (row: FileRow) => {
|
||||
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({
|
||||
<Text style={styles.actionLabel}>{t('files.move_to', 'Move to…')}</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{access.duplicate && isFile ? (
|
||||
{access.duplicate && !isSymlink(target) ? (
|
||||
<Pressable style={styles.action} onPress={() => onDuplicate(target)}>
|
||||
<Copy size={18} color={c.text} />
|
||||
<Text style={styles.actionLabel}>{t('files.duplicate', 'Duplicate')}</Text>
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -263,7 +263,7 @@ export interface CalendarState {
|
||||
changes: Partial<CalendarEvent> | Record<string, unknown>,
|
||||
options?: { sendSchedulingMessages?: boolean },
|
||||
) => Promise<void>;
|
||||
deleteEvent: (id: string) => Promise<void>;
|
||||
deleteEvent: (id: string, options?: { sendSchedulingMessages?: boolean }) => Promise<void>;
|
||||
// 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<CalendarEvent | null>;
|
||||
@@ -1210,7 +1210,7 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
}
|
||||
},
|
||||
|
||||
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<CalendarState>()(
|
||||
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<CalendarState>()(
|
||||
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<CalendarEvent> = { ...changes };
|
||||
let destAccount = sourceAccount;
|
||||
if (changes.calendarIds) {
|
||||
const remapped: Record<string, boolean> = {};
|
||||
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<string, unknown>;
|
||||
const destCalendarId = Object.keys(patch.calendarIds ?? {})[0];
|
||||
const { calendarIds: _patchCalendars, ...fields } = patch;
|
||||
await apiCreateEvent(
|
||||
{ ...rest, ...fields, '@type': 'Task' } as Partial<CalendarEvent>,
|
||||
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)) });
|
||||
|
||||
@@ -132,6 +132,13 @@ export interface ContactsState {
|
||||
removeContactKeyword: (id: string, keyword: string) => Promise<void>;
|
||||
addKeywordToContacts: (ids: string[], keyword: string) => Promise<void>;
|
||||
renameKeyword: (oldKeyword: string, newKeyword: string) => Promise<void>;
|
||||
/**
|
||||
* 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<string[]>;
|
||||
/** Put a category back on these contacts (the Undo of removeKeyword). */
|
||||
addKeyword: (keyword: string, contactIds: string[]) => Promise<void>;
|
||||
moveContactsToAddressBook: (ids: string[], addressBookId: string) => Promise<void>;
|
||||
|
||||
// Groups
|
||||
@@ -742,6 +749,31 @@ export const useContactsStore = create<ContactsState>()(
|
||||
}
|
||||
},
|
||||
|
||||
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();
|
||||
|
||||
@@ -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<FilterRule>) => 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<boolean>) | null>;
|
||||
reorderRules: (ruleIds: string[]) => void;
|
||||
toggleRule: (ruleId: string) => void;
|
||||
setRawScript: (content: string) => void;
|
||||
@@ -237,6 +246,31 @@ export const useFilterStore = create<FilterStore>()((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<VacationF
|
||||
// Told apart by the envelope sender, which the auto-reply goes to.
|
||||
audienceAvailable: filtersUsable && !!capabilities?.sieveExtensions?.includes('envelope'),
|
||||
notRunning: storedButIdle(!!target?.isActive, !!vacationScript?.isActive, forward, audience),
|
||||
otherForwards: worstCaseForwards(parsed?.rules ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -309,6 +309,10 @@ interface PersistedSettings {
|
||||
// Hard cap on the on-disk body cache, in megabytes. When a sync pushes the
|
||||
// cache past this, the oldest messages are evicted to fit.
|
||||
offlineCacheMaxMB: number;
|
||||
|
||||
// calendar: the colour of the virtual Birthdays calendar (webmail
|
||||
// birthdayCalendarColor).
|
||||
birthdayCalendarColor: string;
|
||||
}
|
||||
|
||||
const DEFAULT_PERSISTED: PersistedSettings = {
|
||||
@@ -463,6 +467,9 @@ const DEFAULT_PERSISTED: PersistedSettings = {
|
||||
offlineCacheEnabled: false,
|
||||
offlineCacheDays: 7,
|
||||
offlineCacheMaxMB: 50,
|
||||
|
||||
// calendar:
|
||||
birthdayCalendarColor: '#eab308',
|
||||
};
|
||||
|
||||
export interface SettingsState extends PersistedSettings {
|
||||
|
||||
@@ -52,6 +52,8 @@ export interface VacationState {
|
||||
audienceAvailable: boolean;
|
||||
/** Stored forwarding or recipients do not run until the card is saved (see readVacationFilters). */
|
||||
notRunning: boolean;
|
||||
/** The most forwards the filter rules let one message collect (see readVacationFilters). */
|
||||
otherForwards: number;
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
error: string | null;
|
||||
@@ -80,6 +82,7 @@ const NO_FILTERS = {
|
||||
audience: null,
|
||||
audienceAvailable: false,
|
||||
notRunning: false,
|
||||
otherForwards: 0,
|
||||
};
|
||||
|
||||
const INITIAL = {
|
||||
@@ -106,6 +109,7 @@ function fromFilters(filters: VacationFilters) {
|
||||
audience: filters.audience,
|
||||
audienceAvailable: filters.audienceAvailable,
|
||||
notRunning: filters.notRunning,
|
||||
otherForwards: filters.otherForwards,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user