mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
Merge branch 'cu-calendar' into catch-up-2026-11
This commit is contained in:
@@ -50,7 +50,7 @@ both versions want.
|
|||||||
|
|
||||||
Findings:
|
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 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.
|
- 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.
|
- 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: 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
|
- [ ] **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
|
- [ ] **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
|
- [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
|
||||||
- [ ] **No forward limit (#1155, #1208), no vacation length warning** — `P3` — `missing` — FilterRuleModal.tsx:443-459, VacationSettings.tsx:140-142
|
- [x] **No forward limit (#1155, #1208), no vacation length warning** — `P3` — `missing` — FilterRuleModal.tsx:443-459, VacationSettings.tsx:140-142 — fixed in dcbfc907
|
||||||
- [ ] **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] **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
|
- [ ] **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)
|
- [ ] **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)
|
- [ ] **`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.
|
- 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
|
- [ ] **#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
|
- [ ] **#85 keep the shade open on Delete** — question 1
|
||||||
- [ ] **#58 PGP / S/MIME** — `P3` — L, not in this run
|
- [ ] **#58 PGP / S/MIME** — `P3` — L, not in this run
|
||||||
- [ ] **#3 TLS client certificates** — Android done in 0.1.83; iOS has none (L)
|
- [ ] **#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.
|
- 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`.
|
- 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 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.
|
- 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`.
|
- 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
|
### 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 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`).
|
- 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()`.
|
- 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`).
|
- 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.
|
- 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 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.
|
- 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.
|
- 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
|
### 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 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`).
|
- 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`.
|
- 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.
|
- 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.
|
- 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).
|
- 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 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).
|
- 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.
|
- 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.
|
- 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.
|
- 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 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`).
|
- 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
|
### 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.
|
- 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 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`).
|
- 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).
|
- 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`.
|
- 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.
|
- 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 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`.
|
- 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 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`.
|
- 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 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`.
|
- 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.
|
- 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
|
- [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`.
|
- 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`.
|
- 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`).
|
- 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`.
|
- 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`.
|
- What WEB does: `lib/jmap/client.ts:1198`.
|
||||||
|
|
||||||
### Contacts
|
### 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
|
- [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`).
|
- 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`.
|
- 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
|
### 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
|
- [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.
|
- 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`.
|
- 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`
|
- [ ] **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
|
- [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`.
|
- 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`.
|
- 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
|
### 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.
|
- 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.
|
- 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 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`).
|
- 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.
|
- 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
|
### 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 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`).
|
- 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.
|
- 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 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`).
|
- 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`).
|
- 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 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`).
|
- What RN does: colours every shared calendar without an override (`src/screens/CalendarScreen.tsx:315-333`).
|
||||||
- Fix hint: skip calendars with `cal.color`.
|
- Fix hint: skip calendars with `cal.color`.
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
"not_starred": "Not starred"
|
"not_starred": "Not starred"
|
||||||
},
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
|
"a11y": {
|
||||||
|
"event_count": "{count, plural, one {# event} other {# events}}"
|
||||||
|
},
|
||||||
"account_changed": "The account changed while the calendar was loading or saving.",
|
"account_changed": "The account changed while the calendar was loading or saving.",
|
||||||
"alerts": {
|
"alerts": {
|
||||||
"minutes_after": "{count, plural, one {# minute after} other {# minutes after}}"
|
"minutes_after": "{count, plural, one {# minute after} other {# minutes after}}"
|
||||||
@@ -17,6 +20,12 @@
|
|||||||
"detail": {
|
"detail": {
|
||||||
"location_hint": "Opens the location; long-press to copy it"
|
"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": {
|
"drawer": {
|
||||||
"my_calendars": "My calendars",
|
"my_calendars": "My calendars",
|
||||||
"no_calendars": "No calendars yet.",
|
"no_calendars": "No calendars yet.",
|
||||||
@@ -28,6 +37,7 @@
|
|||||||
},
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"copy_link_short": "Link",
|
"copy_link_short": "Link",
|
||||||
|
"copy_title_short": "Title",
|
||||||
"export_ics_short": ".ics",
|
"export_ics_short": ".ics",
|
||||||
"nothing_planned": "Nothing planned. Tap to create.",
|
"nothing_planned": "Nothing planned. Tap to create.",
|
||||||
"tap_to_create": "Tap + to create one"
|
"tap_to_create": "Tap + to create one"
|
||||||
@@ -59,12 +69,16 @@
|
|||||||
"add_to_calendar": "Add to calendar",
|
"add_to_calendar": "Add to calendar",
|
||||||
"added": "Added to calendar",
|
"added": "Added to calendar",
|
||||||
"already_in_calendar": "Already in your 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",
|
"cancelled": "This event was cancelled",
|
||||||
"decline": "No",
|
"decline": "No",
|
||||||
"join_video": "Join video call",
|
"join_video": "Join video call",
|
||||||
"no_writable_calendar": "No writable calendar available",
|
"no_writable_calendar": "No writable calendar available",
|
||||||
"organizer": "Organizer",
|
"organizer": "Organizer",
|
||||||
"reading": "Reading invitation…",
|
"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",
|
"reply": "A participant replied to this invitation",
|
||||||
"response_error": "Could not send your response",
|
"response_error": "Could not send your response",
|
||||||
"response_sent": "Response sent",
|
"response_sent": "Response sent",
|
||||||
@@ -80,10 +94,14 @@
|
|||||||
"clear_confirm": "Remove all",
|
"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_description": "Every event in this calendar will be deleted. Events that also belong to another calendar are only unlinked.",
|
||||||
"clear_title": "Remove all events?",
|
"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_description": "The calendar and all of its events will be deleted. This cannot be undone.",
|
||||||
"delete_title": "Delete calendar?",
|
"delete_title": "Delete calendar?",
|
||||||
"description_placeholder": "Optional",
|
"description_placeholder": "Optional",
|
||||||
"edit_calendar": "Edit calendar",
|
"edit_calendar": "Edit calendar",
|
||||||
|
"fewer_colors": "Fewer colors",
|
||||||
|
"more_colors": "More colors",
|
||||||
"new_calendar": "New calendar"
|
"new_calendar": "New calendar"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"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.",
|
"occurrence_not_found_again": "The change was not saved: the occurrence could not be found again on the server. Please try again.",
|
||||||
"participants": {
|
"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."
|
"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": {
|
"recurrence": {
|
||||||
@@ -188,8 +207,11 @@
|
|||||||
"move_to_address_book": "Move to address book",
|
"move_to_address_book": "Move to address book",
|
||||||
"tag_failed": "Tagging failed"
|
"tag_failed": "Tagging failed"
|
||||||
},
|
},
|
||||||
|
"category_delete_failed": "Couldn't delete the category",
|
||||||
|
"category_deleted": "Category \"{name}\" deleted",
|
||||||
"contact": "Contact",
|
"contact": "Contact",
|
||||||
"create_hint_mobile": "Long-press to create a group instead",
|
"create_hint_mobile": "Long-press to create a group instead",
|
||||||
|
"delete_category": "Delete category",
|
||||||
"detail": {
|
"detail": {
|
||||||
"add_to_group_failed": "Add to group failed",
|
"add_to_group_failed": "Add to group failed",
|
||||||
"copy_name": "{name} (Copy)",
|
"copy_name": "{name} (Copy)",
|
||||||
@@ -287,6 +309,7 @@
|
|||||||
},
|
},
|
||||||
"rename_tag_hint_mobile": "Long-press a tag to rename it",
|
"rename_tag_hint_mobile": "Long-press a tag to rename it",
|
||||||
"tag_name_placeholder": "New tag name",
|
"tag_name_placeholder": "New tag name",
|
||||||
|
"undo": "Undo",
|
||||||
"unnamed": "Unnamed"
|
"unnamed": "Unnamed"
|
||||||
},
|
},
|
||||||
"context_menu": {
|
"context_menu": {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
queryEvents,
|
queryEvents,
|
||||||
getEvents,
|
getEvents,
|
||||||
createEvent,
|
createEvent,
|
||||||
|
createCalendar,
|
||||||
updateEvent,
|
updateEvent,
|
||||||
deleteEvents,
|
deleteEvents,
|
||||||
batchCreateEvents,
|
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)', () => {
|
describe('refusals for the participant limit (Stalwart 1.0)', () => {
|
||||||
// 1.0 enforces maxParticipantsPerEvent on every create and update, an
|
// 1.0 enforces maxParticipantsPerEvent on every create and update, an
|
||||||
// answer included; 0.16 advertises the limit without enforcing it.
|
// answer included; 0.16 advertises the limit without enforcing it.
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { CAPABILITIES } from '../types';
|
|||||||
import {
|
import {
|
||||||
accountSupportsFiles,
|
accountSupportsFiles,
|
||||||
copyFileNode,
|
copyFileNode,
|
||||||
|
copyFileTree,
|
||||||
createFolder,
|
createFolder,
|
||||||
deleteFileNodes,
|
deleteFileNodes,
|
||||||
fileNodeKind,
|
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', () => {
|
describe('fileNodeKind', () => {
|
||||||
it('takes nodeType as Stalwart 0.16.6+ returns it', () => {
|
it('takes nodeType as Stalwart 0.16.6+ returns it', () => {
|
||||||
expect(fileNodeKind({ nodeType: 'file', blobId: 'b1' })).toBe('file');
|
expect(fileNodeKind({ nodeType: 'file', blobId: 'b1' })).toBe('file');
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import type { Calendar, CalendarEvent, Participant } from '../types';
|
|||||||
import {
|
import {
|
||||||
createEvent,
|
createEvent,
|
||||||
deleteEvents,
|
deleteEvents,
|
||||||
|
findEventsByUid,
|
||||||
getCalendars,
|
getCalendars,
|
||||||
getEvents,
|
getEvents,
|
||||||
queryEvents,
|
queryEvents,
|
||||||
@@ -43,6 +44,7 @@ import { expandRecurringEvents } from '../../lib/recurrence-expansion';
|
|||||||
import { getEventStartDate } from '../../lib/calendar-utils';
|
import { getEventStartDate } from '../../lib/calendar-utils';
|
||||||
import { alertsToReminders, calendarDefaultAlerts, defaultAlertIdPrefix, remindersToDefaultAlerts } from '../../lib/calendar-alerts';
|
import { alertsToReminders, calendarDefaultAlerts, defaultAlertIdPrefix, remindersToDefaultAlerts } from '../../lib/calendar-alerts';
|
||||||
import { getEffectiveAlerts } from '../../lib/calendar-alert-scheduler';
|
import { getEffectiveAlerts } from '../../lib/calendar-alert-scheduler';
|
||||||
|
import { removeCancelledMeeting } from '../../lib/calendar-cancellation';
|
||||||
|
|
||||||
const SERVER = process.env.BULWARK_LIVE_JMAP;
|
const SERVER = process.env.BULWARK_LIVE_JMAP;
|
||||||
const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org';
|
const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org';
|
||||||
@@ -607,6 +609,89 @@ live('live Stalwart calendar', () => {
|
|||||||
}, 120_000);
|
}, 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', () => {
|
liveTwo('shares', () => {
|
||||||
let mailboxId = '';
|
let mailboxId = '';
|
||||||
let sharedCalendarId = '';
|
let sharedCalendarId = '';
|
||||||
@@ -698,6 +783,59 @@ live('live Stalwart calendar', () => {
|
|||||||
expect(list).toHaveLength(1);
|
expect(list).toHaveLength(1);
|
||||||
expect(list[0].recurrenceOverrides?.['2027-10-11T10:00:00']?.title).toBe(`${RUN} owner second`);
|
expect(list[0].recurrenceOverrides?.['2027-10-11T10:00:00']?.title).toBe(`${RUN} owner second`);
|
||||||
}, 60_000);
|
}, 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)', () => {
|
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
|
// 1.0 refuses to destroy a book that holds cards without
|
||||||
// onDestroyRemoveContents (addressBookHasContents).
|
// 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 () => {
|
it('deletes an address book that still holds contacts', async () => {
|
||||||
const book = await createAddressBook(`live-contacts-${RUN}-full`);
|
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);
|
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 type { FileNode } from '../types';
|
||||||
import {
|
import {
|
||||||
copyFileNode,
|
copyFileNode,
|
||||||
|
copyFileTree,
|
||||||
createFolder,
|
createFolder,
|
||||||
deleteFileNodes,
|
deleteFileNodes,
|
||||||
destroyEmptyFolders,
|
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([]);
|
expect(all.filter((n) => [copy.id, box.id, file.id, inner.id].includes(n.id))).toEqual([]);
|
||||||
}, 90_000);
|
}, 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 () => {
|
it('moves a node to the drive root and back', async () => {
|
||||||
const { uploaded } = await uploadFileBatch([pick('root-trip.txt', 'x')], scratch, []);
|
const { uploaded } = await uploadFileBatch([pick('root-trip.txt', 'x')], scratch, []);
|
||||||
const id = uploaded[0].id;
|
const id = uploaded[0].id;
|
||||||
|
|||||||
@@ -45,8 +45,9 @@ import { pruneEmailSubmissions } from '../submissions';
|
|||||||
import { listPrincipals } from '../files';
|
import { listPrincipals } from '../files';
|
||||||
import { createContact, deleteContacts, getAddressBooks, getContact } from '../contacts';
|
import { createContact, deleteContacts, getAddressBooks, getContact } from '../contacts';
|
||||||
import {
|
import {
|
||||||
createSieveScript, deleteSieveScript, getSieveScriptContent, getSieveScripts, validateSieveScript,
|
createSieveScript, deleteSieveScript, getSieveScriptContent, getSieveScripts, updateSieveScript, validateSieveScript,
|
||||||
} from '../sieve';
|
} from '../sieve';
|
||||||
|
import { useFilterStore } from '../../stores/filter-store';
|
||||||
import { inlineContactMediaForExport, writeContactWithNewPhoto } from '../../lib/contact-media';
|
import { inlineContactMediaForExport, writeContactWithNewPhoto } from '../../lib/contact-media';
|
||||||
import { getBulwarkMetadata, resetBulwarkMetadata, supportsBulwarkMetadata } from '../bulwark-metadata';
|
import { getBulwarkMetadata, resetBulwarkMetadata, supportsBulwarkMetadata } from '../bulwark-metadata';
|
||||||
import { parseSharedTags, SHARED_TAGS_KEY } from '../../lib/shared-tags';
|
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);
|
}, 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 () => {
|
it('changes and restores an identity\'s name and signature', async () => {
|
||||||
const [identity] = await getIdentities();
|
const [identity] = await getIdentities();
|
||||||
const before = { name: identity.name, textSignature: identity.textSignature ?? '' };
|
const before = { name: identity.name, textSignature: identity.textSignature ?? '' };
|
||||||
|
|||||||
+26
-6
@@ -1250,23 +1250,43 @@ export async function createCalendar(
|
|||||||
const accountId = targetAccountId || jmapClient.accountId;
|
const accountId = targetAccountId || jmapClient.accountId;
|
||||||
const props: Record<string, unknown> = { name, color, isVisible: true, isSubscribed: true };
|
const props: Record<string, unknown> = { name, color, isVisible: true, isSubscribed: true };
|
||||||
if (description?.trim()) props.description = description.trim();
|
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(
|
const res = await jmapClient.request(
|
||||||
[['Calendar/set', {
|
[
|
||||||
accountId,
|
['Calendar/set', {
|
||||||
create: { 'new-cal': props },
|
accountId,
|
||||||
}, '0']],
|
create: { 'new-cal': props },
|
||||||
|
}, '0'],
|
||||||
|
['Calendar/get', {
|
||||||
|
accountId,
|
||||||
|
ids: ['#new-cal'],
|
||||||
|
properties: CALENDAR_PROPERTIES,
|
||||||
|
}, '1'],
|
||||||
|
],
|
||||||
USING,
|
USING,
|
||||||
);
|
);
|
||||||
const result = methodResult<{
|
const result = methodResult<{
|
||||||
created?: Record<string, Calendar>;
|
created?: Record<string, Calendar>;
|
||||||
notCreated?: Record<string, { description?: string; type?: string }>;
|
notCreated?: Record<string, { description?: string; type?: string }>;
|
||||||
}>(res);
|
}>(res, '0');
|
||||||
const created = result.created?.['new-cal'];
|
const created = result.created?.['new-cal'];
|
||||||
if (!created) {
|
if (!created) {
|
||||||
const err = result.notCreated?.['new-cal'];
|
const err = result.notCreated?.['new-cal'];
|
||||||
throw new Error(err?.description || err?.type || 'Failed to create calendar');
|
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);
|
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) ────────────────────────────────────
|
// ── Sharing (RFC 9670) ────────────────────────────────────
|
||||||
|
|
||||||
// Add, update, or remove a principal's rights on an owned FileNode (file or
|
// 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,
|
* 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
|
* 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 { useColors } from '../../theme/colors';
|
||||||
import { useLocaleStore, type TranslateFn } from '../../stores/locale-store';
|
import { useLocaleStore, type TranslateFn } from '../../stores/locale-store';
|
||||||
import { CALENDAR_COLOR_PALETTE, calendarColorName, getCalendarColor } from '../../lib/calendar-utils';
|
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 Button from '../Button';
|
||||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
import { useSheetInsets } from '../../lib/use-layout';
|
import { useSheetInsets } from '../../lib/use-layout';
|
||||||
@@ -150,6 +153,9 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
|
|||||||
const [name, setName] = React.useState('');
|
const [name, setName] = React.useState('');
|
||||||
const [color, setColor] = React.useState<string>(CALENDAR_COLOR_PALETTE[0]);
|
const [color, setColor] = React.useState<string>(CALENDAR_COLOR_PALETTE[0]);
|
||||||
const [description, setDescription] = React.useState('');
|
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 [saving, setSaving] = React.useState(false);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
// The calendar's default reminders (#1209): the owner's to set, for an
|
// 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(() => {
|
React.useEffect(() => {
|
||||||
if (!visible) return;
|
if (!visible) return;
|
||||||
setName(calendar?.name ?? '');
|
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 ?? '');
|
setDescription(calendar?.description ?? '');
|
||||||
setTimedReminders(initialTimed);
|
setTimedReminders(initialTimed);
|
||||||
setAllDayReminders(initialAllDay);
|
setAllDayReminders(initialAllDay);
|
||||||
@@ -173,9 +182,15 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
|
|||||||
setSaving(false);
|
setSaving(false);
|
||||||
}, [visible, calendar, initialTimed, initialAllDay]);
|
}, [visible, calendar, initialTimed, initialAllDay]);
|
||||||
|
|
||||||
|
const pickColor = (col: string) => {
|
||||||
|
setColor(col);
|
||||||
|
setHexInput(col);
|
||||||
|
};
|
||||||
|
const hexInvalid = normalizeHexColor(hexInput) === null;
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
if (!trimmed || saving) return;
|
if (!trimmed || saving || hexInvalid) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -224,7 +239,7 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
|
|||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={col}
|
key={col}
|
||||||
onPress={() => setColor(col)}
|
onPress={() => pickColor(col)}
|
||||||
style={[styles.swatch, { backgroundColor: col }, active && styles.swatchActive]}
|
style={[styles.swatch, { backgroundColor: col }, active && styles.swatchActive]}
|
||||||
accessibilityRole="radio"
|
accessibilityRole="radio"
|
||||||
accessibilityLabel={calendarColorName(col, t)}
|
accessibilityLabel={calendarColorName(col, t)}
|
||||||
@@ -235,6 +250,62 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</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>
|
<Text style={styles.label}>{t('calendar.management.description', 'Description')}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
value={description}
|
value={description}
|
||||||
@@ -282,7 +353,7 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onPress={() => { void handleSave(); }}
|
onPress={() => { void handleSave(); }}
|
||||||
disabled={!name.trim() || saving}
|
disabled={!name.trim() || saving || hexInvalid}
|
||||||
icon={saving ? <ActivityIndicator size="small" color={c.primaryForeground} /> : undefined}
|
icon={saving ? <ActivityIndicator size="small" color={c.primaryForeground} /> : undefined}
|
||||||
>
|
>
|
||||||
{calendar ? t('common.save', 'Save') : t('common.create', 'Create')}
|
{calendar ? t('common.save', 'Save') : t('common.create', 'Create')}
|
||||||
@@ -336,6 +407,15 @@ function makeStyles(c: ThemePalette) {
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
swatchActive: { borderWidth: 2, borderColor: c.text },
|
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 },
|
error: { ...typography.caption, color: c.error },
|
||||||
footer: { flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.md },
|
footer: { flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.md },
|
||||||
sectionTitle: { ...typography.bodyMedium, color: c.text, marginTop: spacing.md },
|
sectionTitle: { ...typography.bodyMedium, color: c.text, marginTop: spacing.md },
|
||||||
|
|||||||
@@ -322,8 +322,9 @@ function Section({
|
|||||||
const isSubscription = !!isSubscriptionCalendar?.(cal.id);
|
const isSubscription = !!isSubscriptionCalendar?.(cal.id);
|
||||||
const isOwn = !cal.isShared && !isBirthday && !isSubscription;
|
const isOwn = !cal.isShared && !isBirthday && !isSubscription;
|
||||||
const canSetDefault = !!onSetDefault && isOwn && !cal.isDefault;
|
const canSetDefault = !!onSetDefault && isOwn && !cal.isDefault;
|
||||||
// Own calendars recolour server-side; shared ones locally (#345).
|
// Own calendars recolour server-side; shared ones and the birthdays
|
||||||
const canRecolor = !!onSetColor && (!!cal.isShared || isOwn);
|
// locally (#345).
|
||||||
|
const canRecolor = !!onSetColor && (!!cal.isShared || isOwn || isBirthday);
|
||||||
const canRename = !!onRename && isOwn;
|
const canRename = !!onRename && isOwn;
|
||||||
const canShare = !!onShare && isOwn && (!cal.myRights || cal.myRights.mayShare !== false);
|
const canShare = !!onShare && isOwn && (!cal.myRights || cal.myRights.mayShare !== false);
|
||||||
const canClear = !!onClear && isOwn && isWritableCalendar(cal);
|
const canClear = !!onClear && isOwn && isWritableCalendar(cal);
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ interface TimedEventBlockProps {
|
|||||||
/** Position and size in the day column. */
|
/** Position and size in the day column. */
|
||||||
style: StyleProp<ViewStyle>;
|
style: StyleProp<ViewStyle>;
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
|
/** What a screen reader says: title, date, time (see lib/calendar-a11y). */
|
||||||
|
accessibilityLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A timed event in a day column. */
|
/** A timed event in a day column. */
|
||||||
@@ -66,11 +68,14 @@ export function TimedEventBlock({
|
|||||||
continuesAfter,
|
continuesAfter,
|
||||||
style,
|
style,
|
||||||
onPress,
|
onPress,
|
||||||
|
accessibilityLabel,
|
||||||
}: TimedEventBlockProps) {
|
}: TimedEventBlockProps) {
|
||||||
const inactive = colors.border !== null;
|
const inactive = colors.border !== null;
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={accessibilityLabel ?? title}
|
||||||
style={[
|
style={[
|
||||||
styles.block,
|
styles.block,
|
||||||
corners('vertical', continuesBefore, continuesAfter, EVENT_RADIUS),
|
corners('vertical', continuesBefore, continuesAfter, EVENT_RADIUS),
|
||||||
@@ -114,6 +119,8 @@ interface AllDayEventBarProps {
|
|||||||
/** Position and size in the all-day strip. */
|
/** Position and size in the all-day strip. */
|
||||||
style: StyleProp<ViewStyle>;
|
style: StyleProp<ViewStyle>;
|
||||||
onPress?: () => void;
|
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. */
|
/** An all-day or multi-day event in the strip above the grid. */
|
||||||
@@ -124,10 +131,13 @@ export function AllDayEventBar({
|
|||||||
continuesAfter,
|
continuesAfter,
|
||||||
style,
|
style,
|
||||||
onPress,
|
onPress,
|
||||||
|
accessibilityLabel,
|
||||||
}: AllDayEventBarProps) {
|
}: AllDayEventBarProps) {
|
||||||
const inactive = colors.border !== null;
|
const inactive = colors.border !== null;
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={accessibilityLabel ?? title}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
style={[
|
style={[
|
||||||
styles.bar,
|
styles.bar,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
|
TextInput,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import * as Clipboard from 'expo-clipboard';
|
import * as Clipboard from 'expo-clipboard';
|
||||||
@@ -82,6 +83,8 @@ interface EventDetailSheetProps {
|
|||||||
// Copy the meeting link; only offered when the event has one.
|
// Copy the meeting link; only offered when the event has one.
|
||||||
onCopyLink?: (event: CalendarEvent, link: string) => void;
|
onCopyLink?: (event: CalendarEvent, link: string) => void;
|
||||||
onRsvp?: (event: CalendarEvent, participantId: string, status: RsvpStatus) => void | Promise<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 {
|
function formatRange(event: CalendarEvent, timeFormat: TimeFormat | undefined, locale: Locale): string {
|
||||||
@@ -134,10 +137,20 @@ export function EventDetailSheet({
|
|||||||
onExport,
|
onExport,
|
||||||
onCopyLink,
|
onCopyLink,
|
||||||
onRsvp,
|
onRsvp,
|
||||||
|
onSaveNote,
|
||||||
}: EventDetailSheetProps) {
|
}: EventDetailSheetProps) {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const { locale, t } = useCalendarLocale();
|
const { locale, t } = useCalendarLocale();
|
||||||
const [rsvpBusy, setRsvpBusy] = React.useState(false);
|
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]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
// Bare addresses (the organizer above all — Stalwart drops its display
|
// Bare addresses (the organizer above all — Stalwart drops its display
|
||||||
// name) render with the contact card's name instead of the raw email.
|
// 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 = () => {
|
const copyLocation = () => {
|
||||||
if (!location) return;
|
if (!location) return;
|
||||||
void Clipboard.setStringAsync(location).then(() => {
|
void Clipboard.setStringAsync(location).then(() => {
|
||||||
@@ -333,6 +366,38 @@ export function EventDetailSheet({
|
|||||||
linkify
|
linkify
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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 && (
|
{recurrence && (
|
||||||
<DetailRow
|
<DetailRow
|
||||||
icon={<Repeat size={16} color={c.textMuted} />}
|
icon={<Repeat size={16} color={c.textMuted} />}
|
||||||
@@ -419,7 +484,7 @@ export function EventDetailSheet({
|
|||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{(canEdit || onDuplicate || onExport || (onCopyLink && videoUri)) && (
|
{(
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
{onEdit && canEdit && (
|
{onEdit && canEdit && (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
@@ -442,6 +507,11 @@ export function EventDetailSheet({
|
|||||||
onPress={() => onExport(event)}
|
onPress={() => onExport(event)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<ActionButton
|
||||||
|
icon={<Copy size={18} color={c.text} />}
|
||||||
|
label={t('calendar.events.copy_title_short', 'Title')}
|
||||||
|
onPress={copyTitle}
|
||||||
|
/>
|
||||||
{onCopyLink && videoUri && (
|
{onCopyLink && videoUri && (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={<Link2 size={18} color={c.text} />}
|
icon={<Link2 size={18} color={c.text} />}
|
||||||
@@ -669,6 +739,24 @@ function makeStyles(c: ThemePalette) {
|
|||||||
joinBtnText: { ...typography.bodyMedium, color: c.primary },
|
joinBtnText: { ...typography.bodyMedium, color: c.primary },
|
||||||
|
|
||||||
rsvpBlock: { gap: spacing.sm, marginTop: spacing.xs },
|
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 },
|
rsvpPrompt: { ...typography.bodyMedium, color: c.text },
|
||||||
rsvpButtons: { flexDirection: 'row', gap: spacing.sm },
|
rsvpButtons: { flexDirection: 'row', gap: spacing.sm },
|
||||||
rsvpBtn: {
|
rsvpBtn: {
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ import { SafeAreaModal } from '../SafeAreaModal';
|
|||||||
import { useKeyboardObstruction } from '../../lib/use-keyboard-obstruction';
|
import { useKeyboardObstruction } from '../../lib/use-keyboard-obstruction';
|
||||||
import { editorKeyboardInset } from '../../lib/calendar-editor-keyboard';
|
import { editorKeyboardInset } from '../../lib/calendar-editor-keyboard';
|
||||||
import ToggleSwitch from '../ToggleSwitch';
|
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';
|
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
|
||||||
|
|
||||||
@@ -254,12 +256,34 @@ export function EventModal({
|
|||||||
const [showEndTime, setShowEndTime] = React.useState(false);
|
const [showEndTime, setShowEndTime] = React.useState(false);
|
||||||
const [saving, setSaving] = 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.
|
// Reset the form when the editor opens or switches to another event only.
|
||||||
// `calendars` and `currentUserEmails` get new identities when a calendar
|
// `calendars` and `currentUserEmails` get new identities when a calendar
|
||||||
// push or the alias lookup lands, which used to wipe what the user typed.
|
// push or the alias lookup lands, which used to wipe what the user typed.
|
||||||
const formKey = visible ? event?.id ?? 'new' : null;
|
const formKey = visible ? event?.id ?? 'new' : null;
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!visible) return;
|
if (!visible) return;
|
||||||
|
setBaseline(null);
|
||||||
if (event) {
|
if (event) {
|
||||||
setTitle(event.title || '');
|
setTitle(event.title || '');
|
||||||
setDescription(event.description || '');
|
setDescription(event.description || '');
|
||||||
@@ -306,7 +330,11 @@ export function EventModal({
|
|||||||
|
|
||||||
// The calendars can arrive after a new-event form opened.
|
// The calendars can arrive after a new-event form opened.
|
||||||
React.useEffect(() => {
|
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]);
|
}, [visible, calendarId, fallbackCalendarId]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -408,14 +436,18 @@ export function EventModal({
|
|||||||
for (const key of Object.keys(data) as (keyof CalendarEvent)[]) {
|
for (const key of Object.keys(data) as (keyof CalendarEvent)[]) {
|
||||||
if (data[key] === undefined) delete data[key];
|
if (data[key] === undefined) delete data[key];
|
||||||
}
|
}
|
||||||
await onSave(data, calendarId, {
|
// Only the organizer sends: on an invitation someone else sent, a
|
||||||
// Only the organizer sends: on an invitation someone else sent, a
|
// save would reach the organizer as a reply.
|
||||||
// save would reach the organizer as a reply.
|
const send = organizes && attendees.length > 0 && sendInvitations;
|
||||||
sendSchedulingMessages: 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
|
// The dates as shown: a series moves by the occurrence's move, on
|
||||||
// its own clock (lib/recurrence-scope-edit).
|
// its own clock (lib/recurrence-scope-edit).
|
||||||
edit: { attendees, when: { allDay, start, end }, ruleChanged },
|
edit: { attendees, when: { allDay, start, end }, ruleChanged },
|
||||||
});
|
}), send, t);
|
||||||
|
if (!saved) return;
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// The account changed under the save: the event is the previous
|
// The account changed under the save: the event is the previous
|
||||||
@@ -492,12 +524,12 @@ export function EventModal({
|
|||||||
visible={visible}
|
visible={visible}
|
||||||
animationType="slide"
|
animationType="slide"
|
||||||
presentationStyle="fullScreen"
|
presentationStyle="fullScreen"
|
||||||
onRequestClose={onClose}
|
onRequestClose={requestClose}
|
||||||
>
|
>
|
||||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']} onLayout={onContainerLayout}>
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']} onLayout={onContainerLayout}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onClose}
|
onPress={requestClose}
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
style={styles.headerBtn}
|
style={styles.headerBtn}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { dayIndexIn, monthChipShowsTime, monthKeyOf, monthMask } from '../../lib
|
|||||||
import { isInactiveEvent } from '../../lib/calendar-participants';
|
import { isInactiveEvent } from '../../lib/calendar-participants';
|
||||||
import { eventBlockColors } from '../../lib/event-colors';
|
import { eventBlockColors } from '../../lib/event-colors';
|
||||||
import { useCalendarLocale } from '../../lib/calendar-locale';
|
import { useCalendarLocale } from '../../lib/calendar-locale';
|
||||||
|
import { dayCellAccessibilityLabel } from '../../lib/calendar-a11y';
|
||||||
import { displayNow } from '../../lib/calendar-timezone';
|
import { displayNow } from '../../lib/calendar-timezone';
|
||||||
|
|
||||||
type WeekStart = 0 | 1 | 6;
|
type WeekStart = 0 | 1 | 6;
|
||||||
@@ -153,6 +154,7 @@ function MonthWeekRowInner({
|
|||||||
onLongPressDate,
|
onLongPressDate,
|
||||||
}: MonthWeekRowProps) {
|
}: MonthWeekRowProps) {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
|
const { t } = useCalendarLocale();
|
||||||
const { width } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
const chipTime = monthChipShowsTime(true, width / 7);
|
const chipTime = monthChipShowsTime(true, width / 7);
|
||||||
if (variant === 'full') {
|
if (variant === 'full') {
|
||||||
@@ -201,6 +203,9 @@ function MonthWeekRowInner({
|
|||||||
style={[styles.dayCell, showTimeInMonthView && styles.dayCellTall]}
|
style={[styles.dayCell, showTimeInMonthView && styles.dayCellTall]}
|
||||||
onPress={() => onSelectDate(d)}
|
onPress={() => onSelectDate(d)}
|
||||||
onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined}
|
onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={dayCellAccessibilityLabel(d, dayEvents.length, t, locale)}
|
||||||
|
accessibilityState={{ selected }}
|
||||||
>
|
>
|
||||||
<View style={[
|
<View style={[
|
||||||
styles.dayNumber,
|
styles.dayNumber,
|
||||||
@@ -321,6 +326,7 @@ function FullWeekRow({
|
|||||||
colors: ThemePalette;
|
colors: ThemePalette;
|
||||||
}) {
|
}) {
|
||||||
const { width } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
|
const { t } = useCalendarLocale();
|
||||||
const showTime = monthChipShowsTime(showTimeInMonthView, width / 7);
|
const showTime = monthChipShowsTime(showTimeInMonthView, width / 7);
|
||||||
return (
|
return (
|
||||||
<View style={[styles.fullWeekRow, { height }]}>
|
<View style={[styles.fullWeekRow, { height }]}>
|
||||||
@@ -341,7 +347,7 @@ function FullWeekRow({
|
|||||||
onPress={() => onSelectDate(d)}
|
onPress={() => onSelectDate(d)}
|
||||||
onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined}
|
onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={format(d, 'PPPP', { locale })}
|
accessibilityLabel={dayCellAccessibilityLabel(d, dayEvents.length, t, locale)}
|
||||||
>
|
>
|
||||||
<View style={styles.fullNumberWrap}>
|
<View style={styles.fullNumberWrap}>
|
||||||
<View style={[styles.fullNumber, today && styles.fullNumberToday]}>
|
<View style={[styles.fullNumber, today && styles.fullNumberToday]}>
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import {
|
|||||||
import { X } from '../icons';
|
import { X } from '../icons';
|
||||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
import { useColors } from '../../theme/colors';
|
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 { useLocaleStore } from '../../stores/locale-store';
|
||||||
import type { Attendee } from '../../lib/calendar-participants';
|
import type { Attendee } from '../../lib/calendar-participants';
|
||||||
|
import { participantSuggestions, participantsFromSuggestion } from '../../lib/calendar-participant-suggestions';
|
||||||
|
|
||||||
interface ParticipantInputProps {
|
interface ParticipantInputProps {
|
||||||
attendees: Attendee[];
|
attendees: Attendee[];
|
||||||
@@ -20,28 +21,10 @@ interface ParticipantInputProps {
|
|||||||
onRemove: (email: string) => void;
|
onRemove: (email: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Suggestion {
|
|
||||||
email: string;
|
|
||||||
name?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function emailRegex(): RegExp {
|
function emailRegex(): RegExp {
|
||||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
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
|
// Attendee rows only: the organizer participant is added by
|
||||||
// buildParticipantMap on save (see lib/calendar-participants).
|
// buildParticipantMap on save (see lib/calendar-participants).
|
||||||
export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInputProps) {
|
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 styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const t = useLocaleStore((s) => s.t);
|
const t = useLocaleStore((s) => s.t);
|
||||||
const [draft, setDraft] = React.useState('');
|
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(() => {
|
const existingEmails = React.useMemo(() => {
|
||||||
return new Set(attendees.map((a) => a.email.toLowerCase()));
|
return new Set(attendees.map((a) => a.email.toLowerCase()));
|
||||||
}, [attendees]);
|
}, [attendees]);
|
||||||
|
|
||||||
const filteredSuggestions = React.useMemo(() => {
|
const filteredSuggestions = React.useMemo(
|
||||||
const q = draft.trim().toLowerCase();
|
() => participantSuggestions(draft, existingEmails, getAutocomplete, getGroupRecipients),
|
||||||
if (!q || q.length < 2) return [];
|
// contactsVersion/recentVersion re-run the lookup when the store loads.
|
||||||
return allSuggestions
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
.filter((s) => {
|
[draft, existingEmails, getAutocomplete, getGroupRecipients, contactsVersion, recentVersion],
|
||||||
if (existingEmails.has(s.email.toLowerCase())) return false;
|
);
|
||||||
return (
|
|
||||||
s.email.toLowerCase().includes(q) ||
|
const pickSuggestion = (s: RecipientSuggestion) => {
|
||||||
s.name?.toLowerCase().includes(q)
|
for (const person of participantsFromSuggestion(s, existingEmails, getGroupRecipients)) {
|
||||||
);
|
if (emailRegex().test(person.email.trim())) onAdd({ name: person.name, email: person.email.trim() });
|
||||||
})
|
}
|
||||||
.slice(0, 6);
|
setDraft('');
|
||||||
}, [draft, allSuggestions, existingEmails]);
|
};
|
||||||
|
|
||||||
const addParticipant = (email: string, name?: string) => {
|
const addParticipant = (email: string, name?: string) => {
|
||||||
const trimmed = email.trim();
|
const trimmed = email.trim();
|
||||||
@@ -117,6 +104,10 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu
|
|||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
keyboardType="email-address"
|
keyboardType="email-address"
|
||||||
|
// The app's own suggestions only: system autofill would cover them.
|
||||||
|
autoComplete="off"
|
||||||
|
importantForAutofill="no"
|
||||||
|
textContentType="none"
|
||||||
onSubmitEditing={handleSubmit}
|
onSubmitEditing={handleSubmit}
|
||||||
onBlur={handleSubmit}
|
onBlur={handleSubmit}
|
||||||
returnKeyType="done"
|
returnKeyType="done"
|
||||||
@@ -131,15 +122,19 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu
|
|||||||
>
|
>
|
||||||
{filteredSuggestions.map((s) => (
|
{filteredSuggestions.map((s) => (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={s.email}
|
key={s.group ? `group:${s.group.id}` : s.email}
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [
|
||||||
styles.suggestionRow,
|
styles.suggestionRow,
|
||||||
pressed && styles.suggestionRowPressed,
|
pressed && styles.suggestionRowPressed,
|
||||||
]}
|
]}
|
||||||
onPress={() => addParticipant(s.email, s.name)}
|
onPress={() => pickSuggestion(s)}
|
||||||
>
|
>
|
||||||
{s.name && <Text style={styles.suggestionName}>{s.name}</Text>}
|
{!!s.name && <Text style={styles.suggestionName}>{s.name}</Text>}
|
||||||
<Text style={styles.suggestionEmail}>{s.email}</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>
|
</Pressable>
|
||||||
))}
|
))}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -34,10 +34,12 @@ import {
|
|||||||
emptyTaskEditor,
|
emptyTaskEditor,
|
||||||
priorityToLevel,
|
priorityToLevel,
|
||||||
submitTaskEditor,
|
submitTaskEditor,
|
||||||
|
taskEditorChanged,
|
||||||
taskEditorFromTask,
|
taskEditorFromTask,
|
||||||
type PriorityLevel,
|
type PriorityLevel,
|
||||||
type TaskEditorState,
|
type TaskEditorState,
|
||||||
} from '../../lib/task-editor';
|
} from '../../lib/task-editor';
|
||||||
|
import { askBeforeDiscard } from '../../lib/calendar-editor-discard';
|
||||||
import { isWritableCalendar } from '../../lib/calendar-editability';
|
import { isWritableCalendar } from '../../lib/calendar-editability';
|
||||||
import { useCalendarLocale } from '../../lib/calendar-locale';
|
import { useCalendarLocale } from '../../lib/calendar-locale';
|
||||||
import { formatCalendarDate } from '../../lib/date-format';
|
import { formatCalendarDate } from '../../lib/date-format';
|
||||||
@@ -113,10 +115,13 @@ export function TasksSheet({
|
|||||||
// Centred on a wide window, clear of side insets in landscape.
|
// Centred on a wide window, clear of side insets in landscape.
|
||||||
const sheetInsets = useSheetInsets();
|
const sheetInsets = useSheetInsets();
|
||||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
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({
|
const dragHandlers = useSheetDrag({
|
||||||
slideY,
|
slideY,
|
||||||
closedY: Dimensions.get('window').height,
|
closedY: Dimensions.get('window').height,
|
||||||
onClose,
|
onClose: React.useCallback(() => closeRef.current(), []),
|
||||||
});
|
});
|
||||||
|
|
||||||
const writableCalendars = React.useMemo(
|
const writableCalendars = React.useMemo(
|
||||||
@@ -127,6 +132,12 @@ export function TasksSheet({
|
|||||||
|
|
||||||
const [filter, setFilter] = React.useState<TaskFilter>('all');
|
const [filter, setFilter] = React.useState<TaskFilter>('all');
|
||||||
const [editor, setEditor] = React.useState<TaskEditorState>(() => emptyTaskEditor(defaultCalendarId));
|
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 [expanded, setExpanded] = React.useState(false);
|
||||||
const [showDatePicker, setShowDatePicker] = React.useState(false);
|
const [showDatePicker, setShowDatePicker] = React.useState(false);
|
||||||
const [showTimePicker, setShowTimePicker] = React.useState(false);
|
const [showTimePicker, setShowTimePicker] = React.useState(false);
|
||||||
@@ -136,10 +147,10 @@ export function TasksSheet({
|
|||||||
if (!visible) return;
|
if (!visible) return;
|
||||||
const initial = initialTaskId ? tasks.find((task) => task.id === initialTaskId) : undefined;
|
const initial = initialTaskId ? tasks.find((task) => task.id === initialTaskId) : undefined;
|
||||||
if (initial) {
|
if (initial) {
|
||||||
setEditor(taskEditorFromTask(initial, defaultCalendarId));
|
seedEditor(taskEditorFromTask(initial, defaultCalendarId));
|
||||||
setExpanded(true);
|
setExpanded(true);
|
||||||
} else {
|
} else {
|
||||||
setEditor(emptyTaskEditor(defaultCalendarId));
|
seedEditor(emptyTaskEditor(defaultCalendarId));
|
||||||
setExpanded(startNew);
|
setExpanded(startNew);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -148,6 +159,7 @@ export function TasksSheet({
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (visible && !editor.calendarId && defaultCalendarId) {
|
if (visible && !editor.calendarId && defaultCalendarId) {
|
||||||
setEditor((e) => ({ ...e, calendarId: defaultCalendarId }));
|
setEditor((e) => ({ ...e, calendarId: defaultCalendarId }));
|
||||||
|
setBaseline((b) => (b.calendarId ? b : { ...b, calendarId: defaultCalendarId }));
|
||||||
}
|
}
|
||||||
}, [visible, editor.calendarId, defaultCalendarId]);
|
}, [visible, editor.calendarId, defaultCalendarId]);
|
||||||
|
|
||||||
@@ -189,10 +201,17 @@ export function TasksSheet({
|
|||||||
}), [tasks]);
|
}), [tasks]);
|
||||||
|
|
||||||
const resetEditor = () => {
|
const resetEditor = () => {
|
||||||
setEditor(emptyTaskEditor(defaultCalendarId));
|
seedEditor(emptyTaskEditor(defaultCalendarId));
|
||||||
setExpanded(false);
|
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 () => {
|
const handleSave = async () => {
|
||||||
if (!editor.title.trim() || !editor.calendarId || saving) return;
|
if (!editor.title.trim() || !editor.calendarId || saving) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -235,9 +254,9 @@ export function TasksSheet({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
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 }]}>
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
<Pressable style={styles.overlayPress} onPress={onClose} />
|
<Pressable style={styles.overlayPress} onPress={requestClose} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }], ...sheetInsets }]}>
|
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }], ...sheetInsets }]}>
|
||||||
@@ -250,7 +269,7 @@ export function TasksSheet({
|
|||||||
<ListChecks size={20} color={c.text} />
|
<ListChecks size={20} color={c.text} />
|
||||||
<Text style={styles.headerTitle}>{t('calendar.tasks.label', 'Tasks')}</Text>
|
<Text style={styles.headerTitle}>{t('calendar.tasks.label', 'Tasks')}</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onClose}
|
onPress={requestClose}
|
||||||
style={styles.closeBtn}
|
style={styles.closeBtn}
|
||||||
hitSlop={10}
|
hitSlop={10}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
@@ -359,7 +378,7 @@ export function TasksSheet({
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
)}
|
)}
|
||||||
{editor.id && (
|
{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>
|
<Text style={styles.cancelEditText}>{t('calendar.form.cancel', 'Cancel')}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
@@ -410,9 +429,11 @@ export function TasksSheet({
|
|||||||
<Pressable
|
<Pressable
|
||||||
style={styles.taskText}
|
style={styles.taskText}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (!onUpdate) return;
|
if (!onUpdate || editor.id === task.id) return;
|
||||||
setEditor(taskEditorFromTask(task, defaultCalendarId));
|
askBeforeDiscard(dirty, t, () => {
|
||||||
setExpanded(true);
|
seedEditor(taskEditorFromTask(task, defaultCalendarId));
|
||||||
|
setExpanded(true);
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View style={styles.taskTitleRow}>
|
<View style={styles.taskTitleRow}>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type { Calendar, CalendarEvent } from '../../api/types';
|
|||||||
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
|
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
import { useColors } from '../../theme/colors';
|
import { useColors } from '../../theme/colors';
|
||||||
import { useCalendarLocale } from '../../lib/calendar-locale';
|
import { useCalendarLocale } from '../../lib/calendar-locale';
|
||||||
|
import { eventAccessibilityLabel } from '../../lib/calendar-a11y';
|
||||||
import {
|
import {
|
||||||
buildEventDayIndex,
|
buildEventDayIndex,
|
||||||
dayKey,
|
dayKey,
|
||||||
@@ -439,6 +440,7 @@ function TimeGridScrollViewInner({
|
|||||||
)}
|
)}
|
||||||
continuesBefore={segment.continuesBefore}
|
continuesBefore={segment.continuesBefore}
|
||||||
continuesAfter={segment.continuesAfter}
|
continuesAfter={segment.continuesAfter}
|
||||||
|
accessibilityLabel={eventAccessibilityLabel(segment.event, { t, locale, timeFormat, calendars, currentUserEmails })}
|
||||||
onPress={() => onSelectEvent?.(segment.event)}
|
onPress={() => onSelectEvent?.(segment.event)}
|
||||||
style={{
|
style={{
|
||||||
left: segment.startIndex * colWidth + 1,
|
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. */
|
/** Hour of the "New event" block in this column, or -1. */
|
||||||
draftHour: number;
|
draftHour: number;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useCalendarLocale();
|
const { locale, t } = useCalendarLocale();
|
||||||
const handleLongPress = React.useCallback(
|
const handleLongPress = React.useCallback(
|
||||||
(e: GestureResponderEvent) => {
|
(e: GestureResponderEvent) => {
|
||||||
onLongPressAt?.(day, hourAtOffset(e.nativeEvent.locationY, HOUR_HEIGHT));
|
onLongPressAt?.(day, hourAtOffset(e.nativeEvent.locationY, HOUR_HEIGHT));
|
||||||
@@ -630,6 +632,7 @@ const DayColumn = React.memo(function DayColumn({
|
|||||||
ringColor={colors.background}
|
ringColor={colors.background}
|
||||||
continuesBefore={continuesBefore}
|
continuesBefore={continuesBefore}
|
||||||
continuesAfter={continuesAfter}
|
continuesAfter={continuesAfter}
|
||||||
|
accessibilityLabel={eventAccessibilityLabel(event, { t, locale, timeFormat, calendars, currentUserEmails })}
|
||||||
onPress={() => onSelectEvent?.(event)}
|
onPress={() => onSelectEvent?.(event)}
|
||||||
style={{ top, height, left: `${column * widthPct}%`, width: `${widthPct}%` }}
|
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 { spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
import { useColors } from '../../theme/colors';
|
import { useColors } from '../../theme/colors';
|
||||||
import { useCalendarLocale } from '../../lib/calendar-locale';
|
import { useCalendarLocale } from '../../lib/calendar-locale';
|
||||||
|
import { eventAccessibilityLabel } from '../../lib/calendar-a11y';
|
||||||
import {
|
import {
|
||||||
buildEventDayIndex,
|
buildEventDayIndex,
|
||||||
buildTimedFullDayWeekSegments,
|
buildTimedFullDayWeekSegments,
|
||||||
@@ -202,6 +203,7 @@ function WeekViewInner({
|
|||||||
)}
|
)}
|
||||||
continuesBefore={segment.continuesBefore}
|
continuesBefore={segment.continuesBefore}
|
||||||
continuesAfter={segment.continuesAfter}
|
continuesAfter={segment.continuesAfter}
|
||||||
|
accessibilityLabel={eventAccessibilityLabel(segment.event, { t, locale, timeFormat, calendars, currentUserEmails })}
|
||||||
onPress={() => onSelectEvent?.(segment.event)}
|
onPress={() => onSelectEvent?.(segment.event)}
|
||||||
style={{
|
style={{
|
||||||
left: `${leftPct}%`,
|
left: `${leftPct}%`,
|
||||||
@@ -277,6 +279,7 @@ function WeekViewInner({
|
|||||||
ringColor={c.background}
|
ringColor={c.background}
|
||||||
continuesBefore={continuesBefore}
|
continuesBefore={continuesBefore}
|
||||||
continuesAfter={continuesAfter}
|
continuesAfter={continuesAfter}
|
||||||
|
accessibilityLabel={eventAccessibilityLabel(event, { t, locale, timeFormat, calendars, currentUserEmails })}
|
||||||
onPress={() => onSelectEvent?.(event)}
|
onPress={() => onSelectEvent?.(event)}
|
||||||
style={{ top, height, left: `${leftPct}%`, width: `${widthPct}%` }}
|
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 { useNavigation } from '@react-navigation/native';
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import {
|
import {
|
||||||
X, Users, Tag, BookUser, Inbox, ChevronDown, ChevronRight, Plus, Share2, Check,
|
X, Users, Tag, BookUser, Inbox, ChevronDown, ChevronRight, Plus, Share2, Check, Trash2,
|
||||||
} from '../icons';
|
} from '../icons';
|
||||||
|
import { toast } from '../../stores/toast-store';
|
||||||
import type { RootStackParamList } from '../../navigation/types';
|
import type { RootStackParamList } from '../../navigation/types';
|
||||||
import type { AddressBook } from '../../api/types';
|
import type { AddressBook } from '../../api/types';
|
||||||
import type { ContactCategory } from '../../stores/contacts-store';
|
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 contacts = useContactsStore((s) => s.contacts);
|
||||||
const addressBooks = useContactsStore((s) => s.addressBooks);
|
const addressBooks = useContactsStore((s) => s.addressBooks);
|
||||||
const renameKeyword = useContactsStore((s) => s.renameKeyword);
|
const renameKeyword = useContactsStore((s) => s.renameKeyword);
|
||||||
|
const removeKeyword = useContactsStore((s) => s.removeKeyword);
|
||||||
|
const addKeyword = useContactsStore((s) => s.addKeyword);
|
||||||
|
|
||||||
const totalCount = React.useMemo(
|
const totalCount = React.useMemo(
|
||||||
() => contacts.filter((c) => !isGroup(c)).length,
|
() => 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 }) => (
|
const renderBook = (book: AddressBook & { count: number }) => (
|
||||||
<CategoryRow
|
<CategoryRow
|
||||||
key={book.id}
|
key={book.id}
|
||||||
@@ -271,6 +299,15 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
|
|||||||
>
|
>
|
||||||
<Check size={16} color={c.primary} />
|
<Check size={16} color={c.primary} />
|
||||||
</Pressable>
|
</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
|
<Pressable
|
||||||
onPress={() => setRenaming(null)}
|
onPress={() => setRenaming(null)}
|
||||||
hitSlop={6}
|
hitSlop={6}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { View, Text, StyleSheet, Pressable, ActivityIndicator } from 'react-nati
|
|||||||
import { openExternalUrl } from '../../lib/open-url';
|
import { openExternalUrl } from '../../lib/open-url';
|
||||||
import {
|
import {
|
||||||
CalendarPlus, Check, HelpCircle, X, MapPin, Video, Clock, CalendarDays, AlertTriangle,
|
CalendarPlus, Check, HelpCircle, X, MapPin, Video, Clock, CalendarDays, AlertTriangle,
|
||||||
ShieldCheck, ShieldAlert, ChevronDown,
|
ShieldCheck, ShieldAlert, ChevronDown, Trash2,
|
||||||
} from '../icons';
|
} from '../icons';
|
||||||
import { format, isSameDay } from 'date-fns';
|
import { format, isSameDay } from 'date-fns';
|
||||||
import type { Calendar, Email, CalendarEvent } from '../../api/types';
|
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 { getCalendarColor, timePattern } from '../../lib/calendar-utils';
|
||||||
import { getDateFnsLocale } from '../../lib/calendar-locale';
|
import { getDateFnsLocale } from '../../lib/calendar-locale';
|
||||||
import { useCalendarSubscriptionsStore } from '../../stores/calendar-subscriptions-store';
|
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 BannerState = 'loading' | 'parsed' | 'done' | 'error';
|
||||||
type RsvpStatus = 'accepted' | 'tentative' | 'declined';
|
type RsvpStatus = 'accepted' | 'tentative' | 'declined';
|
||||||
@@ -76,6 +83,8 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
|
|||||||
const subscriptions = useCalendarSubscriptionsStore((s) => s.subscriptions);
|
const subscriptions = useCalendarSubscriptionsStore((s) => s.subscriptions);
|
||||||
const importEvents = useCalendarStore((s) => s.importEvents);
|
const importEvents = useCalendarStore((s) => s.importEvents);
|
||||||
const rsvpEvent = useCalendarStore((s) => s.rsvpEvent);
|
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]);
|
const attachment = React.useMemo(() => findCalendarAttachment(email), [email]);
|
||||||
// Login address + identities + aliases, so invitations addressed to an
|
// Login address + identities + aliases, so invitations addressed to an
|
||||||
// alias still show the RSVP buttons. Only an invitation looks the aliases up.
|
// 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 [busy, setBusy] = React.useState(false);
|
||||||
const [calendarId, setCalendarId] = React.useState<string | null>(null);
|
const [calendarId, setCalendarId] = React.useState<string | null>(null);
|
||||||
const [pickerOpen, setPickerOpen] = React.useState(false);
|
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(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -139,6 +151,17 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [invitationKey, enabled, jmapAccountId]);
|
}, [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,
|
// 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
|
// an iCal subscription (the next feed sync would delete the event) or a
|
||||||
// read-only one. The user can still pick another writable calendar.
|
// 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;
|
const trustColor = trust?.level === 'warning' ? c.error : trust?.level === 'caution' ? c.warning : c.success;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -270,6 +332,11 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
|
|||||||
{method === 'cancel' && (
|
{method === 'cancel' && (
|
||||||
<Text style={styles.cancelled}>{t('calendar.invitation.cancelled', 'This event was cancelled')}</Text>
|
<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' && (
|
{method === 'reply' && (
|
||||||
<Text style={styles.subtitle}>{t('calendar.invitation.reply', 'A participant replied to this invitation')}</Text>
|
<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} />}
|
icon={<X size={15} color={currentStatus === 'declined' ? c.textInverse : c.error} />}
|
||||||
disabled={busy} onPress={() => ensureImportedAndRsvp('declined')} c={c} styles={styles} />
|
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
|
<Pressable
|
||||||
style={[styles.importBtn, (busy || !targetCalendar) && { opacity: 0.5 }]}
|
style={[styles.importBtn, (busy || !targetCalendar) && { opacity: 0.5 }]}
|
||||||
onPress={() => { void handleImport(); }}
|
onPress={() => { void handleImport(); }}
|
||||||
@@ -521,6 +602,19 @@ function makeStyles(c: ThemePalette) {
|
|||||||
backgroundColor: c.primary,
|
backgroundColor: c.primary,
|
||||||
},
|
},
|
||||||
importBtnText: { ...typography.bodyMedium, color: c.primaryForeground },
|
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 },
|
warnRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs, marginTop: spacing.xs },
|
||||||
warnText: { ...typography.caption, color: c.warning },
|
warnText: { ...typography.caption, color: c.warning },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ import type {
|
|||||||
FilterActionType,
|
FilterActionType,
|
||||||
} from '../../lib/sieve/types';
|
} from '../../lib/sieve/types';
|
||||||
import { SafeAreaModal } from '../SafeAreaModal';
|
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';
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
|
|
||||||
const ALL_FIELDS: FilterConditionField[] = ['from', 'to', 'cc', 'subject', 'header', 'size', 'body', 'attachment', 'all'];
|
const ALL_FIELDS: FilterConditionField[] = ['from', 'to', 'cc', 'subject', 'header', 'size', 'body', 'attachment', 'all'];
|
||||||
@@ -72,12 +74,19 @@ interface FilterRuleModalProps {
|
|||||||
mailboxes: Mailbox[];
|
mailboxes: Mailbox[];
|
||||||
/** The server can confine a rule to a period (Sieve "date" and "relational"). */
|
/** The server can confine a rule to a period (Sieve "date" and "relational"). */
|
||||||
periodsSupported?: boolean;
|
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;
|
onSave: (rule: FilterRule) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FilterRuleModal({
|
export function FilterRuleModal({
|
||||||
visible, rule: editedRule, initialRule, mailboxes, periodsSupported = false, onSave, onClose,
|
visible, rule: editedRule, initialRule, mailboxes, periodsSupported = false, forwardContext, onSave, onClose,
|
||||||
}: FilterRuleModalProps) {
|
}: FilterRuleModalProps) {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = useMemo(() => makeStyles(c), [c]);
|
const styles = useMemo(() => makeStyles(c), [c]);
|
||||||
@@ -120,6 +129,25 @@ export function FilterRuleModal({
|
|||||||
|
|
||||||
const mailboxTargets = useMemo(() => buildMailboxTargets(mailboxes), [mailboxes]);
|
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(
|
const fieldOptions = useMemo(
|
||||||
() => ALL_FIELDS.map((f) => ({ value: f, label: t(`settings.filters.condition_fields.${f}`, f) })),
|
() => ALL_FIELDS.map((f) => ({ value: f, label: t(`settings.filters.condition_fields.${f}`, f) })),
|
||||||
[t],
|
[t],
|
||||||
@@ -407,7 +435,7 @@ export function FilterRuleModal({
|
|||||||
<Select
|
<Select
|
||||||
value={action.type}
|
value={action.type}
|
||||||
onChange={(v) => updateAction(index, { type: v as FilterActionType })}
|
onChange={(v) => updateAction(index, { type: v as FilterActionType })}
|
||||||
options={actionTypeOptions}
|
options={actionOptionsFor(action)}
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -489,6 +517,16 @@ export function FilterRuleModal({
|
|||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</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
|
<Pressable
|
||||||
onPress={() => setActions((prev) => [...prev, makeEmptyAction()])}
|
onPress={() => setActions((prev) => [...prev, makeEmptyAction()])}
|
||||||
style={styles.addRow}
|
style={styles.addRow}
|
||||||
@@ -645,6 +683,7 @@ function makeStyles(c: ThemePalette) {
|
|||||||
},
|
},
|
||||||
optionLabel: { ...typography.body, color: c.text, flex: 1 },
|
optionLabel: { ...typography.body, color: c.text, flex: 1 },
|
||||||
hint: { ...typography.caption, color: c.mutedForeground },
|
hint: { ...typography.caption, color: c.mutedForeground },
|
||||||
|
forwardWarning: { ...typography.caption, color: c.warning, marginTop: spacing.sm },
|
||||||
|
|
||||||
footer: {
|
footer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { MoveSheet } from '../MoveSheet';
|
|||||||
import { FilterRuleModal } from './FilterRuleModal';
|
import { FilterRuleModal } from './FilterRuleModal';
|
||||||
import type { Email, Mailbox } from '../../api/types';
|
import type { Email, Mailbox } from '../../api/types';
|
||||||
import { getSieveAccountId, getSieveCapabilities } from '../../api/sieve';
|
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 { jmapClient } from '../../api/jmap-client';
|
||||||
import { servedLoginOf } from '../../lib/draft-login';
|
import { servedLoginOf } from '../../lib/draft-login';
|
||||||
import { supportsPeriods } from '../../lib/sieve/period';
|
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 junk = React.useMemo(() => findJunkMailbox(mailboxes), [mailboxes]);
|
||||||
const periodsSupported = supportsPeriods(getSieveCapabilities()?.sieveExtensions);
|
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 [sender] = subject.senders;
|
||||||
const senderLabel = subject.senders.length === 1
|
const senderLabel = subject.senders.length === 1
|
||||||
? (sender.name || sender.email)
|
? (sender.name || sender.email)
|
||||||
@@ -248,6 +269,10 @@ export function QuickRuleSheet({ visible, email, mailboxes, onClose }: QuickRule
|
|||||||
initialRule={step.kind === 'editor' ? step.rule : undefined}
|
initialRule={step.kind === 'editor' ? step.rule : undefined}
|
||||||
mailboxes={mailboxes}
|
mailboxes={mailboxes}
|
||||||
periodsSupported={periodsSupported}
|
periodsSupported={periodsSupported}
|
||||||
|
forwardContext={{
|
||||||
|
maxRedirects: getSieveCapabilities(openedIn.current.accountId ?? undefined)?.maxNumberRedirects,
|
||||||
|
...forwards,
|
||||||
|
}}
|
||||||
onSave={(rule) => void saveEditorRule(rule)}
|
onSave={(rule) => void saveEditorRule(rule)}
|
||||||
onClose={finish}
|
onClose={finish}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import Button from '../Button';
|
|||||||
import { useLocaleStore } from '../../stores/locale-store';
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
import { SafeAreaModal } from '../SafeAreaModal';
|
import { SafeAreaModal } from '../SafeAreaModal';
|
||||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
|
import { seedsOnRender } from '../../lib/sieve/editor-seed';
|
||||||
|
|
||||||
interface SieveEditorSheetProps {
|
interface SieveEditorSheetProps {
|
||||||
visible: boolean;
|
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 [validationResult, setValidationResult] = useState<{ isValid: boolean; errors?: string[] } | null>(null);
|
||||||
const [showSaveWarning, setShowSaveWarning] = useState(false);
|
const [showSaveWarning, setShowSaveWarning] = useState(false);
|
||||||
|
|
||||||
// Re-seed the editor whenever it's (re)opened with fresh content. Keying the
|
// Seed the editor when it opens, with the script as it is then. A new
|
||||||
// editor on `content` while closed avoids a stale draft on the next open.
|
// `content` while it is open (a store refresh) must not wipe what was
|
||||||
|
// typed (#1162).
|
||||||
|
const wasVisible = React.useRef(false);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (visible) {
|
if (seedsOnRender(wasVisible.current, visible)) {
|
||||||
setScript(content);
|
setScript(content);
|
||||||
setValidationResult(null);
|
setValidationResult(null);
|
||||||
setShowSaveWarning(false);
|
setShowSaveWarning(false);
|
||||||
}
|
}
|
||||||
|
wasVisible.current = visible;
|
||||||
}, [visible, content]);
|
}, [visible, content]);
|
||||||
|
|
||||||
const lineCount = script.split('\n').length;
|
const lineCount = script.split('\n').length;
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ import {
|
|||||||
parseLocalInput,
|
parseLocalInput,
|
||||||
utcIsoToLocalInput,
|
utcIsoToLocalInput,
|
||||||
} from '../../lib/vacation-dates';
|
} 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
|
// 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;
|
// 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,
|
// 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.
|
// which another device may have changed since this one loaded.
|
||||||
const forwardChanged = forwardSettings !== undefined && !sameForward(forwardSettings, store.forward);
|
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 fromParsed = parseLocalInput(fromDate);
|
||||||
const toParsed = parseLocalInput(toDate);
|
const toParsed = parseLocalInput(toDate);
|
||||||
@@ -137,6 +145,22 @@ export function VacationSettings() {
|
|||||||
return fromParsed < todayStart;
|
return fromParsed < todayStart;
|
||||||
}, [fromParsed]);
|
}, [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[] = [];
|
const warnings: string[] = [];
|
||||||
if (endBeforeStart) warnings.push(t('settings.vacation.warnings.end_before_start', 'End date must be after start date'));
|
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'));
|
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 ||
|
forwardChanged ||
|
||||||
audienceChanged;
|
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 () => {
|
const handleSave = useCallback(async () => {
|
||||||
// Read the live editor DOM rather than trusting onChange state (issue #9).
|
// 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')}
|
placeholder={t('settings.vacation.message.subject_placeholder', 'Out of Office')}
|
||||||
placeholderTextColor={c.mutedForeground}
|
placeholderTextColor={c.mutedForeground}
|
||||||
accessibilityLabel={t('settings.vacation.message.subject_label', 'Subject')}
|
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>
|
||||||
<View style={styles.bodyBlock}>
|
<View style={styles.bodyBlock}>
|
||||||
<Text style={styles.bodyLabel}>{t('settings.vacation.message.body_label', 'Message Body')}</Text>
|
<Text style={styles.bodyLabel}>{t('settings.vacation.message.body_label', 'Message Body')}</Text>
|
||||||
@@ -353,11 +379,12 @@ export function VacationSettings() {
|
|||||||
onChangeText={setBody}
|
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.')}
|
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}
|
placeholderTextColor={c.mutedForeground}
|
||||||
style={styles.bodyInput}
|
style={[styles.bodyInput, oversize.body && styles.inputInvalid]}
|
||||||
multiline
|
multiline
|
||||||
numberOfLines={6}
|
numberOfLines={6}
|
||||||
textAlignVertical="top"
|
textAlignVertical="top"
|
||||||
/>
|
/>
|
||||||
|
{bodyTooLong && <Text style={styles.fieldError}>{bodyTooLong}</Text>}
|
||||||
</View>
|
</View>
|
||||||
<SettingItem
|
<SettingItem
|
||||||
label={t('settings.vacation.message.html_label', 'Formatted message (HTML)')}
|
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')}
|
accessibilityLabel={t('settings.vacation.forward.keep_label', 'Keep in inbox')}
|
||||||
/>
|
/>
|
||||||
</SettingItem>
|
</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>
|
</SettingsSection>
|
||||||
@@ -570,6 +602,8 @@ function makeStyles(c: ThemePalette) {
|
|||||||
...typography.body,
|
...typography.body,
|
||||||
},
|
},
|
||||||
inputInvalid: { borderColor: c.error },
|
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: {
|
subjectInput: {
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: 6,
|
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'));
|
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
|
export const BIRTHDAY_CALENDAR_COLOR = '#eab308'; // yellow
|
||||||
|
|
||||||
// A read-only virtual calendar holding generated birthday events.
|
// A read-only virtual calendar holding generated birthday events.
|
||||||
export function createBirthdayCalendar(name = 'Birthdays'): Calendar {
|
export function createBirthdayCalendar(name = 'Birthdays', color?: string): Calendar {
|
||||||
return {
|
return {
|
||||||
id: BIRTHDAY_CALENDAR_ID,
|
id: BIRTHDAY_CALENDAR_ID,
|
||||||
name,
|
name,
|
||||||
color: BIRTHDAY_CALENDAR_COLOR,
|
color: color || BIRTHDAY_CALENDAR_COLOR,
|
||||||
sortOrder: 999,
|
sortOrder: 999,
|
||||||
isSubscribed: true,
|
isSubscribed: true,
|
||||||
isVisible: 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
|
* via the `colorIsLocalOverride` flag (see getEventColor). Personal calendars
|
||||||
* are passed through untouched.
|
* 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(
|
export function applySharedCalendarColors(
|
||||||
calendars: Calendar[],
|
calendars: Calendar[],
|
||||||
overrides: Record<string, string>,
|
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()));
|
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 {
|
function occurrenceDateKey(master: CalendarEvent, date: Date): string {
|
||||||
return master.showWithoutTime ? format(date, 'yyyy-MM-dd') : date.toISOString();
|
return master.showWithoutTime ? format(date, 'yyyy-MM-dd') : date.toISOString();
|
||||||
}
|
}
|
||||||
@@ -106,8 +159,10 @@ function expandEvent(
|
|||||||
rangeStart: Date,
|
rangeStart: Date,
|
||||||
rangeEnd: Date,
|
rangeEnd: Date,
|
||||||
): CalendarEvent[] {
|
): CalendarEvent[] {
|
||||||
const eventStart = parseISO(master.start);
|
const eventStart = master.showWithoutTime ? parseISO(master.start) : dateForWall(master.start.slice(0, 19));
|
||||||
if (isNaN(eventStart.getTime())) return [];
|
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 rules = master.recurrenceRules || [];
|
||||||
const overrides = master.recurrenceOverrides || {};
|
const overrides = master.recurrenceOverrides || {};
|
||||||
@@ -120,13 +175,13 @@ function expandEvent(
|
|||||||
for (const exRule of master.excludedRecurrenceRules || []) {
|
for (const exRule of master.excludedRecurrenceRules || []) {
|
||||||
// includeStartDate=false: "the series start is always an occurrence"
|
// includeStartDate=false: "the series start is always an occurrence"
|
||||||
// applies to recurrence rules, not to exclusion rules.
|
// 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));
|
excludedDates.add(occurrenceDateKey(master, date));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const rule of rules) {
|
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) {
|
for (const date of dates) {
|
||||||
const dateKey = occurrenceDateKey(master, date);
|
const dateKey = occurrenceDateKey(master, date);
|
||||||
|
|
||||||
@@ -136,7 +191,7 @@ function expandEvent(
|
|||||||
|
|
||||||
const recurrenceId = master.showWithoutTime
|
const recurrenceId = master.showWithoutTime
|
||||||
? format(date, "yyyy-MM-dd'T'00:00:00")
|
? 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
|
const override = overrides[recurrenceId] as
|
||||||
| (Partial<CalendarEvent> & { excluded?: boolean })
|
| (Partial<CalendarEvent> & { excluded?: boolean })
|
||||||
@@ -153,7 +208,7 @@ function expandEvent(
|
|||||||
for (const [recurrenceId, rawOverride] of Object.entries(overrides)) {
|
for (const [recurrenceId, rawOverride] of Object.entries(overrides)) {
|
||||||
const override = rawOverride as Partial<CalendarEvent> & { excluded?: boolean };
|
const override = rawOverride as Partial<CalendarEvent> & { excluded?: boolean };
|
||||||
if (override.excluded) continue;
|
if (override.excluded) continue;
|
||||||
const overrideDate = parseISO(recurrenceId);
|
const overrideDate = master.showWithoutTime ? parseISO(recurrenceId) : dateForWall(recurrenceId.slice(0, 19));
|
||||||
if (isNaN(overrideDate.getTime())) continue;
|
if (isNaN(overrideDate.getTime())) continue;
|
||||||
|
|
||||||
// Overrides take precedence over excludedRecurrenceRules (they re-add
|
// 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 {
|
export function zonedWallTimeToUtc(wall: Date, timeZone: string): Date | null {
|
||||||
const formatter = getTzFormatter(timeZone);
|
const formatter = getTzFormatter(timeZone);
|
||||||
if (!formatter) return null;
|
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;
|
let guess = wallMs;
|
||||||
for (let i = 0; i < 2; i++) {
|
for (let i = 0; i < 2; i++) {
|
||||||
guess = wallMs - (wallClockAsUtcTimestamp(formatter, guess) - guess);
|
guess = wallMs - (wallClockAsUtcTimestamp(formatter, guess) - guess);
|
||||||
}
|
}
|
||||||
return new Date(guess);
|
return guess;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createOccurrence(
|
function createOccurrence(
|
||||||
@@ -304,7 +363,7 @@ function createOccurrence(
|
|||||||
): CalendarEvent {
|
): CalendarEvent {
|
||||||
const startStr = master.showWithoutTime
|
const startStr = master.showWithoutTime
|
||||||
? format(date, "yyyy-MM-dd'T'00:00:00")
|
? 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
|
// 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
|
// 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
|
// 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
|
// their wall time. Reusing the master's fixed UTC offset (the fallback
|
||||||
// below) would shift them by the DST delta.
|
// 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) {
|
if (zoned) {
|
||||||
utcStart = zoned.toISOString();
|
utcStart = zoned.toISOString();
|
||||||
if (durationMs !== null) {
|
if (durationMs !== null) {
|
||||||
@@ -430,6 +492,8 @@ function generateDates(
|
|||||||
rangeEnd: Date,
|
rangeEnd: Date,
|
||||||
includeStartDate = true,
|
includeStartDate = true,
|
||||||
maxOccurrences = 500,
|
maxOccurrences = 500,
|
||||||
|
// The series' time zone: daily occurrences are built on its clock.
|
||||||
|
timeZone?: string,
|
||||||
): Date[] {
|
): Date[] {
|
||||||
const rule = addImplicitByX(rawRule, eventStart);
|
const rule = addImplicitByX(rawRule, eventStart);
|
||||||
const dates: Date[] = [];
|
const dates: Date[] = [];
|
||||||
@@ -465,7 +529,7 @@ function generateDates(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidates = generateCandidatesForPeriod(current, rule, eventStart);
|
const candidates = generateCandidatesForPeriod(current, rule, eventStart, timeZone);
|
||||||
|
|
||||||
const filtered = rule.bySetPosition?.length
|
const filtered = rule.bySetPosition?.length
|
||||||
? applyBySetPosition(candidates, rule.bySetPosition)
|
? applyBySetPosition(candidates, rule.bySetPosition)
|
||||||
@@ -509,6 +573,7 @@ function generateCandidatesForPeriod(
|
|||||||
periodStart: Date,
|
periodStart: Date,
|
||||||
rule: RecurrenceRule,
|
rule: RecurrenceRule,
|
||||||
eventStart: Date,
|
eventStart: Date,
|
||||||
|
timeZone?: string,
|
||||||
): Date[] {
|
): Date[] {
|
||||||
const freq = rule.frequency;
|
const freq = rule.frequency;
|
||||||
let candidates: Date[];
|
let candidates: Date[];
|
||||||
@@ -523,17 +588,27 @@ function generateCandidatesForPeriod(
|
|||||||
case 'weekly':
|
case 'weekly':
|
||||||
candidates = expandWeekly(periodStart, rule, eventStart);
|
candidates = expandWeekly(periodStart, rule, eventStart);
|
||||||
break;
|
break;
|
||||||
case 'daily':
|
case 'daily': {
|
||||||
// The start's wall-clock time on this day. The period anchor is
|
// 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
|
// 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
|
// (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
|
// it made every later day fail the implicit byHour and the series
|
||||||
// stopped at the gap. Mirrors the webmail (34fb9d247).
|
// 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(),
|
periodStart.getFullYear(), periodStart.getMonth(), periodStart.getDate(),
|
||||||
eventStart.getHours(), eventStart.getMinutes(), eventStart.getSeconds(), eventStart.getMilliseconds(),
|
eventStart.getHours(), eventStart.getMinutes(), eventStart.getSeconds(), eventStart.getMilliseconds(),
|
||||||
)];
|
)];
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case 'hourly':
|
case 'hourly':
|
||||||
case 'minutely':
|
case 'minutely':
|
||||||
case 'secondly':
|
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,
|
getEffectiveTimeZone,
|
||||||
isValidTimeZone,
|
isValidTimeZone,
|
||||||
} from './calendar-timezone';
|
} from './calendar-timezone';
|
||||||
|
import { editorFormSnapshot } from './calendar-editor-discard';
|
||||||
|
|
||||||
export type PriorityLevel = 'none' | 'high' | 'medium' | 'low';
|
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 };
|
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(
|
export function taskEditorFromTask(
|
||||||
task: Pick<CalendarEvent, 'id' | 'title' | 'description' | 'due' | 'timeZone' | 'showWithoutTime' | 'priority' | 'calendarIds'>,
|
task: Pick<CalendarEvent, 'id' | 'title' | 'description' | 'due' | 'timeZone' | 'showWithoutTime' | 'priority' | 'calendarIds'>,
|
||||||
fallbackCalendarId: string,
|
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 { useColors } from '../theme/colors';
|
||||||
import { CalendarAccountChangedError, calendarOperationScope, useCalendarStore } from '../stores/calendar-store';
|
import { CalendarAccountChangedError, calendarOperationScope, useCalendarStore } from '../stores/calendar-store';
|
||||||
import { useSettingsStore } from '../stores/settings-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 { MonthView } from '../components/calendar/MonthView';
|
||||||
import { MonthScrollView } from '../components/calendar/MonthScrollView';
|
import { MonthScrollView } from '../components/calendar/MonthScrollView';
|
||||||
import { WeekView } from '../components/calendar/WeekView';
|
import { WeekView } from '../components/calendar/WeekView';
|
||||||
@@ -87,7 +89,7 @@ import {
|
|||||||
getEventStartDate,
|
getEventStartDate,
|
||||||
getPrimaryCalendarId,
|
getPrimaryCalendarId,
|
||||||
getTaskDueDate,
|
getTaskDueDate,
|
||||||
pickUnusedCalendarColor,
|
autoAssignSharedCalendarColors,
|
||||||
sharedCalendarColorKey,
|
sharedCalendarColorKey,
|
||||||
reopenedEdit,
|
reopenedEdit,
|
||||||
} from '../lib/calendar-utils';
|
} from '../lib/calendar-utils';
|
||||||
@@ -165,6 +167,7 @@ export default function CalendarScreen() {
|
|||||||
const calendarFreeScroll = useSettingsStore((s) => s.calendarFreeScroll);
|
const calendarFreeScroll = useSettingsStore((s) => s.calendarFreeScroll);
|
||||||
const calendarTimeFormat = useSettingsStore((s) => s.calendarTimeFormat);
|
const calendarTimeFormat = useSettingsStore((s) => s.calendarTimeFormat);
|
||||||
const showBirthdayCalendar = useSettingsStore((s) => s.showBirthdayCalendar);
|
const showBirthdayCalendar = useSettingsStore((s) => s.showBirthdayCalendar);
|
||||||
|
const birthdayCalendarColor = useSettingsStore((s) => s.birthdayCalendarColor);
|
||||||
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
|
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
|
||||||
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
|
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
|
||||||
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
|
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
|
||||||
@@ -308,34 +311,18 @@ export default function CalendarScreen() {
|
|||||||
[storeCalendars, sharedCalendarColors],
|
[storeCalendars, sharedCalendarColors],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Auto-assign a random, not-yet-used palette color to any freshly shared
|
// Auto-assign a not-yet-used palette color to any freshly shared calendar
|
||||||
// calendar so multiple shared calendars don't collide on one color. Runs
|
// without a server colour, so multiple shared calendars don't collide on
|
||||||
// once per calendar (guarded by the presence of an existing key), and the
|
// one color (#1414: one with a server colour keeps it). Runs once per
|
||||||
// user can still overwrite it from the sidebar.
|
// calendar, and the user can still overwrite it from the sidebar.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const missing = storeCalendars.filter(
|
const assigned = autoAssignSharedCalendarColors(storeCalendars, sharedCalendarColors);
|
||||||
(cal) => cal.isShared && !sharedCalendarColors[sharedCalendarColorKey(cal)],
|
for (const [key, color] of Object.entries(assigned)) setSharedCalendarColor(key, color);
|
||||||
);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}, [storeCalendars, sharedCalendarColors, setSharedCalendarColor]);
|
}, [storeCalendars, sharedCalendarColors, setSharedCalendarColor]);
|
||||||
|
|
||||||
const allCalendars = React.useMemo(
|
const allCalendars = React.useMemo(
|
||||||
() => (showBirthdayCalendar ? [...displayCalendars, createBirthdayCalendar()] : displayCalendars),
|
() => (showBirthdayCalendar ? [...displayCalendars, createBirthdayCalendar(undefined, birthdayCalendarColor)] : displayCalendars),
|
||||||
[displayCalendars, showBirthdayCalendar],
|
[displayCalendars, showBirthdayCalendar, birthdayCalendarColor],
|
||||||
);
|
);
|
||||||
// Tasks with a due date are overlaid on the grid as chips (webmail's
|
// Tasks with a due date are overlaid on the grid as chips (webmail's
|
||||||
// showTasksOnCalendar); tapping one opens the tasks sheet on that task.
|
// showTasksOnCalendar); tapping one opens the tasks sheet on that task.
|
||||||
@@ -834,6 +821,22 @@ export default function CalendarScreen() {
|
|||||||
[deleteTask, t],
|
[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
|
// Clone the event one day later and open it in the editor (webmail's
|
||||||
// handleDuplicateFromDetail).
|
// handleDuplicateFromDetail).
|
||||||
const handleDuplicateFromDetail = React.useCallback(
|
const handleDuplicateFromDetail = React.useCallback(
|
||||||
@@ -900,6 +903,10 @@ export default function CalendarScreen() {
|
|||||||
|
|
||||||
const handleSetCalendarColor = React.useCallback(
|
const handleSetCalendarColor = React.useCallback(
|
||||||
(cal: Calendar, color: string) => {
|
(cal: Calendar, color: string) => {
|
||||||
|
if (cal.id === BIRTHDAY_CALENDAR_ID) {
|
||||||
|
useSettingsStore.getState().updateSetting('birthdayCalendarColor', color);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (cal.isShared) {
|
if (cal.isShared) {
|
||||||
// Per-viewer recolor (#345): the owner's colour is left alone.
|
// Per-viewer recolor (#345): the owner's colour is left alone.
|
||||||
setSharedCalendarColor(sharedCalendarColorKey(cal), color);
|
setSharedCalendarColor(sharedCalendarColorKey(cal), color);
|
||||||
@@ -1192,6 +1199,7 @@ export default function CalendarScreen() {
|
|||||||
onDuplicate={(ev) => { if (!isReadOnlyEvent(ev)) void handleDuplicateFromDetail(ev); }}
|
onDuplicate={(ev) => { if (!isReadOnlyEvent(ev)) void handleDuplicateFromDetail(ev); }}
|
||||||
onExport={handleExportFromDetail}
|
onExport={handleExportFromDetail}
|
||||||
onCopyLink={handleCopyLink}
|
onCopyLink={handleCopyLink}
|
||||||
|
onSaveNote={handleSaveNoteFromDetail}
|
||||||
onRsvp={async (ev, participantId, status) => {
|
onRsvp={async (ev, participantId, status) => {
|
||||||
// An answer on one occurrence of a series asks whether it covers
|
// An answer on one occurrence of a series asks whether it covers
|
||||||
// just that occurrence or the whole series (webmail #1086).
|
// 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 { listBackStep } from '../lib/list-back';
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import {
|
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';
|
} 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 { RootStackParamList } from '../navigation/types';
|
||||||
import type { ContactCard } from '../api/types';
|
import type { ContactCard } from '../api/types';
|
||||||
import {
|
import {
|
||||||
@@ -137,6 +141,9 @@ export default function ContactsScreen({ onOpenContact, activeContactId = null }
|
|||||||
const sortByLastName = useSettingsStore((s) => s.sortContactsByLastName);
|
const sortByLastName = useSettingsStore((s) => s.sortContactsByLastName);
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = React.useState('');
|
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
|
// 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.
|
// category: it is the server's list, not the user's contacts.
|
||||||
const [showDirectory, setShowDirectory] = React.useState(false);
|
const [showDirectory, setShowDirectory] = React.useState(false);
|
||||||
@@ -201,9 +208,11 @@ export default function ContactsScreen({ onOpenContact, activeContactId = null }
|
|||||||
);
|
);
|
||||||
|
|
||||||
const visible = React.useMemo(() => {
|
const visible = React.useMemo(() => {
|
||||||
if (!searchQuery) return sorted;
|
// The filters sit under the search field and apply while it is open.
|
||||||
return sorted.filter((c) => matchesContactSearch(c, searchQuery));
|
const filtered = searchActive && activeFilterCount > 0 ? sorted.filter((c) => matchesContactFilters(c, filters)) : sorted;
|
||||||
}, [sorted, searchQuery]);
|
if (!searchQuery) return filtered;
|
||||||
|
return filtered.filter((c) => matchesContactSearch(c, searchQuery));
|
||||||
|
}, [sorted, searchQuery, filters, activeFilterCount, searchActive]);
|
||||||
|
|
||||||
const sections = React.useMemo<Section[]>(() => {
|
const sections = React.useMemo<Section[]>(() => {
|
||||||
if (groupByLetter) return groupContacts(visible, sortByLastName);
|
if (groupByLetter) return groupContacts(visible, sortByLastName);
|
||||||
@@ -538,8 +547,20 @@ export default function ContactsScreen({ onOpenContact, activeContactId = null }
|
|||||||
<X size={16} color={c.textMuted} />
|
<X size={16} color={c.textMuted} />
|
||||||
</Pressable>
|
</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>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
{searchActive && filtersOpen && (
|
||||||
|
<ContactFiltersPanel filters={filters} onChange={setFilters} onClose={() => setFiltersOpen(false)} />
|
||||||
|
)}
|
||||||
|
|
||||||
{!selectionMode && (
|
{!selectionMode && (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ function loadDocumentPicker(): DocumentPickerModule | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
import {
|
import {
|
||||||
copyFileNode, createFolder, deleteFileNodes, getAllFileNodesAcrossAccounts,
|
copyFileTree, createFolder, deleteFileNodes, getAllFileNodesAcrossAccounts,
|
||||||
getFileNameRules, getFileNodeDownloadUrl, getMaxSizeUpload, isFolder,
|
getFileNameRules, getFileNodeDownloadUrl, getMaxSizeUpload, isFolder,
|
||||||
isSymlink, listFolder, moveFileNode, renameFileNode, supportsSharing, uploadFileBatch,
|
isSymlink, listFolder, moveFileNode, renameFileNode, supportsSharing, uploadFileBatch,
|
||||||
} from '../api/files';
|
} from '../api/files';
|
||||||
@@ -661,13 +661,14 @@ export default function FilesScreen() {
|
|||||||
}, [renameTarget, renameValue, loadFiles, checkName, t]);
|
}, [renameTarget, renameValue, loadFiles, checkName, t]);
|
||||||
|
|
||||||
const duplicateFile = useCallback(async (row: FileRow) => {
|
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);
|
setBusyId(row.id);
|
||||||
try {
|
try {
|
||||||
const siblings = new Set(
|
const siblings = new Set(
|
||||||
allNodes.filter((n) => (n.parentId ?? null) === (row.parentId ?? null)).map((n) => n.name),
|
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');
|
await loadFiles('refresh');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Alert.alert(t('files.duplicate_error', 'Failed to duplicate'), fileErrorMessage(e, t));
|
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>
|
<Text style={styles.actionLabel}>{t('files.move_to', 'Move to…')}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
) : null}
|
||||||
{access.duplicate && isFile ? (
|
{access.duplicate && !isSymlink(target) ? (
|
||||||
<Pressable style={styles.action} onPress={() => onDuplicate(target)}>
|
<Pressable style={styles.action} onPress={() => onDuplicate(target)}>
|
||||||
<Copy size={18} color={c.text} />
|
<Copy size={18} color={c.text} />
|
||||||
<Text style={styles.actionLabel}>{t('files.duplicate', 'Duplicate')}</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', () => {
|
describe('deleteEvent', () => {
|
||||||
it('should remove event from list', async () => {
|
it('should remove event from list', async () => {
|
||||||
useCalendarStore.setState({
|
useCalendarStore.setState({
|
||||||
@@ -669,6 +712,22 @@ describe('calendar-store', () => {
|
|||||||
expect(useCalendarStore.getState().events[0].id).toBe('ev2');
|
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 () => {
|
it('should drop every expanded occurrence of a destroyed master and refetch', async () => {
|
||||||
useCalendarStore.setState({
|
useCalendarStore.setState({
|
||||||
calendars: [{ id: 'cal-1', name: 'Personal' } as any],
|
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', () => {
|
describe('autocomplete', () => {
|
||||||
it('merges contacts, groups and recent recipients without duplicates', async () => {
|
it('merges contacts, groups and recent recipients without duplicates', async () => {
|
||||||
useContactsStore.setState({
|
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 () => {
|
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.getSieveCapabilities.mockReturnValue({ ...WITH_INCLUDE, sieveExtensions: STALWART_EXTENSIONS });
|
||||||
api.getSieveScripts.mockResolvedValue([{ id: 'v1', name: 'vacation', blobId: 'bv', isActive: true }]);
|
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";');
|
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 = {
|
const NO_FILTERS = {
|
||||||
includesVacation: false, forward: null, forwardAvailable: false,
|
includesVacation: false, forward: null, forwardAvailable: false,
|
||||||
audience: null, audienceAvailable: false, notRunning: false,
|
audience: null, audienceAvailable: false, notRunning: false, otherForwards: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock('../filter-store', () => ({
|
vi.mock('../filter-store', () => ({
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ export interface CalendarState {
|
|||||||
changes: Partial<CalendarEvent> | Record<string, unknown>,
|
changes: Partial<CalendarEvent> | Record<string, unknown>,
|
||||||
options?: { sendSchedulingMessages?: boolean },
|
options?: { sendSchedulingMessages?: boolean },
|
||||||
) => Promise<void>;
|
) => 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
|
// Resolve an expanded occurrence (or a master) to its master event, read
|
||||||
// from the server: a copy the app holds may be older.
|
// from the server: a copy the app holds may be older.
|
||||||
getMasterEvent: (event: CalendarEvent) => Promise<CalendarEvent | null>;
|
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
|
// Deleting an occurrence removes just that one: through its synthetic
|
||||||
// id, or by excluding it on the base event it was expanded from.
|
// id, or by excluding it on the base event it was expanded from.
|
||||||
const scope = writeScope();
|
const scope = writeScope();
|
||||||
@@ -1218,7 +1218,8 @@ export const useCalendarStore = create<CalendarState>()(
|
|||||||
const { storeEvent, realId, accountId } = target;
|
const { storeEvent, realId, accountId } = target;
|
||||||
const touchesSeries = (!!storeEvent && isRecurringSeriesMember(storeEvent))
|
const touchesSeries = (!!storeEvent && isRecurringSeriesMember(storeEvent))
|
||||||
|| hasServerOccurrencesOf(get().events, realId, accountId);
|
|| hasServerOccurrencesOf(get().events, realId, accountId);
|
||||||
const schedule = hasSchedulingParticipants(storeEvent) ? true : undefined;
|
const schedule = options?.sendSchedulingMessages
|
||||||
|
?? (hasSchedulingParticipants(storeEvent) ? true : undefined);
|
||||||
if (target.isOccurrence && storeEvent) {
|
if (target.isOccurrence && storeEvent) {
|
||||||
await destroyOccurrence(storeEvent, realId, schedule, writeAccount(scope, accountId), scope);
|
await destroyOccurrence(storeEvent, realId, schedule, writeAccount(scope, accountId), scope);
|
||||||
assertInScope(scope);
|
assertInScope(scope);
|
||||||
@@ -1539,7 +1540,46 @@ export const useCalendarStore = create<CalendarState>()(
|
|||||||
const scope = writeScope();
|
const scope = writeScope();
|
||||||
const task = get().tasks.find((t) => t.id === id);
|
const task = get().tasks.find((t) => t.id === id);
|
||||||
const realId = task?.originalId || 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.
|
// Saved; the next account's tasks can carry the same ids.
|
||||||
if (!inScope(scope)) return;
|
if (!inScope(scope)) return;
|
||||||
set({ tasks: get().tasks.map((t) => (t.id === id ? { ...t, ...changes } : t)) });
|
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>;
|
removeContactKeyword: (id: string, keyword: string) => Promise<void>;
|
||||||
addKeywordToContacts: (ids: string[], keyword: string) => Promise<void>;
|
addKeywordToContacts: (ids: string[], keyword: string) => Promise<void>;
|
||||||
renameKeyword: (oldKeyword: string, newKeyword: 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>;
|
moveContactsToAddressBook: (ids: string[], addressBookId: string) => Promise<void>;
|
||||||
|
|
||||||
// Groups
|
// 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) => {
|
moveContactsToAddressBook: async (ids, addressBookId) => {
|
||||||
const target = bookTarget(addressBookId);
|
const target = bookTarget(addressBookId);
|
||||||
const primaryAccountId = getContactsAccountId();
|
const primaryAccountId = getContactsAccountId();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { generateScript, supportsSpamGuard, VACATION_SCRIPT_NAME } from '../lib/
|
|||||||
import { supportsPeriods } from '../lib/sieve/period';
|
import { supportsPeriods } from '../lib/sieve/period';
|
||||||
import { isValidVacationForward, withVacationPeriod } from '../lib/sieve/vacation-forward';
|
import { isValidVacationForward, withVacationPeriod } from '../lib/sieve/vacation-forward';
|
||||||
import { isValidVacationAudience, normalizeVacationAudience } from '../lib/sieve/vacation-audience';
|
import { isValidVacationAudience, normalizeVacationAudience } from '../lib/sieve/vacation-audience';
|
||||||
|
import { worstCaseForwards } from '../lib/sieve/forward-limit';
|
||||||
import {
|
import {
|
||||||
createSieveScript,
|
createSieveScript,
|
||||||
getSieveAccountId,
|
getSieveAccountId,
|
||||||
@@ -56,6 +57,14 @@ interface FilterStore {
|
|||||||
addRule: (rule: FilterRule) => void;
|
addRule: (rule: FilterRule) => void;
|
||||||
updateRule: (ruleId: string, updates: Partial<FilterRule>) => void;
|
updateRule: (ruleId: string, updates: Partial<FilterRule>) => void;
|
||||||
deleteRule: (ruleId: string) => 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;
|
reorderRules: (ruleIds: string[]) => void;
|
||||||
toggleRule: (ruleId: string) => void;
|
toggleRule: (ruleId: string) => void;
|
||||||
setRawScript: (content: 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) => {
|
reorderRules: (ruleIds) => {
|
||||||
// Only reorder bulwark rules; external rules always stay at the end in
|
// Only reorder bulwark rules; external rules always stay at the end in
|
||||||
// their original order.
|
// their original order.
|
||||||
@@ -344,6 +378,12 @@ export interface VacationFilters {
|
|||||||
* client), or no script runs at all. Saving the card sets it right.
|
* client), or no script runs at all. Saving the card sets it right.
|
||||||
*/
|
*/
|
||||||
notRunning: boolean;
|
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. */
|
/** 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.
|
// Told apart by the envelope sender, which the auto-reply goes to.
|
||||||
audienceAvailable: filtersUsable && !!capabilities?.sieveExtensions?.includes('envelope'),
|
audienceAvailable: filtersUsable && !!capabilities?.sieveExtensions?.includes('envelope'),
|
||||||
notRunning: storedButIdle(!!target?.isActive, !!vacationScript?.isActive, forward, audience),
|
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
|
// 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.
|
// cache past this, the oldest messages are evicted to fit.
|
||||||
offlineCacheMaxMB: number;
|
offlineCacheMaxMB: number;
|
||||||
|
|
||||||
|
// calendar: the colour of the virtual Birthdays calendar (webmail
|
||||||
|
// birthdayCalendarColor).
|
||||||
|
birthdayCalendarColor: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_PERSISTED: PersistedSettings = {
|
const DEFAULT_PERSISTED: PersistedSettings = {
|
||||||
@@ -463,6 +467,9 @@ const DEFAULT_PERSISTED: PersistedSettings = {
|
|||||||
offlineCacheEnabled: false,
|
offlineCacheEnabled: false,
|
||||||
offlineCacheDays: 7,
|
offlineCacheDays: 7,
|
||||||
offlineCacheMaxMB: 50,
|
offlineCacheMaxMB: 50,
|
||||||
|
|
||||||
|
// calendar:
|
||||||
|
birthdayCalendarColor: '#eab308',
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface SettingsState extends PersistedSettings {
|
export interface SettingsState extends PersistedSettings {
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ export interface VacationState {
|
|||||||
audienceAvailable: boolean;
|
audienceAvailable: boolean;
|
||||||
/** Stored forwarding or recipients do not run until the card is saved (see readVacationFilters). */
|
/** Stored forwarding or recipients do not run until the card is saved (see readVacationFilters). */
|
||||||
notRunning: boolean;
|
notRunning: boolean;
|
||||||
|
/** The most forwards the filter rules let one message collect (see readVacationFilters). */
|
||||||
|
otherForwards: number;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@@ -80,6 +82,7 @@ const NO_FILTERS = {
|
|||||||
audience: null,
|
audience: null,
|
||||||
audienceAvailable: false,
|
audienceAvailable: false,
|
||||||
notRunning: false,
|
notRunning: false,
|
||||||
|
otherForwards: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const INITIAL = {
|
const INITIAL = {
|
||||||
@@ -106,6 +109,7 @@ function fromFilters(filters: VacationFilters) {
|
|||||||
audience: filters.audience,
|
audience: filters.audience,
|
||||||
audienceAvailable: filters.audienceAvailable,
|
audienceAvailable: filters.audienceAvailable,
|
||||||
notRunning: filters.notRunning,
|
notRunning: filters.notRunning,
|
||||||
|
otherForwards: filters.otherForwards,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user