diff --git a/docs/native-sync-2026-11.md b/docs/native-sync-2026-11.md index 5589eb33..9b000c77 100644 --- a/docs/native-sync-2026-11.md +++ b/docs/native-sync-2026-11.md @@ -50,7 +50,7 @@ both versions want. Findings: -- [ ] **A new calendar lands in the store without its name and colour** — `P2` — `bug` (also on 1.0.0) +- [x] **A new calendar lands in the store without its name and colour** — `P2` — `bug` (also on 1.0.0) — fixed in a2344ea0 - What Stalwart does: the Calendar/set create answer never echoes properties the client sent (crates/jmap/src/calendar/set.rs:1307-1367). - What RN does: createCalendar returns `created` alone (src/api/calendar.ts:1269) and the store inserts it (src/stores/calendar-store.ts:1415-1423), so the calendar shows nameless and colourless until the next fetch. - Fix hint: `{ ...props, ...created }`, as createEvent does. Live test in live-stalwart-calendar. @@ -117,9 +117,9 @@ parses it, nothing stores it), the global locks need no webmail change. - [ ] **iOS: reader CSP, composer nonce, `target=_blank` taps, #81 keyboard** — `P2` — `partial` — needs an iOS run (e2e-ios.yml in testing step 4) - [ ] **iOS push lapses after 7 days without opening the app** — `P2` — `bug` — the extension has no expiry handling (plugins/ios-push/NotificationService.swift), no BGTask; needs a device to verify - [ ] **Login switch from a deep link or notification keeps the old navigation stack** — `P2` — `bug` — App.tsx:118-122, :141-145, :181-186 switch and navigate on top; question 7 -- [ ] **Sieve editor loses typed text (#1162)** — `P3` — `bug` — src/components/filters/SieveEditorSheet.tsx:35-41 re-seeds on every content change -- [ ] **No forward limit (#1155, #1208), no vacation length warning** — `P3` — `missing` — FilterRuleModal.tsx:443-459, VacationSettings.tsx:140-142 -- [ ] **Other-zone daily series keeps a wrong recurrenceId on the device's DST day** — `P3` — `bug` — src/lib/recurrence-expansion.ts:526-536 builds candidates from device-local fields +- [x] **Sieve editor loses typed text (#1162)** — `P3` — `bug` — src/components/filters/SieveEditorSheet.tsx:35-41 re-seeds on every content change — fixed in 6c3046c8 +- [x] **No forward limit (#1155, #1208), no vacation length warning** — `P3` — `missing` — FilterRuleModal.tsx:443-459, VacationSettings.tsx:140-142 — fixed in dcbfc907 +- [x] **Other-zone daily series keeps a wrong recurrenceId on the device's DST day** — `P3` — `bug` — src/lib/recurrence-expansion.ts:526-536 builds candidates from device-local fields — fixed in 6e697baa - [ ] **New strings English-only** — `P3` — `partial` — 331 overlay keys missing in 25 locales, 86 in de; question 6 - [ ] **Org policy, hand-off password** — blocked on the webmail (above) - [ ] **`C:\ns` junction and the native-sync worktree still exist** — chore — C:\ns points at C:\Users\info\worktrees\native-sync (e6e5e521, on main) @@ -152,7 +152,7 @@ current lines is in "Webmail bugs" at the end of this file. - Fixed on main, unreleased (close after 0.1.87 ships): #80, #82, #83, #84, #86, #87, #89. #81 is fixed in code (01983a38), unchecked on iOS. - [ ] **#91 multi-select without long press** — `P3` — `missing` — EmailListScreen.tsx:190-218; tap on the avatar toggles selection -- [ ] **#90 custom calendar colours** — `P3` — `missing` — CalendarEditSheet.tsx:222 offers the palette only +- [x] **#90 custom calendar colours** — `P3` — `missing` — CalendarEditSheet.tsx:222 offers the palette only — fixed in d3f6d6ae - [ ] **#85 keep the shade open on Delete** — question 1 - [ ] **#58 PGP / S/MIME** — `P3` — L, not in this run - [ ] **#3 TLS client certificates** — Android done in 0.1.83; iOS has none (L) diff --git a/docs/parity/05-calendar.md b/docs/parity/05-calendar.md index 84ab8b21..daa02e32 100644 --- a/docs/parity/05-calendar.md +++ b/docs/parity/05-calendar.md @@ -83,7 +83,7 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi - What RN does: fixed preset list, multiple allowed, non-offset alerts are dropped from the UI but kept until save (`src/components/calendar/EventModal.tsx:299-312`, `src/lib/calendar-alerts.ts:51-70`). Note the "kept until save" comment is wrong: `remindersToAlerts` rebuilds the map from presets only, so an absolute-trigger alert is lost on the next save. - Fix hint: add a custom value+unit row; when rebuilding alerts, carry over alerts whose trigger has no `offset`. -- [ ] **Duplicate event, export .ics, copy meeting link / title, add note** — `P3` — `missing` — duplicate (+1 day, opens editor), share as .ics (`eventToICS` port + expo-sharing) and copy meeting link done in 5fe8e40; deferred: copy title, append-a-note +- [x] **Duplicate event, export .ics, copy meeting link / title, add note** — `P3` — `missing` — duplicate (+1 day, opens editor), share as .ics (`eventToICS` port + expo-sharing) and copy meeting link done in 5fe8e40; deferred: copy title, append-a-note — fixed in baa3ff59 (copy title, add note) - What WEB does: popover/context-menu actions duplicate (+1 day, opens editor), export via `downloadEventICS`, copy meeting link, copy title, append a timestamped note to the description (ref `components/calendar/calendar-app.tsx:1017-1125`, `lib/calendar-ics-export.ts:96, 176`). - What RN does: `EventDetailSheet` has an `onDuplicate` prop that `CalendarScreen` never passes (`src/components/calendar/EventDetailSheet.tsx:59, 336-342`); no export/copy/note. - Fix hint: wire `onDuplicate` (clone like WEB `handleDuplicateFromDetail`), add "Share .ics" via `expo-sharing` + a port of `eventToICS`, add "Copy link" with `Clipboard`. @@ -169,7 +169,7 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi ### Views / navigation / locale -- [ ] **Entire calendar UI is hard-coded English although RN has i18n** — `P2` — `missing` — partial in 0f80e98: screen, month/week/agenda views, event card/sheet/modal, drawer, scope dialog, settings, banner and calendar/share sheets use `t()` + date-fns locales; TasksSheet (09c24fa), `formatReminder` (2abea97) and ICalImportSheet (b7f7110) localized too; deferred: ICalSubscriptionSheet and RecurrenceEditor still hard-code English +- [x] **Entire calendar UI is hard-coded English although RN has i18n** — `P2` — `missing` — partial in 0f80e98: screen, month/week/agenda views, event card/sheet/modal, drawer, scope dialog, settings, banner and calendar/share sheets use `t()` + date-fns locales; TasksSheet (09c24fa), `formatReminder` (2abea97) and ICalImportSheet (b7f7110) localized too; deferred: ICalSubscriptionSheet and RecurrenceEditor still hard-code English — fixed in 84fef142 - What WEB does: everything through `next-intl` (`t('calendar.*')`), month/day names and popover dates localized (changelog 1.6.x "Localize event start date in detail popover and event modal"). - What RN does: `src/i18n/index.ts` + `useLocaleStore` are used by 18 other files, but none of `src/components/calendar/*`, `src/screens/CalendarScreen.tsx` or `CalendarSettings.tsx` import it; all labels ("Today", "No events", "Does not repeat", "Going?", RSVP labels, drawer titles, etc.) and `date-fns` `format()` calls without a `locale` are English (`CalendarScreen.tsx:408, 448, 504, 640-641`, `EventModal.tsx:60-67, 326, ...`, `MonthView.tsx:25-26`, `AgendaView.tsx:33-37`). - Fix hint: add a `calendar.*` namespace to `locales/*.json` (WEB's keys can be copied) and pass a date-fns locale from the locale store into `format()`. @@ -214,7 +214,7 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi - What RN does: `formatRange` uses `eventTimeRange` -> `getEventEndDate` (exclusive), so a single-day all-day event on Mar 1 prints "Mar 1 – Mar 2" and a two-day one "Mar 1 – Mar 3" (`src/components/calendar/EventDetailSheet.tsx:63-74`, `src/lib/calendar-utils.ts:84-90`). - Fix hint: for `allDay` use `getEventDisplayEndDate(event)` before comparing/formatting. -- [ ] **Deep links to calendar/event** — `P3` — `missing` — partly: incoming links are parsed since 5802cae (`src/navigation/linking.ts`), and an event link opens the event since 9282ee6; a date link only opens the Calendar tab +- [ ] **Deep links to calendar/event** — `P3` — `missing` — partly: incoming links are parsed since 5802cae (`src/navigation/linking.ts`), and an event link opens the event since 9282ee6; a date link only opens the Calendar tab — not done: date deep links need a route in App.tsx/linking.ts (same verdict as the 10 row) - What WEB does: `parseCalendarPath`/`buildCalendarPath` handle `/calendar//?event=` (ref `lib/deep-links.ts:272-300`, changelog 1.8.3 "Deep links for mail, calendar, contacts, files"). - What RN does: `Calendar: undefined` route params (`src/navigation/types.ts:33`); `handleDeepLink` hands an event to the Calendar tab through `pending-calendar-open`, the same path a tapped reminder takes, but ignores the date. - Fix hint: accept `{ date?, eventId? }` params on the Calendar route and open the detail sheet. @@ -267,7 +267,7 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi ### Settings -- [ ] **No custom time-zone setting (#755); no birthday-calendar colour; no "Day" view on mobile (see above)** — `P3` — `missing` — time-zone setting (`calendarTimeZone`, used for saved events and JMAP queries) done in 58a6f45 + 0f80e98; the editors and views work in calendar-zone wall time since a8e6f6e and 8af163a, and floating task dues since 1a651a9; deferred: birthday-calendar colour setting +- [x] **No custom time-zone setting (#755); no birthday-calendar colour; no "Day" view on mobile (see above)** — `P3` — `missing` — time-zone setting (`calendarTimeZone`, used for saved events and JMAP queries) done in 58a6f45 + 0f80e98; the editors and views work in calendar-zone wall time since a8e6f6e and 8af163a, and floating task dues since 1a651a9; deferred: birthday-calendar colour setting — fixed in 574db426 (birthday colour; the time zone and the Day view were done before) - What WEB does: `timeZone: 'auto' | IANA` overriding browser detection, used for display (`displayNow`, `toDisplayDate`) and for the `timeZone` on saved events (ref `lib/timezone.ts:29-75`, `stores/settings-store.ts:317, 553`, changelog 1.9.2 #755); `birthdayCalendarColor` (`settings-store.ts:377, 609`). - What RN does: device zone only (`src/api/calendar.ts:15-21`); birthday colour fixed (`src/lib/birthday-calendar.ts:7`). - Fix hint: add `timeZone` to the settings store (synced with WEB's key so it round-trips through settings sync), pass it as the JMAP `timeZone` arg and into saved events; convert display via `Intl` like WEB's `getWallClock`. diff --git a/docs/parity/06-contacts.md b/docs/parity/06-contacts.md index 21a57355..6e3409b0 100644 --- a/docs/parity/06-contacts.md +++ b/docs/parity/06-contacts.md @@ -73,7 +73,7 @@ RN covers the visible surface reasonably well (list with alphabetical index, det - What RN does: shows the trash icon whenever `books.length > 1 && mayDelete !== false` (`src/components/settings/ContactsSettings.tsx:209`); the server then rejects and the user sees a raw error. - Fix hint: add `!book.isDefault` to the guard. -- [ ] **Address book share / visibility (subscription) management missing** — `P3` — `missing` +- [ ] **Address book share / visibility (subscription) management missing** — `P3` — `missing` — not done: needs its own share sheet; ran out of time (same verdict as the 10 row) - deferred: `shareWith`/`myRights` are now requested explicitly (580e010) and typed on `AddressBook`; no sharing sheet on mobile yet (reuse the calendar-sharing sheet when it exists). - What WEB does: `shareAddressBook` via `shareWith/` patch + ShareCollectionDialog (`stores/contact-store.ts:1064-1085`, `components/contacts/contacts-app.tsx:1142-1158`); sidebar shows a shared indicator (`contacts-sidebar.tsx:879-881`). - What RN does: nothing; `getAddressBooks` requests no `properties` so `shareWith` is whatever the server defaults to (`src/api/contacts.ts:17-24`; WEB requests `ADDRESS_BOOK_PROPERTIES` incl. `shareWith`, `lib/jmap/client.ts:252-261`, #257). @@ -146,7 +146,7 @@ RN covers the visible surface reasonably well (list with alphabetical index, det - What RN does: filter = contacts with no `addressBookIds` (`src/stores/contacts-store.ts:439-440`; `src/screens/ContactsScreen.tsx:146-149`), drawer row passes `count={0}` (`src/components/contacts/ContactsSidebarDrawer.tsx:128-136`) so the count never shows; on Stalwart every card has a book so the view is always empty. - Fix hint: redefine as "no keywords", move the row under the Tags section, compute the count. -- [ ] **Contact list filters (org / title / location / domain / birthday month / has email-phone-photo) missing** — `P3` — `missing` +- [x] **Contact list filters (org / title / location / domain / birthday month / has email-phone-photo) missing** — `P3` — `missing` — fixed in 18c6b03f - deferred: optional; free-text search only. - What WEB does: filter drawer with tri-state chips (`components/contacts/contact-list.tsx:18-76, 154-228, 350-456`; changelog 1.5.x "Revamp contact detail view with filters"). - What RN does: free-text search only (`src/lib/contact-utils.ts:216-241`). @@ -189,7 +189,7 @@ RN covers the visible surface reasonably well (list with alphabetical index, det ### Composer autocomplete -- [ ] **Autocomplete lacks groups, recent recipients (Sent), on-demand server search and directory principals** — `P2` — `missing` +- [ ] **Autocomplete lacks groups, recent recipients (Sent), on-demand server search and directory principals** — `P2` — `missing` — event participants get groups and recent recipients in e988dc29; not done: on-demand server search and directory principals in the composer (compose agent's side) - store-side done in 26a34d5 (`getAutocomplete` with group entries + recent recipients, `loadRecentRecipients`/`searchRecipients` fed by `src/api/recent-recipients.ts`, `getGroupRecipients`, #672 normalisation); the composer uses `getAutocomplete` and the recent Sent recipients since 5b72c4d. Still open: the on-demand server search (`searchRecipients` has no caller), directory principals, and the calendar's `ParticipantInput`, which still offers contacts only. - What WEB does: `getAutocomplete` merges contacts, group entries (as one chip with `memberCount`), RFC 9670 directory principals and recent Sent recipients, deduped and sanitised (#672) (`stores/contact-store.ts:622-707, 23-49`; `loadRecentRecipients` `:1118-1142` fed from `mail-app.tsx:423`; `searchRecipients` `:1144-1154` used by `components/email/email-composer.tsx:1175-1212`; group chip insertion `:1218-1233`; changelogs 1.6.x "Recipient autocomplete from Sent, with on-demand server search", 1.7.x group chips). - What RN does: suggestions come only from `individuals` matched with `matchesContactSearch` (`src/screens/ComposeScreen.tsx:367-392`); groups are explicitly filtered out and nothing else is consulted. `ParticipantInput` (calendar) likewise flattens contacts only (`src/components/calendar/ParticipantInput.tsx:34-51`). @@ -209,7 +209,7 @@ RN covers the visible surface reasonably well (list with alphabetical index, det - What RN does: none (see previous item). - Fix hint: same sheet as above; show the matching card's phone/org and an "Open" that navigates to `ContactDetail`. -- [ ] **Contact deep links (`/contacts/[/edit]`, `/contacts/new`)** — `P3` — `missing` +- [ ] **Contact deep links (`/contacts/[/edit]`, `/contacts/new`)** — `P3` — `missing` — not done: needs routes in linking.ts, the platform agent's file (same verdict as the 10 row) - partly: `/contacts/` opens the contact since 5802cae; `/contacts/new` and `/contacts//edit` only open the Contacts tab. - What WEB does: `parseContactsPath`/`buildContactsPath` (`lib/deep-links.ts:323-329`), applied once on mount (`components/contacts/contacts-app.tsx:182-224`). - What RN does: `src/navigation/linking.ts` (5802cae) maps `/contacts/` to `ContactDetail { contactId }` and every other contacts path to the tab; it never opens `ContactForm`. diff --git a/docs/parity/07-filters-vacation-files.md b/docs/parity/07-filters-vacation-files.md index f8d1a8d6..82808302 100644 --- a/docs/parity/07-filters-vacation-files.md +++ b/docs/parity/07-filters-vacation-files.md @@ -143,7 +143,7 @@ Filters: RN carries a byte-for-byte port of WEB's Sieve parser/generator/tests a - What WEB does: on first listing, reparents nodes named with `/`, `∕`, `⁄`, `/` separators and replaces blob-backed "dir marker" files with real folders, with a progress overlay (`files-app.tsx:245-263`, `710-734`). - What RN does: reads only the real hierarchy (`src/api/files.ts:6-14` comment acknowledges WEB migrates). A user who never opens WEB sees legacy files as flat names at the root. Acceptable if WEB is always used at least once; otherwise port `migrateLegacyFlatNodes`. -- [ ] **Deep link `/files/` and `?preview=` not handled** — `P3` — `missing` (changelog 1.8.1 "Deep links for mail, calendar, contacts, files") — partly: `/files` links open the Files tab since 5802cae; folder paths and `?preview=` are ignored +- [ ] **Deep link `/files/` and `?preview=` not handled** — `P3` — `missing` (changelog 1.8.1 "Deep links for mail, calendar, contacts, files") — partly: `/files` links open the Files tab since 5802cae; folder paths and `?preview=` are ignored — not done: ran out of time (same verdict as the 10 row) - What WEB does: `parseFilesPath`/`buildFilesPath` walk the drive to the folder and open the preview (`components/files/files-app.tsx:190-278`; `lib/deep-links.ts:381`). - What RN does: `src/navigation/linking.ts` (5802cae) turns any `/files/...` link into `{ kind: 'files' }` and opens the tab; `Files: undefined` in `src/navigation/types.ts:35`. - Fix hint: parse the rest of the path in `linking.ts`, pass it to `Files` as a `path` param and walk `allNodes` by names. diff --git a/docs/parity/10-webmail-1.10-to-1.18.md b/docs/parity/10-webmail-1.10-to-1.18.md index a3f78737..206c608d 100644 --- a/docs/parity/10-webmail-1.10-to-1.18.md +++ b/docs/parity/10-webmail-1.10-to-1.18.md @@ -317,16 +317,16 @@ Stalwart 1.0.0 items found in the same pass (1.0 SSO scopes, Files on 1.0, #1385 - [x] **Blank participant names are written (#748)** — `P3` — `bug` — fixed in 59c32782 - What WEB does: `lib/calendar-participants.ts:368`. What RN does: `src/lib/calendar-participants.ts:360`, `:385`, `:484`. -- [ ] **Offer to save without invitations when the server refuses them** — `P3` — `partial` +- [x] **Offer to save without invitations when the server refuses them** — `P3` — `partial` — fixed in 9b5692a8 - What WEB does: `lib/jmap/scheduling-error.ts:7`. What RN does: `src/components/calendar/EventModal.tsx:385-390`. -- [ ] **Moving a task into or within a shared calendar (#1204)** — `P3` — `partial` +- [x] **Moving a task into or within a shared calendar (#1204)** — `P3` — `partial` — fixed in b6995bb6 - What WEB does: `lib/jmap/client.ts:9473`. What RN does: updateTask sends unmapped calendarIds (`src/stores/calendar-store.ts:1311-1315`). -- [ ] **Working hours (#1164); week all-day area (#1122); light tinted colours; Jalali; birthday colour; copy title/append note; component set on new calendars (#760); date deep links; background reminder refresh without device sync** — `P3` — `missing`/`partial` +- [ ] **Working hours (#1164); week all-day area (#1122); light tinted colours; Jalali; birthday colour; copy title/append note; component set on new calendars (#760); date deep links; background reminder refresh without device sync** — `P3` — `missing`/`partial` — birthday colour fixed in 574db426, copy title/append note in baa3ff59 (duplicate and .ics export were there); not done: week all-day area, light tinted colours (needs a toggle on the platform agent's calendar settings page), date deep links (route in App.tsx/linking.ts, see the calendar report) and background reminder refresh: ran out of time; working hours and Jalali: own feature, not in this run; component set (#760): needs a CalDAV client (05 row) - What RN does: `src/components/calendar/WeekView.tsx`, `src/components/calendar/TimeGridScrollView.tsx:177-185`, `src/lib/birthday-calendar.ts:6`, `src/components/calendar/EventDetailSheet.tsx:422`, `src/api/calendar.ts:1150`, `src/navigation/linking.ts:151`, `:288-299`, `src/lib/push-notifications.ts:243`. -- [ ] **Free/busy, CalendarEventNotification, organizer from ParticipantIdentity (1.10.0)** — `P3` — `missing` +- [ ] **Free/busy, CalendarEventNotification, organizer from ParticipantIdentity (1.10.0)** — `P3` — `missing` — not done: own feature, not in this run - What WEB does: `lib/jmap/client.ts:1198`. ### Contacts @@ -350,7 +350,7 @@ Stalwart 1.0.0 items found in the same pass (1.0 SSO scopes, Files on 1.0, #1385 - [x] **vCard 4.0 export (#1407)** — `P3` — `bug` — fixed in 07fd03e2 - What RN does: dashes in dates, MOBILE instead of CELL (`src/lib/vcard.ts:21`, `:24`, `:1264`). -- [ ] **Delete contact categories; address book sharing; list filters; deep links new/edit** — `P3` — `missing`/`partial` +- [ ] **Delete contact categories; address book sharing; list filters; deep links new/edit** — `P3` — `missing`/`partial` — category delete with Undo fixed in 65ffef71, list filters in 18c6b03f; not done: address book sharing (needs its own share sheet, CalendarShareSheet is calendar-only; ran out of time) and deep links new/edit (route in linking.ts, platform agent's file) - What WEB does: `components/settings/address-book-management-settings.tsx:84`, `:435`, `components/contacts/contact-list.tsx:30`, `lib/deep-links.ts:394`. What RN does: `src/components/contacts/ContactsSidebarDrawer.tsx:144`, `src/components/settings/ContactsSettings.tsx:33`, `src/screens/ContactsScreen.tsx:163`, `src/navigation/linking.ts:155`. ### Files @@ -362,7 +362,7 @@ Stalwart 1.0.0 items found in the same pass (1.0 SSO scopes, Files on 1.0, #1385 - [x] **Save an attachment to Files (incl. shared and group mail)** — `P2` — `missing` — fixed in 8e07b7d8, 39e82f99 - What WEB does: `components/email/save-to-files-dialog.tsx:43`, `lib/jmap/client.ts:9981`. What RN does: `src/components/email/AttachmentChips.tsx:183`; `src/api/files.ts:411` has createFileNodeFromBlob. -- [ ] **Copy folders, numbered new folder names, next/previous in preview, starred and recent, deep links** — `P3` — `partial`/`missing` +- [ ] **Copy folders, numbered new folder names, next/previous in preview, starred and recent, deep links** — `P3` — `partial`/`missing` — copy folders fixed in a0f586f9; numbered new folder names: no webmail behaviour to port (its New folder dialog also takes the typed name, file-browser.tsx:1389); not done: next/previous in preview (needs header buttons in AttachmentPreviewModal, the reader agent's file), starred and recent and deep links: ran out of time - What WEB does: `lib/jmap/client.ts:10155`, `stores/file-store.ts:640`, `lib/deep-links.ts:444`. What RN does: `src/api/files.ts:511-519`, `:328-345`, `src/components/files/FilePreviewModal.tsx`, `src/navigation/linking.ts:158`, `:307`. - [ ] **Sharing fails on Stalwart before 0.16.6** — `P3` — `bug` @@ -394,7 +394,7 @@ Stalwart 1.0.0 items found in the same pass (1.0 SSO scopes, Files on 1.0, #1385 - [x] **Create rules from a message** — `P2` — `missing` — fixed in d546d509, 7b7236f0 - What WEB does: `components/email/rules-menu.tsx`, `lib/filters/quick-rules.ts:187`. What RN does: `src/components/email/ActionSheet.tsx`, `src/components/filters/FilterRuleModal.tsx`. -- [ ] **Forward limit (#1155, #1208); vacation length warning; run a rule on a folder (#1166); Sieve editor loses typed text (#1162); size value raw into Sieve; undo after deleting tags/rules/trusted senders** — `P3` — `missing`/`bug` +- [ ] **Forward limit (#1155, #1208); vacation length warning; run a rule on a folder (#1166); Sieve editor loses typed text (#1162); size value raw into Sieve; undo after deleting tags/rules/trusted senders** — `P3` — `missing`/`bug` — forward limit and vacation length fixed in dcbfc907, Sieve editor text in 6c3046c8, size value in 2167a9d7 (before this pass); undo after deleting rules: store side in b95f2c29, FilterSettings (platform agent's file) still has to call deleteRuleNow; not done: undo after deleting tags and trusted senders (KeywordSettings and the trusted-senders list are not the calendar agent's files); run a rule on a folder: needs server-side run, its own feature - What WEB does: `lib/filters/forward-limit.ts:16`, `components/settings/vacation-settings.tsx:253`, `lib/filters/quick-rule-flow.ts:221`, `lib/sieve/generator.ts:63`. What RN does: `src/components/filters/FilterRuleModal.tsx:63`, `:413`, `src/components/settings/VacationSettings.tsx:106`, `src/components/filters/SieveEditorSheet.tsx:35-41`, `src/lib/sieve/generator.ts:69-74`, `src/components/settings/KeywordSettings.tsx:50-56`. ### Push, settings and core diff --git a/docs/parity/11-webmail-1.19.md b/docs/parity/11-webmail-1.19.md index 1d6b77b2..fd0aa44a 100644 --- a/docs/parity/11-webmail-1.19.md +++ b/docs/parity/11-webmail-1.19.md @@ -150,7 +150,7 @@ Classification: `bug` = fixed in the webmail, still live in the app; `missing`; - What RN does: textMuted is `#94a3b8` on white in the light theme (2.56:1) and `#737373` in dark (4.18:1 on the background, 3.19:1 on surfaces) (`src/theme/tokens.ts:236`, `:140`); used in 456 places, also as placeholder colour. - Fix hint: adjust both, add a contrast test over tokens.ts and builtin-themes.ts. -- [ ] **The calendar grid reads poorly with a screen reader** — `P2` — `bug` +- [x] **The calendar grid reads poorly with a screen reader** — `P2` — `bug` — fixed in 294023e2 - What WEB does: events sit inside their day cell and day cells have full date names (0026abba4, 19e0acde9). - What RN does: month day cells read only the number and the dots are silent (`src/components/calendar/MonthView.tsx:199-240`); event blocks have no role, label or date (`src/components/calendar/EventBlock.tsx:72-104`). - Fix hint: label day cells with date and event count; event blocks get a button role and a label with title, date and time. @@ -207,17 +207,17 @@ Classification: `bug` = fixed in the webmail, still live in the app; `missing`; ### Calendar -- [ ] **A cancellation mail can't remove the meeting from the calendar (#1425)** — `P2` — `missing` +- [x] **A cancellation mail can't remove the meeting from the calendar (#1425)** — `P2` — `missing` — fixed in a19afe9b - What WEB does: offers "Remove from calendar" for a trusted cancellation from the organizer, for the series or one occurrence, and says when it's outdated (`components/email/calendar-invitation-banner.tsx:627-635`, `:831`, `:1229`, `lib/calendar-cancellation.ts:49`; 357b6af3c). - What RN does: only "This event was cancelled" (`src/components/email/CalendarInvitationBanner.tsx:201`, `:270-272`); it already looks up the server copy (`:122`). - Fix hint: port planCancellationRemoval behind the banner's trust check. -- [ ] **The event and task editors throw away typed changes on Close or Back** — `P2` — `bug` +- [x] **The event and task editors throw away typed changes on Close or Back** — `P2` — `bug` — fixed in 10e9f81d - What WEB does: asks before closing with unsaved changes (`components/calendar/event-modal.tsx:530-547`, `components/calendar/task-modal.tsx`; 13f9f27c4). - What RN does: close and Back call onClose (`src/components/calendar/EventModal.tsx:495`, `:500`); the task editor resets on the next open (`src/components/calendar/TasksSheet.tsx:135-146`, `:238-253`). - Fix hint: compare with the initial form and ask, as ContactFormScreen does (`src/screens/ContactFormScreen.tsx:639`). -- [ ] **A shared calendar's server colour is replaced by a random one (#1414)** — `P3` — `bug` +- [x] **A shared calendar's server colour is replaced by a random one (#1414)** — `P3` — `bug` — fixed in 64eb97f4 - What WEB does: auto-colours only shared calendars with neither a server colour nor an override (`lib/shared-calendar-colors.ts:79`; 2dbe5d024). - What RN does: colours every shared calendar without an override (`src/screens/CalendarScreen.tsx:315-333`). - Fix hint: skip calendars with `cal.color`. diff --git a/locales/rn/en.json b/locales/rn/en.json index a34db434..a6cb4b8a 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -6,6 +6,9 @@ "not_starred": "Not starred" }, "calendar": { + "a11y": { + "event_count": "{count, plural, one {# event} other {# events}}" + }, "account_changed": "The account changed while the calendar was loading or saving.", "alerts": { "minutes_after": "{count, plural, one {# minute after} other {# minutes after}}" @@ -17,6 +20,12 @@ "detail": { "location_hint": "Opens the location; long-press to copy it" }, + "discard": { + "confirm": "Discard", + "keep": "Keep editing", + "message": "Your changes haven't been saved.", + "title": "Discard changes?" + }, "drawer": { "my_calendars": "My calendars", "no_calendars": "No calendars yet.", @@ -28,6 +37,7 @@ }, "events": { "copy_link_short": "Link", + "copy_title_short": "Title", "export_ics_short": ".ics", "nothing_planned": "Nothing planned. Tap to create.", "tap_to_create": "Tap + to create one" @@ -59,12 +69,16 @@ "add_to_calendar": "Add to calendar", "added": "Added to calendar", "already_in_calendar": "Already in your calendar", + "cancel_outdated": "Your calendar has a newer version of this event than this cancellation.", "cancelled": "This event was cancelled", "decline": "No", "join_video": "Join video call", "no_writable_calendar": "No writable calendar available", "organizer": "Organizer", "reading": "Reading invitation…", + "remove_error": "Could not remove the event", + "remove_from_calendar": "Remove from calendar", + "removed_from_calendar": "Removed from your calendar.", "reply": "A participant replied to this invitation", "response_error": "Could not send your response", "response_sent": "Response sent", @@ -80,10 +94,14 @@ "clear_confirm": "Remove all", "clear_description": "Every event in this calendar will be deleted. Events that also belong to another calendar are only unlinked.", "clear_title": "Remove all events?", + "color_invalid": "Enter a color as # and six hex digits, such as #3b82f6.", + "custom_color": "Custom color (hex)", "delete_description": "The calendar and all of its events will be deleted. This cannot be undone.", "delete_title": "Delete calendar?", "description_placeholder": "Optional", "edit_calendar": "Edit calendar", + "fewer_colors": "Fewer colors", + "more_colors": "More colors", "new_calendar": "New calendar" }, "notifications": { @@ -95,6 +113,7 @@ }, "occurrence_not_found_again": "The change was not saved: the occurrence could not be found again on the server. Please try again.", "participants": { + "group_members": "{count, plural, one {# member} other {# members}}", "limit_warning": "More than {max, plural, one {# participant} other {# participants}}, including the organizer: the server may refuse to save this event. An administrator can raise the limit." }, "recurrence": { @@ -188,8 +207,11 @@ "move_to_address_book": "Move to address book", "tag_failed": "Tagging failed" }, + "category_delete_failed": "Couldn't delete the category", + "category_deleted": "Category \"{name}\" deleted", "contact": "Contact", "create_hint_mobile": "Long-press to create a group instead", + "delete_category": "Delete category", "detail": { "add_to_group_failed": "Add to group failed", "copy_name": "{name} (Copy)", @@ -287,6 +309,7 @@ }, "rename_tag_hint_mobile": "Long-press a tag to rename it", "tag_name_placeholder": "New tag name", + "undo": "Undo", "unnamed": "Unnamed" }, "context_menu": { diff --git a/src/api/__tests__/calendar.test.ts b/src/api/__tests__/calendar.test.ts index ea16e878..9f376758 100644 --- a/src/api/__tests__/calendar.test.ts +++ b/src/api/__tests__/calendar.test.ts @@ -15,6 +15,7 @@ import { queryEvents, getEvents, createEvent, + createCalendar, updateEvent, deleteEvents, batchCreateEvents, @@ -557,6 +558,35 @@ describe('calendar operations', () => { }); }); + describe('createCalendar', () => { + it('returns the new calendar with the name and colour sent when the server echoes only its id', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [ + ['Calendar/set', { created: { 'new-cal': { id: 'c9', color: null, myRights: { mayDelete: true } } } }, '0'], + ['error', { type: 'serverFail' }, '1'], + ], + }); + const cal = await createCalendar('Work', '#123456'); + expect(cal).toMatchObject({ id: 'c9', name: 'Work', color: '#123456', isVisible: true, myRights: { mayDelete: true } }); + }); + + it('takes the read-back calendar with its rights', async () => { + const rights = { mayReadItems: true, mayWriteAll: true, mayDelete: true, mayShare: true }; + mockRequest.mockResolvedValue({ + methodResponses: [ + ['Calendar/set', { created: { 'new-cal': { id: 'c9' } } }, '0'], + ['Calendar/get', { list: [{ id: 'c9', name: 'Work', color: '#123456', myRights: rights }] }, '1'], + ], + }); + const cal = await createCalendar('Work', '#123456'); + expect(cal).toMatchObject({ id: 'c9', name: 'Work', color: '#123456', myRights: rights }); + const calls = mockRequest.mock.calls[0][0]; + expect(calls[1][0]).toBe('Calendar/get'); + // The creation id: 0.16 refuses a result reference into `created`. + expect(calls[1][1].ids).toEqual(['#new-cal']); + }); + }); + describe('refusals for the participant limit (Stalwart 1.0)', () => { // 1.0 enforces maxParticipantsPerEvent on every create and update, an // answer included; 0.16 advertises the limit without enforcing it. diff --git a/src/api/__tests__/files.test.ts b/src/api/__tests__/files.test.ts index d49bac33..5bcad0d8 100644 --- a/src/api/__tests__/files.test.ts +++ b/src/api/__tests__/files.test.ts @@ -30,6 +30,7 @@ import { CAPABILITIES } from '../types'; import { accountSupportsFiles, copyFileNode, + copyFileTree, createFolder, deleteFileNodes, fileNodeKind, @@ -665,6 +666,52 @@ describe('copyFileNode', () => { }); }); +describe('copyFileTree', () => { + const tree = [ + { id: 'src', name: 'Docs', type: '', blobId: null, parentId: null }, + { id: 'sub', name: 'Old', type: '', blobId: null, parentId: 'src' }, + { id: 'f1', name: 'a.txt', type: 'text/plain', blobId: 'b1', parentId: 'src' }, + { id: 'f2', name: 'b.txt', type: 'text/plain', blobId: 'b2', parentId: 'sub' }, + { id: 'other', name: 'c.txt', type: 'text/plain', blobId: 'b3', parentId: null }, + ]; + /** Answers each FileNode/set create with a new id, or fails the call for `failOn`. */ + function serve(failOn?: string) { + let n = 0; + const creates: Record[] = []; + const destroys: string[][] = []; + mockRequest.mockImplementation(async (calls: [string, Record, string][]) => { + const [, args] = calls[0]; + if (args.destroy) { + destroys.push(args.destroy); + return { methodResponses: [['FileNode/set', { destroyed: args.destroy }, '0']] }; + } + const [key, props] = Object.entries(args.create as Record>)[0]; + if (props.name === failOn) return { methodResponses: [['FileNode/set', { notCreated: { [key]: { type: 'forbidden' } } }, '0']] }; + creates.push(props); + return { methodResponses: [['FileNode/set', { created: { [key]: { id: `new-${++n}` } } }, '0']] }; + }); + return { creates, destroys }; + } + + it('makes the folder again with its folders and files, the files on the same blobs', async () => { + const { creates } = serve(); + const copy = await copyFileTree(tree[0], tree, null, 'Docs (1)'); + expect(copy.id).toBe('new-1'); + expect(creates).toEqual([ + { name: 'Docs (1)' }, + { name: 'Old', parentId: 'new-1' }, + { name: 'b.txt', type: 'text/plain', blobId: 'b2', parentId: 'new-2' }, + { name: 'a.txt', type: 'text/plain', blobId: 'b1', parentId: 'new-1' }, + ]); + }); + + it('takes a half-made copy back, the deepest level first', async () => { + const { destroys } = serve('a.txt'); + await expect(copyFileTree(tree[0], tree, null, 'Docs (1)')).rejects.toThrow(); + expect(destroys).toEqual([['new-3'], ['new-2'], ['new-1']]); + }); +}); + describe('fileNodeKind', () => { it('takes nodeType as Stalwart 0.16.6+ returns it', () => { expect(fileNodeKind({ nodeType: 'file', blobId: 'b1' })).toBe('file'); diff --git a/src/api/__tests__/live-stalwart-calendar.test.ts b/src/api/__tests__/live-stalwart-calendar.test.ts index e5b382e8..6cda02e3 100644 --- a/src/api/__tests__/live-stalwart-calendar.test.ts +++ b/src/api/__tests__/live-stalwart-calendar.test.ts @@ -29,6 +29,7 @@ import type { Calendar, CalendarEvent, Participant } from '../types'; import { createEvent, deleteEvents, + findEventsByUid, getCalendars, getEvents, queryEvents, @@ -43,6 +44,7 @@ import { expandRecurringEvents } from '../../lib/recurrence-expansion'; import { getEventStartDate } from '../../lib/calendar-utils'; import { alertsToReminders, calendarDefaultAlerts, defaultAlertIdPrefix, remindersToDefaultAlerts } from '../../lib/calendar-alerts'; import { getEffectiveAlerts } from '../../lib/calendar-alert-scheduler'; +import { removeCancelledMeeting } from '../../lib/calendar-cancellation'; const SERVER = process.env.BULWARK_LIVE_JMAP; const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org'; @@ -607,6 +609,89 @@ live('live Stalwart calendar', () => { }, 120_000); }); + liveTwo('a cancelled meeting (#1425)', () => { + const after = '2027-10-01T00:00:00.000Z'; + const before = '2027-10-31T00:00:00.000Z'; + const me = [USER]; + + /** Run `fn` as the second user, in the app's singleton. */ + async function asOther(fn: () => Promise): Promise { + (await singleton()).restoreSnapshot(other!.snapshot()); + useCalendarStore.getState().reset(); + try { + return await fn(); + } finally { + await connectSingleton(); + } + } + + function removeAsOther(cancel: Partial) { + return asOther(async () => { + await useCalendarStore.getState().fetchCalendars(); + const s = useCalendarStore.getState(); + return removeCancelledMeeting(cancel, { + findEventsByUid: (u) => findEventsByUid(u), + deleteEvent: s.deleteEvent, + updateEvent: s.updateEvent, + }); + }); + } + + it('takes one cancelled occurrence, then the cancelled series, out of the attendee\'s calendar', async () => { + await connectSingleton(); + // Four Mondays from 4 October 2027, the second user invited. + const invitation = editorSave( + { title: `${RUN} cancelled`, start: '2027-10-04T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin' } as CalendarEvent, + { attendees: [{ name: 'Land', email: USER2! }] }, + me, + ); + const created = await useCalendarStore.getState().createEvent({ + ...seriesData('cancelled', '2027-10-04T10:00:00', 4), + participants: invitation.updates.participants, + organizerCalendarAddress: invitation.updates.organizerCalendarAddress, + }, calendarId, { sendSchedulingMessages: true }); + const uid = created.uid!; + const copies = await waitFor(() => seriesByUid(other!, uid), (list) => list.length > 0); + expect(copies, 'the invitation reached the second user').toHaveLength(1); + + // The organizer cancels the second Monday. The attendee's 1.0 keeps + // it, marked cancelled; 0.16 takes it out itself. + const occurrences = await loadOccurrences(uid, after, before); + const second = occurrences.find((e) => (e.recurrenceId ?? '').startsWith('2027-10-11'))!; + expect(await deleteOccurrence('this', { event: second, userEmails: me }, storeActions())).toBe(true); + const key = '2027-10-11T10:00:00'; + const marked = await waitFor(() => seriesByUid(other!, uid), (list) => + /cancelled|excluded/.test(JSON.stringify(list[0]?.recurrenceOverrides?.[key] ?? null))); + const entry = marked[0].recurrenceOverrides?.[key] as { sequence?: number; excluded?: boolean } | undefined; + expect(JSON.stringify(entry ?? null)).toMatch(/cancelled|excluded/); + + const one = await removeAsOther({ + uid, + recurrenceId: key, + recurrenceIdTimeZone: 'Europe/Berlin', + sequence: entry?.sequence ?? 0, + }); + expect(one.kind).toBe(entry?.excluded ? 'none' : 'exclude'); + const [excluded] = await seriesByUid(other!, uid); + expect(excluded.recurrenceOverrides?.[key]).toEqual({ excluded: true }); + expect(excluded.status).not.toBe('cancelled'); + + // An older cancellation of the whole meeting is not applied. + const outdated = await removeAsOther({ uid, sequence: -1 }); + expect(outdated.kind).toBe('outdated'); + + // The organizer cancels the series. + const left = await loadOccurrences(uid, after, before); + expect(await deleteOccurrence('all', { event: left[0], userEmails: me }, storeActions())).toBe(true); + // Both servers keep it, marked cancelled. + const marked2 = await waitFor(() => seriesByUid(other!, uid), (list) => list[0]?.status === 'cancelled'); + expect(marked2[0]?.status).toBe('cancelled'); + const all = await removeAsOther({ uid, sequence: marked2[0].sequence ?? 0 }); + expect(all.kind).toBe('delete'); + expect(await seriesByUid(other!, uid)).toEqual([]); + }, 120_000); + }); + liveTwo('shares', () => { let mailboxId = ''; let sharedCalendarId = ''; @@ -698,6 +783,59 @@ live('live Stalwart calendar', () => { expect(list).toHaveLength(1); expect(list[0].recurrenceOverrides?.['2027-10-11T10:00:00']?.title).toBe(`${RUN} owner second`); }, 60_000); + + it('moves a task into the shared calendar and back out of it (#1204)', async () => { + expect(sharedCalendarId, 'the calendar shared above').toBeTruthy(); + await connectSingleton(); + const [[, made]] = await raw(client, [['CalendarEvent/set', { + accountId: client.accountId, + create: { t: { '@type': 'Task', title: `${RUN} moving task`, calendarIds: { [calendarId]: true }, due: '2027-11-03T10:00:00', timeZone: 'Europe/Berlin' } }, + }, '0']]); + expect(made.created?.t?.id, JSON.stringify(made.notCreated)).toBeTruthy(); + const store = useCalendarStore.getState(); + await store.fetchCalendars(); + await useCalendarStore.getState().fetchTasks(); + const taskOf = () => useCalendarStore.getState().tasks.find((t) => t.title === `${RUN} moving task`); + const shared = useCalendarStore.getState().calendars.find((c) => c.name === `${RUN} team`)!; + const task = taskOf()!; + expect(task.accountId).toBeFalsy(); + + await useCalendarStore.getState().updateTask(task.id, { calendarIds: { [shared.id]: true } }); + const inShared = (await raw(other!, [['CalendarEvent/query', { accountId: other!.accountId, filter: { inCalendar: sharedCalendarId } }, 'q'], + ['CalendarEvent/get', { accountId: other!.accountId, '#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' }, properties: ['id', 'uid', 'title', '@type'] }, 'g']])) + .find((r) => r[2] === 'g')?.[1].list as Record[]; + const moved = inShared.find((e) => e.title === `${RUN} moving task`); + expect(moved?.uid).toBe(task.uid); + expect((await raw(client, [['CalendarEvent/get', { accountId: client.accountId, ids: [task.id], properties: ['id'] }, '0']]))[0][1].notFound) + .toEqual([task.id]); + expect(taskOf()?.accountId).toBe(other!.accountId); + + // And back into the user's own calendar. + await useCalendarStore.getState().updateTask(taskOf()!.id, { calendarIds: { [calendarId]: true } }); + expect(taskOf()?.accountId).toBeFalsy(); + await removeRunEvents(client); + }, 60_000); + }); + + describe('a new calendar', () => { + let newCalendarId = ''; + + afterAll(async () => { + if (newCalendarId) { + await raw(client, [['Calendar/set', { accountId: client.accountId, destroy: [newCalendarId], onDestroyRemoveEvents: true }, '0']]).catch(() => undefined); + } + }, 30_000); + + it('has its name, colour and rights in the store right after it is made', async () => { + await connectSingleton(); + await useCalendarStore.getState().fetchCalendars(); + const made = await useCalendarStore.getState().createCalendar(`${RUN} new`, '#0f766e'); + newCalendarId = made.id; + const inStore = useCalendarStore.getState().calendars.find((c) => c.id === made.id); + expect(inStore).toMatchObject({ name: `${RUN} new`, color: '#0f766e' }); + expect(inStore?.myRights?.mayDelete).toBe(true); + expect(inStore?.myRights?.mayWriteAll).toBe(true); + }, 30_000); }); describe('a calendar\'s default reminders (#1209)', () => { diff --git a/src/api/__tests__/live-stalwart-contacts.test.ts b/src/api/__tests__/live-stalwart-contacts.test.ts index 9eb71442..1f012675 100644 --- a/src/api/__tests__/live-stalwart-contacts.test.ts +++ b/src/api/__tests__/live-stalwart-contacts.test.ts @@ -171,6 +171,29 @@ live('live Stalwart: contacts and filters (0.16 and 1.0)', () => { // 1.0 refuses to destroy a book that holds cards without // onDestroyRemoveContents (addressBookHasContents). + it('deletes a category from its contacts and puts it back on Undo', async () => { + const tag = `live-cat-${RUN}`; + const one = await createContact({ + '@type': 'Card', version: '1.0', kind: 'individual', name: { full: `Live Cat One ${RUN}` }, + keywords: { [tag]: true, keep: true }, + } as Partial, bookId); + const two = await createContact({ + '@type': 'Card', version: '1.0', kind: 'individual', name: { full: `Live Cat Two ${RUN}` }, + keywords: { [tag]: true }, + } as Partial, bookId); + createdCards.push(one.id, two.id); + await useContactsStore.getState().fetchContacts(); + + const ids = await useContactsStore.getState().removeKeyword(tag); + expect(ids.sort()).toEqual([one.id, two.id].sort()); + expect((await getContact(one.id))?.keywords ?? {}).toEqual({ keep: true }); + expect((await getContact(two.id))?.keywords?.[tag]).toBeFalsy(); + + await useContactsStore.getState().addKeyword(tag, ids); + expect((await getContact(one.id))?.keywords).toEqual({ [tag]: true, keep: true }); + expect((await getContact(two.id))?.keywords).toEqual({ [tag]: true }); + }, 60_000); + it('deletes an address book that still holds contacts', async () => { const book = await createAddressBook(`live-contacts-${RUN}-full`); const card = await createContact({ '@type': 'Card', version: '1.0', name: { full: `Live Book ${RUN}` } } as Partial, book.id); diff --git a/src/api/__tests__/live-stalwart-files.test.ts b/src/api/__tests__/live-stalwart-files.test.ts index 7c4d7d4e..3a9a2c41 100644 --- a/src/api/__tests__/live-stalwart-files.test.ts +++ b/src/api/__tests__/live-stalwart-files.test.ts @@ -34,6 +34,7 @@ import { CAPABILITIES } from '../types'; import type { FileNode } from '../types'; import { copyFileNode, + copyFileTree, createFolder, deleteFileNodes, destroyEmptyFolders, @@ -161,6 +162,30 @@ live('live Stalwart files', () => { expect(all.filter((n) => [copy.id, box.id, file.id, inner.id].includes(n.id))).toEqual([]); }, 90_000); + it('copies a folder with the folders and files in it', async () => { + const source = await createFolder('Copy me', scratch); + const sub = await createFolder('Inside', source.id); + await uploadFileBatch([pick('top.txt', 'top')], source.id, []); + await uploadFileBatch([pick('deep.txt', 'deeper')], sub.id, []); + const all = await nodes(); + + const copy = await copyFileTree(all.find((n) => n.id === source.id)!, all, scratch, 'Copy me (1)'); + const after = await nodes(); + const copied = after.find((n) => n.id === copy.id)!; + expect(copied).toMatchObject({ name: 'Copy me (1)', parentId: scratch }); + expect(fileNodeKind(copied)).toBe('directory'); + const inCopy = after.filter((n) => n.parentId === copy.id); + expect(inCopy.map((n) => n.name).sort()).toEqual(['Inside', 'top.txt']); + const copiedSub = inCopy.find((n) => n.name === 'Inside')!; + expect(fileNodeKind(copiedSub)).toBe('directory'); + const deep = after.find((n) => n.parentId === copiedSub.id)!; + expect(deep).toMatchObject({ name: 'deep.txt', size: 6 }); + const res = await fetch(getFileNodeDownloadUrl(deep), { headers: { Authorization: client.authHeader } }); + expect(await res.text()).toBe('deeper'); + // The source is untouched. + expect(after.filter((n) => n.parentId === source.id).map((n) => n.name).sort()).toEqual(['Inside', 'top.txt']); + }, 90_000); + it('moves a node to the drive root and back', async () => { const { uploaded } = await uploadFileBatch([pick('root-trip.txt', 'x')], scratch, []); const id = uploaded[0].id; diff --git a/src/api/__tests__/live-stalwart-mail.test.ts b/src/api/__tests__/live-stalwart-mail.test.ts index 9cfe4a25..2c7c805d 100644 --- a/src/api/__tests__/live-stalwart-mail.test.ts +++ b/src/api/__tests__/live-stalwart-mail.test.ts @@ -45,8 +45,9 @@ import { pruneEmailSubmissions } from '../submissions'; import { listPrincipals } from '../files'; import { createContact, deleteContacts, getAddressBooks, getContact } from '../contacts'; import { - createSieveScript, deleteSieveScript, getSieveScriptContent, getSieveScripts, validateSieveScript, + createSieveScript, deleteSieveScript, getSieveScriptContent, getSieveScripts, updateSieveScript, validateSieveScript, } from '../sieve'; +import { useFilterStore } from '../../stores/filter-store'; import { inlineContactMediaForExport, writeContactWithNewPhoto } from '../../lib/contact-media'; import { getBulwarkMetadata, resetBulwarkMetadata, supportsBulwarkMetadata } from '../bulwark-metadata'; import { parseSharedTags, SHARED_TAGS_KEY } from '../../lib/shared-tags'; @@ -405,6 +406,47 @@ live('live Stalwart: mail, contacts, search (0.16 and 1.0)', () => { } }, 60_000); + it('deletes a filter rule at once and puts it back on Undo, in the script on the server', async () => { + // The account's filters as they are, to put back afterwards. + const before = await getSieveScripts(); + const active = before.find((s) => s.isActive && s.name !== 'vacation'); + const original = active ? await getSieveScriptContent(active.blobId) : null; + const activeContent = async () => { + const now = (await getSieveScripts()).find((s) => s.isActive && s.name !== 'vacation'); + return now ? getSieveScriptContent(now.blobId) : ''; + }; + try { + await useFilterStore.getState().selectAccount(null); + expect(useFilterStore.getState().isOpaque, 'the test account has no hand-written filters').toBe(false); + const rule = (id: string) => ({ + id, name: `live ${id} ${RUN}`, enabled: true, matchType: 'all' as const, + conditions: [{ field: 'subject' as const, comparator: 'contains' as const, value: `${id}-${RUN}` }], + actions: [{ type: 'mark_read' as const, value: '' }], stopProcessing: false, + }); + useFilterStore.getState().addRule(rule('one')); + useFilterStore.getState().addRule(rule('two')); + await useFilterStore.getState().saveFilters(); + expect(await activeContent()).toContain(`one-${RUN}`); + + const undo = await useFilterStore.getState().deleteRuleNow('one'); + let content = await activeContent(); + expect(content).not.toContain(`one-${RUN}`); + expect(content).toContain(`two-${RUN}`); + + expect(await undo!()).toBe(true); + content = await activeContent(); + expect(content.indexOf(`one-${RUN}`)).toBeGreaterThan(-1); + expect(content.indexOf(`one-${RUN}`)).toBeLessThan(content.indexOf(`two-${RUN}`)); + } finally { + const after = await getSieveScripts(); + if (active && original !== null) await updateSieveScript(active.id, original, true); + for (const script of after) { + if (!before.some((b) => b.id === script.id)) await deleteSieveScript(script.id).catch(() => undefined); + } + useFilterStore.getState().clearState(); + } + }, 60_000); + it('changes and restores an identity\'s name and signature', async () => { const [identity] = await getIdentities(); const before = { name: identity.name, textSignature: identity.textSignature ?? '' }; diff --git a/src/api/calendar.ts b/src/api/calendar.ts index d66e7471..a21b3ea5 100644 --- a/src/api/calendar.ts +++ b/src/api/calendar.ts @@ -1250,23 +1250,43 @@ export async function createCalendar( const accountId = targetAccountId || jmapClient.accountId; const props: Record = { name, color, isVisible: true, isSubscribed: true }; if (description?.trim()) props.description = description.trim(); + // Stalwart's create answer never carries the name sent, and 1.0 answers + // `color: null` for one it stored. A get in the same request reads the + // calendar back with its rights, as the webmail re-reads it. The creation + // id stands for the new id: 0.16 refuses a result reference into + // `created`, both take `#new-cal`. const res = await jmapClient.request( - [['Calendar/set', { - accountId, - create: { 'new-cal': props }, - }, '0']], + [ + ['Calendar/set', { + accountId, + create: { 'new-cal': props }, + }, '0'], + ['Calendar/get', { + accountId, + ids: ['#new-cal'], + properties: CALENDAR_PROPERTIES, + }, '1'], + ], USING, ); const result = methodResult<{ created?: Record; notCreated?: Record; - }>(res); + }>(res, '0'); const created = result.created?.['new-cal']; if (!created) { const err = result.notCreated?.['new-cal']; throw new Error(err?.description || err?.type || 'Failed to create calendar'); } - return created; + let stored: Calendar | undefined; + try { + stored = methodResult<{ list?: Calendar[] }>(res, '1').list?.find((c) => c.id === created.id); + } catch { + // The calendar exists; what was sent stands in for the read-back. + } + if (stored) return { ...props, ...created, ...stored } as Calendar; + const echoed = Object.fromEntries(Object.entries(created).filter(([, v]) => v !== null)); + return { ...props, ...echoed } as Calendar; } /** diff --git a/src/api/files.ts b/src/api/files.ts index 8907d2e2..64c9c02a 100644 --- a/src/api/files.ts +++ b/src/api/files.ts @@ -723,6 +723,66 @@ export async function copyFileNode( return createFileNodeFromBlob(newName, node.blobId, node.type, parentId); } +/** + * Copy a file, or a folder with everything in it, into `parentId` (webmail + * copyFileNode). A folder node carries no content, so the subtree is made + * again level by level from `tree`, the listing the ids of `node` and + * `parentId` come from; the files in it point at the same blobs. A copy that + * fails part-way is taken back, the deepest level first, without + * onDestroyRemoveChildren: whatever someone else put into its folders + * meanwhile stays. + */ +export async function copyFileTree( + node: FileNode, + tree: FileNode[], + parentId: string | null, + newName: string = node.name, +): Promise { + const kind = fileNodeKind(node); + if (kind === 'symlink') throw new Error('Symbolic links cannot be copied'); + if (kind === 'file') return copyFileNode(node, parentId, newName); + + const childrenOf = new Map(); + for (const entry of tree) { + if (entry.parentId == null) continue; + const siblings = childrenOf.get(entry.parentId); + if (siblings) siblings.push(entry); + else childrenOf.set(entry.parentId, [entry]); + } + // The ids this copy creates, by level below the copy (0 is the copy itself). + const made: string[][] = []; + const seen = new Set([node.id]); + const create = async (source: FileNode, name: string, parent: string | null, level: number): Promise => { + const created = fileNodeKind(source) === 'directory' + ? await createFolder(name, parent) + : await createFileNodeFromBlob(name, source.blobId!, source.type, parent); + (made[level] ??= []).push(created.id); + return created; + }; + const copyChildren = async (folderId: string, copyId: string, level: number): Promise => { + for (const child of childrenOf.get(folderId) ?? []) { + // Symlinks have nothing to copy; a copy inside the source is not copied again. + if (seen.has(child.id) || fileNodeKind(child) === 'symlink') continue; + if (fileNodeKind(child) === 'file' && !child.blobId) continue; + seen.add(child.id); + const created = await create(child, child.name, copyId, level); + if (fileNodeKind(child) === 'directory') await copyChildren(child.id, created.id, level + 1); + } + }; + + const root = await create(node, newName, parentId, 0); + seen.add(root.id); + try { + await copyChildren(node.id, root.id, 1); + } catch (err) { + for (let level = made.length - 1; level >= 0; level--) { + await destroyEmptyFolders(made[level] ?? []).catch(() => []); + } + throw err; + } + return root; +} + // ── Sharing (RFC 9670) ──────────────────────────────────── // Add, update, or remove a principal's rights on an owned FileNode (file or diff --git a/src/api/sieve-rules.ts b/src/api/sieve-rules.ts index 672faf77..03ad09df 100644 --- a/src/api/sieve-rules.ts +++ b/src/api/sieve-rules.ts @@ -33,6 +33,16 @@ export class FiltersLoginChangedError extends Error { } } +/** The account's filters as the server has them now (its active script, parsed). */ +export async function readAccountFilters(accountId: string = getSieveAccountId()): Promise { + const all = await getSieveScripts(accountId); + const scripts = all.filter((s) => s.name !== VACATION_SCRIPT_NAME); + const script = scripts.find((s) => s.isActive) || scripts[0] || null; + return script + ? parseScript(await getSieveScriptContent(script.blobId, accountId)) + : { rules: [], isOpaque: false, externalRequires: [] }; +} + /** * Read the account's script from the server, let `modify` change its rules, * and write the result back as the active script, keeping the vacation diff --git a/src/components/calendar/CalendarEditSheet.tsx b/src/components/calendar/CalendarEditSheet.tsx index eb435147..b6130c0b 100644 --- a/src/components/calendar/CalendarEditSheet.tsx +++ b/src/components/calendar/CalendarEditSheet.tsx @@ -27,6 +27,9 @@ import { radius, spacing, typography, type ThemePalette } from '../../theme/toke import { useColors } from '../../theme/colors'; import { useLocaleStore, type TranslateFn } from '../../stores/locale-store'; import { CALENDAR_COLOR_PALETTE, calendarColorName, getCalendarColor } from '../../lib/calendar-utils'; +import { customColorGrid, normalizeHexColor } from '../../lib/calendar-custom-color'; + +const CUSTOM_COLOR_GRID = customColorGrid(); import Button from '../Button'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; import { useSheetInsets } from '../../lib/use-layout'; @@ -150,6 +153,9 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend const [name, setName] = React.useState(''); const [color, setColor] = React.useState(CALENDAR_COLOR_PALETTE[0]); const [description, setDescription] = React.useState(''); + // The colour as typed in the hex field; a pick from the swatches fills it. + const [hexInput, setHexInput] = React.useState(''); + const [moreColors, setMoreColors] = React.useState(false); const [saving, setSaving] = React.useState(false); const [error, setError] = React.useState(null); // The calendar's default reminders (#1209): the owner's to set, for an @@ -165,7 +171,10 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend React.useEffect(() => { if (!visible) return; setName(calendar?.name ?? ''); - setColor(calendar ? getCalendarColor(calendar) : CALENDAR_COLOR_PALETTE[0]); + const initialColor = calendar ? getCalendarColor(calendar) : CALENDAR_COLOR_PALETTE[0]; + setColor(initialColor); + setHexInput(initialColor); + setMoreColors(false); setDescription(calendar?.description ?? ''); setTimedReminders(initialTimed); setAllDayReminders(initialAllDay); @@ -173,9 +182,15 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend setSaving(false); }, [visible, calendar, initialTimed, initialAllDay]); + const pickColor = (col: string) => { + setColor(col); + setHexInput(col); + }; + const hexInvalid = normalizeHexColor(hexInput) === null; + const handleSave = async () => { const trimmed = name.trim(); - if (!trimmed || saving) return; + if (!trimmed || saving || hexInvalid) return; setSaving(true); setError(null); try { @@ -224,7 +239,7 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend return ( setColor(col)} + onPress={() => pickColor(col)} style={[styles.swatch, { backgroundColor: col }, active && styles.swatchActive]} accessibilityRole="radio" accessibilityLabel={calendarColorName(col, t)} @@ -235,6 +250,62 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend ); })} + + + { + setHexInput(v); + const hex = normalizeHexColor(v); + if (hex) setColor(hex); + }} + placeholder="#3b82f6" + placeholderTextColor={c.textMuted} + autoCapitalize="none" + autoCorrect={false} + maxLength={7} + accessibilityLabel={t('calendar.management.custom_color', 'Custom color (hex)')} + style={[styles.input, styles.hexInput, hexInvalid && styles.inputInvalid]} + /> + setMoreColors((v) => !v)} + style={styles.moreColors} + accessibilityRole="button" + accessibilityState={{ expanded: moreColors }} + > + + {moreColors + ? t('calendar.management.fewer_colors', 'Fewer colors') + : t('calendar.management.more_colors', 'More colors')} + + + + {hexInvalid && ( + + {t('calendar.management.color_invalid', 'Enter a color as # and six hex digits, such as #3b82f6.')} + + )} + {moreColors && ( + + {CUSTOM_COLOR_GRID.map((row, r) => ( + + {row.map((col) => { + const active = color.toLowerCase() === col; + return ( + pickColor(col)} + style={[styles.gridSwatch, { backgroundColor: col }, active && styles.swatchActive]} + accessibilityRole="radio" + accessibilityLabel={col} + accessibilityState={{ selected: active }} + /> + ); + })} + + ))} + + )} {t('calendar.management.description', 'Description')} { void handleSave(); }} - disabled={!name.trim() || saving} + disabled={!name.trim() || saving || hexInvalid} icon={saving ? : undefined} > {calendar ? t('common.save', 'Save') : t('common.create', 'Create')} @@ -336,6 +407,15 @@ function makeStyles(c: ThemePalette) { justifyContent: 'center', }, swatchActive: { borderWidth: 2, borderColor: c.text }, + hexRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, marginTop: spacing.xs }, + hexPreview: { width: 28, height: 28, borderRadius: radius.full, borderWidth: 1, borderColor: c.border }, + hexInput: { flex: 1 }, + inputInvalid: { borderColor: c.error }, + moreColors: { paddingVertical: spacing.xs, paddingHorizontal: spacing.sm }, + moreColorsText: { ...typography.captionMedium, color: c.primary }, + grid: { gap: 4, marginTop: spacing.xs }, + gridRow: { flexDirection: 'row', gap: 4 }, + gridSwatch: { flex: 1, aspectRatio: 1, borderRadius: radius.sm, maxWidth: 28 }, error: { ...typography.caption, color: c.error }, footer: { flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.md }, sectionTitle: { ...typography.bodyMedium, color: c.text, marginTop: spacing.md }, diff --git a/src/components/calendar/CalendarSidebarDrawer.tsx b/src/components/calendar/CalendarSidebarDrawer.tsx index 59ec99e6..f725a8f6 100644 --- a/src/components/calendar/CalendarSidebarDrawer.tsx +++ b/src/components/calendar/CalendarSidebarDrawer.tsx @@ -322,8 +322,9 @@ function Section({ const isSubscription = !!isSubscriptionCalendar?.(cal.id); const isOwn = !cal.isShared && !isBirthday && !isSubscription; const canSetDefault = !!onSetDefault && isOwn && !cal.isDefault; - // Own calendars recolour server-side; shared ones locally (#345). - const canRecolor = !!onSetColor && (!!cal.isShared || isOwn); + // Own calendars recolour server-side; shared ones and the birthdays + // locally (#345). + const canRecolor = !!onSetColor && (!!cal.isShared || isOwn || isBirthday); const canRename = !!onRename && isOwn; const canShare = !!onShare && isOwn && (!cal.myRights || cal.myRights.mayShare !== false); const canClear = !!onClear && isOwn && isWritableCalendar(cal); diff --git a/src/components/calendar/EventBlock.tsx b/src/components/calendar/EventBlock.tsx index 46e3df8c..57cce7c5 100644 --- a/src/components/calendar/EventBlock.tsx +++ b/src/components/calendar/EventBlock.tsx @@ -54,6 +54,8 @@ interface TimedEventBlockProps { /** Position and size in the day column. */ style: StyleProp; onPress?: () => void; + /** What a screen reader says: title, date, time (see lib/calendar-a11y). */ + accessibilityLabel?: string; } /** A timed event in a day column. */ @@ -66,11 +68,14 @@ export function TimedEventBlock({ continuesAfter, style, onPress, + accessibilityLabel, }: TimedEventBlockProps) { const inactive = colors.border !== null; return ( ; onPress?: () => void; + /** What a screen reader says: title, date, time (see lib/calendar-a11y). */ + accessibilityLabel?: string; } /** An all-day or multi-day event in the strip above the grid. */ @@ -124,10 +131,13 @@ export function AllDayEventBar({ continuesAfter, style, onPress, + accessibilityLabel, }: AllDayEventBarProps) { const inactive = colors.border !== null; return ( void; onRsvp?: (event: CalendarEvent, participantId: string, status: RsvpStatus) => void | Promise; + /** Append a quick note to the description (webmail's "Add note"); offered where the event can be edited. */ + onSaveNote?: (event: CalendarEvent, note: string) => Promise; } function formatRange(event: CalendarEvent, timeFormat: TimeFormat | undefined, locale: Locale): string { @@ -134,10 +137,20 @@ export function EventDetailSheet({ onExport, onCopyLink, onRsvp, + onSaveNote, }: EventDetailSheetProps) { const c = useColors(); const { locale, t } = useCalendarLocale(); const [rsvpBusy, setRsvpBusy] = React.useState(false); + const [noteOpen, setNoteOpen] = React.useState(false); + const [noteText, setNoteText] = React.useState(''); + const [noteBusy, setNoteBusy] = React.useState(false); + // A note typed for one event is not left for the next. + const eventId = event?.id; + React.useEffect(() => { + setNoteOpen(false); + setNoteText(''); + }, [eventId]); const styles = React.useMemo(() => makeStyles(c), [c]); // Bare addresses (the organizer above all — Stalwart drops its display // name) render with the contact card's name instead of the raw email. @@ -230,6 +243,26 @@ export function EventDetailSheet({ } }; + const copyTitle = () => { + if (!event) return; + void Clipboard.setStringAsync(event.title || '').then(() => { + toast.success(t('calendar.notifications.title_copied', 'Title copied')); + }).catch(() => {}); + }; + + const saveNote = async () => { + const note = noteText.trim(); + if (!event || !onSaveNote || !note || noteBusy) return; + setNoteBusy(true); + try { + await onSaveNote(event, note); + setNoteText(''); + setNoteOpen(false); + } finally { + setNoteBusy(false); + } + }; + const copyLocation = () => { if (!location) return; void Clipboard.setStringAsync(location).then(() => { @@ -333,6 +366,38 @@ export function EventDetailSheet({ linkify /> ) : null} + {onSaveNote && canEdit && (noteOpen ? ( + + + + { setNoteText(''); setNoteOpen(false); }} style={styles.noteButton} accessibilityRole="button"> + {t('calendar.form.cancel', 'Cancel')} + + { void saveNote(); }} + disabled={!noteText.trim() || noteBusy} + style={[styles.noteButton, (!noteText.trim() || noteBusy) && { opacity: 0.5 }]} + accessibilityRole="button" + > + {t('calendar.detail.save_note', 'Save note')} + + + + ) : ( + setNoteOpen(true)} style={styles.noteToggle} accessibilityRole="button"> + + {t('calendar.detail.add_note', 'Add note')} + + ))} {recurrence && ( } @@ -419,7 +484,7 @@ export function EventDetailSheet({ )} - {(canEdit || onDuplicate || onExport || (onCopyLink && videoUri)) && ( + {( {onEdit && canEdit && ( onExport(event)} /> )} + } + label={t('calendar.events.copy_title_short', 'Title')} + onPress={copyTitle} + /> {onCopyLink && videoUri && ( } @@ -669,6 +739,24 @@ function makeStyles(c: ThemePalette) { joinBtnText: { ...typography.bodyMedium, color: c.primary }, rsvpBlock: { gap: spacing.sm, marginTop: spacing.xs }, + noteToggle: { flexDirection: 'row', alignItems: 'center', gap: spacing.md, paddingVertical: spacing.xs }, + noteToggleText: { ...typography.body, color: c.textMuted }, + noteBlock: { gap: spacing.xs }, + noteInput: { + ...typography.body, + color: c.text, + borderWidth: 1, + borderColor: c.border, + borderRadius: radius.md, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + minHeight: 56, + textAlignVertical: 'top', + }, + noteButtons: { flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.sm }, + noteButton: { paddingHorizontal: spacing.md, paddingVertical: spacing.xs }, + noteCancel: { ...typography.bodyMedium, color: c.textMuted }, + noteSave: { ...typography.bodyMedium, color: c.primary }, rsvpPrompt: { ...typography.bodyMedium, color: c.text }, rsvpButtons: { flexDirection: 'row', gap: spacing.sm }, rsvpBtn: { diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx index f3041203..108eb870 100644 --- a/src/components/calendar/EventModal.tsx +++ b/src/components/calendar/EventModal.tsx @@ -79,6 +79,8 @@ import { SafeAreaModal } from '../SafeAreaModal'; import { useKeyboardObstruction } from '../../lib/use-keyboard-obstruction'; import { editorKeyboardInset } from '../../lib/calendar-editor-keyboard'; import ToggleSwitch from '../ToggleSwitch'; +import { askBeforeDiscard, editorFormChanged, editorFormSnapshot } from '../../lib/calendar-editor-discard'; +import { saveOfferingWithoutInvitations } from '../../lib/calendar-scheduling-denied'; type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom'; @@ -254,12 +256,34 @@ export function EventModal({ const [showEndTime, setShowEndTime] = React.useState(false); const [saving, setSaving] = React.useState(false); + // What the form holds against what it opened with: Close and Back ask + // before they drop changes (webmail 13f9f27c4). + const formSnapshot = React.useMemo(() => editorFormSnapshot({ + title, description, allDay, start, end, calendarId, attendees, recurrence, customRule, + reminders, useDefaults, preserved, sendInvitations, location, videoUrl, + }), [title, description, allDay, start, end, calendarId, attendees, recurrence, customRule, + reminders, useDefaults, preserved, sendInvitations, location, videoUrl]); + const [baseline, setBaseline] = React.useState(null); + // Before the reset below: the reset clears the baseline after this took + // the form of the render before it, and the next render takes the + // seeded form. + React.useEffect(() => { + if (!visible) { + if (baseline !== null) setBaseline(null); + return; + } + if (baseline === null) setBaseline(formSnapshot); + }, [visible, baseline, formSnapshot]); + const dirty = visible && editorFormChanged(baseline, formSnapshot); + const requestClose = () => askBeforeDiscard(dirty, t, onClose); + // Reset the form when the editor opens or switches to another event only. // `calendars` and `currentUserEmails` get new identities when a calendar // push or the alias lookup lands, which used to wipe what the user typed. const formKey = visible ? event?.id ?? 'new' : null; React.useEffect(() => { if (!visible) return; + setBaseline(null); if (event) { setTitle(event.title || ''); setDescription(event.description || ''); @@ -306,7 +330,11 @@ export function EventModal({ // The calendars can arrive after a new-event form opened. React.useEffect(() => { - if (visible && !calendarId && fallbackCalendarId) setCalendarId(fallbackCalendarId); + if (visible && !calendarId && fallbackCalendarId) { + setCalendarId(fallbackCalendarId); + // Not a change of the user's. + setBaseline(null); + } }, [visible, calendarId, fallbackCalendarId]); React.useEffect(() => { @@ -408,14 +436,18 @@ export function EventModal({ for (const key of Object.keys(data) as (keyof CalendarEvent)[]) { if (data[key] === undefined) delete data[key]; } - await onSave(data, calendarId, { - // Only the organizer sends: on an invitation someone else sent, a - // save would reach the organizer as a reply. - sendSchedulingMessages: organizes && attendees.length > 0 && sendInvitations, + // Only the organizer sends: on an invitation someone else sent, a + // save would reach the organizer as a reply. + const send = organizes && attendees.length > 0 && sendInvitations; + // A server that refuses to send the invitations refuses the whole + // save: offer to keep the event without them. + const saved = await saveOfferingWithoutInvitations((sendSchedulingMessages) => onSave(data, calendarId, { + sendSchedulingMessages, // The dates as shown: a series moves by the occurrence's move, on // its own clock (lib/recurrence-scope-edit). edit: { attendees, when: { allDay, start, end }, ruleChanged }, - }); + }), send, t); + if (!saved) return; onClose(); } catch (err) { // The account changed under the save: the event is the previous @@ -492,12 +524,12 @@ export function EventModal({ visible={visible} animationType="slide" presentationStyle="fullScreen" - onRequestClose={onClose} + onRequestClose={requestClose} > onSelectDate(d)} onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined} + accessibilityRole="button" + accessibilityLabel={dayCellAccessibilityLabel(d, dayEvents.length, t, locale)} + accessibilityState={{ selected }} > @@ -341,7 +347,7 @@ function FullWeekRow({ onPress={() => onSelectDate(d)} onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined} accessibilityRole="button" - accessibilityLabel={format(d, 'PPPP', { locale })} + accessibilityLabel={dayCellAccessibilityLabel(d, dayEvents.length, t, locale)} > diff --git a/src/components/calendar/ParticipantInput.tsx b/src/components/calendar/ParticipantInput.tsx index 34162d9b..2b03c95e 100644 --- a/src/components/calendar/ParticipantInput.tsx +++ b/src/components/calendar/ParticipantInput.tsx @@ -10,9 +10,10 @@ import { import { X } from '../icons'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; -import { useContactsStore } from '../../stores/contacts-store'; +import { useContactsStore, type RecipientSuggestion } from '../../stores/contacts-store'; import { useLocaleStore } from '../../stores/locale-store'; import type { Attendee } from '../../lib/calendar-participants'; +import { participantSuggestions, participantsFromSuggestion } from '../../lib/calendar-participant-suggestions'; interface ParticipantInputProps { attendees: Attendee[]; @@ -20,28 +21,10 @@ interface ParticipantInputProps { onRemove: (email: string) => void; } -interface Suggestion { - email: string; - name?: string; -} - function emailRegex(): RegExp { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/; } -function flattenContactEmails(): Suggestion[] { - const contacts = useContactsStore.getState().contacts; - const out: Suggestion[] = []; - for (const c of contacts) { - const name = c.name?.full || undefined; - if (!c.emails) continue; - for (const e of Object.values(c.emails)) { - if (e.address) out.push({ email: e.address, name }); - } - } - return out; -} - // Attendee rows only: the organizer participant is added by // buildParticipantMap on save (see lib/calendar-participants). export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInputProps) { @@ -49,25 +32,29 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); const [draft, setDraft] = React.useState(''); - const [allSuggestions] = React.useState(() => flattenContactEmails()); + // Contacts, groups and recent Sent recipients, as the composer offers them. + const getAutocomplete = useContactsStore((s) => s.getAutocomplete); + const getGroupRecipients = useContactsStore((s) => s.getGroupRecipients); + const contactsVersion = useContactsStore((s) => s.contacts); + const recentVersion = useContactsStore((s) => s.recentRecipients); const existingEmails = React.useMemo(() => { return new Set(attendees.map((a) => a.email.toLowerCase())); }, [attendees]); - const filteredSuggestions = React.useMemo(() => { - const q = draft.trim().toLowerCase(); - if (!q || q.length < 2) return []; - return allSuggestions - .filter((s) => { - if (existingEmails.has(s.email.toLowerCase())) return false; - return ( - s.email.toLowerCase().includes(q) || - s.name?.toLowerCase().includes(q) - ); - }) - .slice(0, 6); - }, [draft, allSuggestions, existingEmails]); + const filteredSuggestions = React.useMemo( + () => participantSuggestions(draft, existingEmails, getAutocomplete, getGroupRecipients), + // contactsVersion/recentVersion re-run the lookup when the store loads. + // eslint-disable-next-line react-hooks/exhaustive-deps + [draft, existingEmails, getAutocomplete, getGroupRecipients, contactsVersion, recentVersion], + ); + + const pickSuggestion = (s: RecipientSuggestion) => { + for (const person of participantsFromSuggestion(s, existingEmails, getGroupRecipients)) { + if (emailRegex().test(person.email.trim())) onAdd({ name: person.name, email: person.email.trim() }); + } + setDraft(''); + }; const addParticipant = (email: string, name?: string) => { const trimmed = email.trim(); @@ -117,6 +104,10 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu autoCapitalize="none" autoCorrect={false} keyboardType="email-address" + // The app's own suggestions only: system autofill would cover them. + autoComplete="off" + importantForAutofill="no" + textContentType="none" onSubmitEditing={handleSubmit} onBlur={handleSubmit} returnKeyType="done" @@ -131,15 +122,19 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu > {filteredSuggestions.map((s) => ( [ styles.suggestionRow, pressed && styles.suggestionRowPressed, ]} - onPress={() => addParticipant(s.email, s.name)} + onPress={() => pickSuggestion(s)} > - {s.name && {s.name}} - {s.email} + {!!s.name && {s.name}} + + {s.group + ? t('calendar.participants.group_members', '{count, plural, one {# member} other {# members}}', { count: s.group.memberCount }) + : s.email} + ))} diff --git a/src/components/calendar/TasksSheet.tsx b/src/components/calendar/TasksSheet.tsx index 5f6e0726..778ce71f 100644 --- a/src/components/calendar/TasksSheet.tsx +++ b/src/components/calendar/TasksSheet.tsx @@ -34,10 +34,12 @@ import { emptyTaskEditor, priorityToLevel, submitTaskEditor, + taskEditorChanged, taskEditorFromTask, type PriorityLevel, type TaskEditorState, } from '../../lib/task-editor'; +import { askBeforeDiscard } from '../../lib/calendar-editor-discard'; import { isWritableCalendar } from '../../lib/calendar-editability'; import { useCalendarLocale } from '../../lib/calendar-locale'; import { formatCalendarDate } from '../../lib/date-format'; @@ -113,10 +115,13 @@ export function TasksSheet({ // Centred on a wide window, clear of side insets in landscape. const sheetInsets = useSheetInsets(); const overlayOpacity = React.useRef(new Animated.Value(0)).current; + // Close, Back, a tap beside the sheet and a drag down ask before they drop + // a changed task (webmail 13f9f27c4); kept, a dragged sheet comes back. + const closeRef = React.useRef<() => void>(onClose); const dragHandlers = useSheetDrag({ slideY, closedY: Dimensions.get('window').height, - onClose, + onClose: React.useCallback(() => closeRef.current(), []), }); const writableCalendars = React.useMemo( @@ -127,6 +132,12 @@ export function TasksSheet({ const [filter, setFilter] = React.useState('all'); const [editor, setEditor] = React.useState(() => emptyTaskEditor(defaultCalendarId)); + // The task as the editor was opened on it, to tell typed changes. + const [baseline, setBaseline] = React.useState(editor); + const seedEditor = (state: TaskEditorState) => { + setEditor(state); + setBaseline(state); + }; const [expanded, setExpanded] = React.useState(false); const [showDatePicker, setShowDatePicker] = React.useState(false); const [showTimePicker, setShowTimePicker] = React.useState(false); @@ -136,10 +147,10 @@ export function TasksSheet({ if (!visible) return; const initial = initialTaskId ? tasks.find((task) => task.id === initialTaskId) : undefined; if (initial) { - setEditor(taskEditorFromTask(initial, defaultCalendarId)); + seedEditor(taskEditorFromTask(initial, defaultCalendarId)); setExpanded(true); } else { - setEditor(emptyTaskEditor(defaultCalendarId)); + seedEditor(emptyTaskEditor(defaultCalendarId)); setExpanded(startNew); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -148,6 +159,7 @@ export function TasksSheet({ React.useEffect(() => { if (visible && !editor.calendarId && defaultCalendarId) { setEditor((e) => ({ ...e, calendarId: defaultCalendarId })); + setBaseline((b) => (b.calendarId ? b : { ...b, calendarId: defaultCalendarId })); } }, [visible, editor.calendarId, defaultCalendarId]); @@ -189,10 +201,17 @@ export function TasksSheet({ }), [tasks]); const resetEditor = () => { - setEditor(emptyTaskEditor(defaultCalendarId)); + seedEditor(emptyTaskEditor(defaultCalendarId)); setExpanded(false); }; + const dirty = visible && taskEditorChanged(baseline, editor); + const springBack = () => { + Animated.spring(slideY, { toValue: 0, useNativeDriver: true, bounciness: 0 }).start(); + }; + const requestClose = () => askBeforeDiscard(dirty, t, onClose, springBack); + closeRef.current = requestClose; + const handleSave = async () => { if (!editor.title.trim() || !editor.calendarId || saving) return; setSaving(true); @@ -235,9 +254,9 @@ export function TasksSheet({ ]; return ( - + - + @@ -250,7 +269,7 @@ export function TasksSheet({ {t('calendar.tasks.label', 'Tasks')} )} {editor.id && ( - + askBeforeDiscard(dirty, t, resetEditor)} style={styles.cancelEdit}> {t('calendar.form.cancel', 'Cancel')} )} @@ -410,9 +429,11 @@ export function TasksSheet({ { - if (!onUpdate) return; - setEditor(taskEditorFromTask(task, defaultCalendarId)); - setExpanded(true); + if (!onUpdate || editor.id === task.id) return; + askBeforeDiscard(dirty, t, () => { + seedEditor(taskEditorFromTask(task, defaultCalendarId)); + setExpanded(true); + }); }} > diff --git a/src/components/calendar/TimeGridScrollView.tsx b/src/components/calendar/TimeGridScrollView.tsx index 8c226637..8ded28a2 100644 --- a/src/components/calendar/TimeGridScrollView.tsx +++ b/src/components/calendar/TimeGridScrollView.tsx @@ -20,6 +20,7 @@ import type { Calendar, CalendarEvent } from '../../api/types'; import { spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { useCalendarLocale } from '../../lib/calendar-locale'; +import { eventAccessibilityLabel } from '../../lib/calendar-a11y'; import { buildEventDayIndex, dayKey, @@ -439,6 +440,7 @@ function TimeGridScrollViewInner({ )} continuesBefore={segment.continuesBefore} continuesAfter={segment.continuesAfter} + accessibilityLabel={eventAccessibilityLabel(segment.event, { t, locale, timeFormat, calendars, currentUserEmails })} onPress={() => onSelectEvent?.(segment.event)} style={{ left: segment.startIndex * colWidth + 1, @@ -592,7 +594,7 @@ const DayColumn = React.memo(function DayColumn({ /** Hour of the "New event" block in this column, or -1. */ draftHour: number; }) { - const { t } = useCalendarLocale(); + const { locale, t } = useCalendarLocale(); const handleLongPress = React.useCallback( (e: GestureResponderEvent) => { onLongPressAt?.(day, hourAtOffset(e.nativeEvent.locationY, HOUR_HEIGHT)); @@ -630,6 +632,7 @@ const DayColumn = React.memo(function DayColumn({ ringColor={colors.background} continuesBefore={continuesBefore} continuesAfter={continuesAfter} + accessibilityLabel={eventAccessibilityLabel(event, { t, locale, timeFormat, calendars, currentUserEmails })} onPress={() => onSelectEvent?.(event)} style={{ top, height, left: `${column * widthPct}%`, width: `${widthPct}%` }} /> diff --git a/src/components/calendar/WeekView.tsx b/src/components/calendar/WeekView.tsx index d3c156d9..0d7718c9 100644 --- a/src/components/calendar/WeekView.tsx +++ b/src/components/calendar/WeekView.tsx @@ -12,6 +12,7 @@ import type { Calendar, CalendarEvent } from '../../api/types'; import { spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { useCalendarLocale } from '../../lib/calendar-locale'; +import { eventAccessibilityLabel } from '../../lib/calendar-a11y'; import { buildEventDayIndex, buildTimedFullDayWeekSegments, @@ -202,6 +203,7 @@ function WeekViewInner({ )} continuesBefore={segment.continuesBefore} continuesAfter={segment.continuesAfter} + accessibilityLabel={eventAccessibilityLabel(segment.event, { t, locale, timeFormat, calendars, currentUserEmails })} onPress={() => onSelectEvent?.(segment.event)} style={{ left: `${leftPct}%`, @@ -277,6 +279,7 @@ function WeekViewInner({ ringColor={c.background} continuesBefore={continuesBefore} continuesAfter={continuesAfter} + accessibilityLabel={eventAccessibilityLabel(event, { t, locale, timeFormat, calendars, currentUserEmails })} onPress={() => onSelectEvent?.(event)} style={{ top, height, left: `${leftPct}%`, width: `${widthPct}%` }} /> diff --git a/src/components/calendar/__tests__/participant-input-wiring.test.ts b/src/components/calendar/__tests__/participant-input-wiring.test.ts new file mode 100644 index 00000000..abd8f2fd --- /dev/null +++ b/src/components/calendar/__tests__/participant-input-wiring.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +// Checked on the source: the suite has no React Native renderer. +const source = fs.readFileSync(path.resolve(__dirname, '../ParticipantInput.tsx'), 'utf8'); + +describe('ParticipantInput wiring', () => { + it('offers no system autofill in the participant address field, as the composer\u2019s fields do', () => { + const input = source.slice(source.indexOf('', source.indexOf(' void; + onClose: () => void; +} + +// The contact list's advanced filters (webmail contact-list.tsx): company, +// job title, location, email domain, birthday month and has email / phone / +// photo, each a three-way toggle (off, must have, must not have). +export function ContactFiltersPanel({ filters, onChange, onClose }: Props) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + const locale = useLocaleStore((s) => s.locale); + const monthNames = React.useMemo(() => { + const fmt = new Intl.DateTimeFormat(locale, { month: 'long' }); + return Array.from({ length: 12 }, (_, i) => fmt.format(new Date(2000, i, 1))); + }, [locale]); + const set = (key: K, value: ContactListFilters[K]) => + onChange({ ...filters, [key]: value }); + + const field = (key: 'organization' | 'jobTitle' | 'location' | 'emailDomain', label: string, placeholder: string) => ( + + {label} + set(key, v)} + placeholder={placeholder} + placeholderTextColor={c.textMuted} + accessibilityLabel={label} + autoCapitalize="none" + autoCorrect={false} + style={styles.input} + /> + + ); + + const toggle = (key: 'hasEmail' | 'hasPhone' | 'hasPhoto', label: string, icon: React.ReactNode) => { + const value: TriState = filters[key]; + return ( + set(key, cycleTri(value))} + style={[styles.chip, value === true && styles.chipOn, value === false && styles.chipNot]} + accessibilityRole="button" + accessibilityState={{ checked: value === null ? false : value === true ? true : 'mixed' }} + > + {icon} + {label} + + ); + }; + + return ( + + + {t('contacts.filters.title', 'Advanced filters')} + {countActiveFilters(filters) > 0 && ( + onChange(EMPTY_CONTACT_FILTERS)} hitSlop={6} accessibilityRole="button"> + {t('contacts.filters.clear', 'Clear')} + + )} + + + + + + {field('organization', t('contacts.filters.organization', 'Company'), t('contacts.filters.organization_placeholder', 'e.g. Acme Corp'))} + {field('jobTitle', t('contacts.filters.job_title', 'Job title'), t('contacts.filters.job_title_placeholder', 'e.g. Designer'))} + + + {field('location', t('contacts.filters.location', 'Location'), t('contacts.filters.location_placeholder', 'City or country'))} + {field('emailDomain', t('contacts.filters.email_domain', 'Email domain'), t('contacts.filters.email_domain_placeholder', 'example.com'))} + + {t('contacts.filters.birthday_month', 'Birthday in')} + + set('birthdayMonth', null)} + style={[styles.chip, filters.birthdayMonth === null && styles.chipOn]} + accessibilityRole="radio" + accessibilityState={{ selected: filters.birthdayMonth === null }} + > + {t('contacts.filters.any_month', 'Any month')} + + {monthNames.map((name, i) => { + const active = filters.birthdayMonth === i + 1; + return ( + set('birthdayMonth', i + 1)} + style={[styles.chip, active && styles.chipOn]} + accessibilityRole="radio" + accessibilityState={{ selected: active }} + > + {name} + + ); + })} + + + {toggle('hasEmail', t('contacts.filters.has_email', 'Has email'), )} + {toggle('hasPhone', t('contacts.filters.has_phone', 'Has phone'), )} + {toggle('hasPhoto', t('contacts.filters.has_photo', 'Has photo'), )} + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + panel: { + marginHorizontal: spacing.lg, + marginBottom: spacing.sm, + padding: spacing.md, + gap: spacing.sm, + borderRadius: radius.md, + borderWidth: 1, + borderColor: c.border, + backgroundColor: c.surface, + }, + header: { flexDirection: 'row', alignItems: 'center', gap: spacing.md }, + title: { ...typography.bodyMedium, color: c.text, flex: 1 }, + clear: { ...typography.captionMedium, color: c.primary }, + row: { flexDirection: 'row', gap: spacing.sm }, + field: { flex: 1, gap: 2 }, + label: { ...typography.caption, color: c.textMuted }, + input: { + ...typography.body, + color: c.text, + borderWidth: 1, + borderColor: c.border, + borderRadius: radius.sm, + paddingHorizontal: spacing.sm, + paddingVertical: 6, + backgroundColor: c.background, + }, + months: { gap: spacing.xs }, + toggles: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs }, + chip: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingHorizontal: spacing.sm, + paddingVertical: 4, + borderRadius: radius.full, + borderWidth: 1, + borderColor: c.border, + backgroundColor: c.background, + }, + chipOn: { backgroundColor: c.primaryBg, borderColor: c.primary }, + chipNot: { backgroundColor: c.surface }, + chipText: { ...typography.caption, color: c.textMuted }, + chipTextOn: { color: c.primary }, + chipTextNot: { textDecorationLine: 'line-through' }, + }); +} diff --git a/src/components/contacts/ContactsSidebarDrawer.tsx b/src/components/contacts/ContactsSidebarDrawer.tsx index 299c0bf3..6e47ecda 100644 --- a/src/components/contacts/ContactsSidebarDrawer.tsx +++ b/src/components/contacts/ContactsSidebarDrawer.tsx @@ -6,8 +6,9 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { - X, Users, Tag, BookUser, Inbox, ChevronDown, ChevronRight, Plus, Share2, Check, + X, Users, Tag, BookUser, Inbox, ChevronDown, ChevronRight, Plus, Share2, Check, Trash2, } from '../icons'; +import { toast } from '../../stores/toast-store'; import type { RootStackParamList } from '../../navigation/types'; import type { AddressBook } from '../../api/types'; import type { ContactCategory } from '../../stores/contacts-store'; @@ -46,6 +47,8 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) { const contacts = useContactsStore((s) => s.contacts); const addressBooks = useContactsStore((s) => s.addressBooks); const renameKeyword = useContactsStore((s) => s.renameKeyword); + const removeKeyword = useContactsStore((s) => s.removeKeyword); + const addKeyword = useContactsStore((s) => s.addKeyword); const totalCount = React.useMemo( () => contacts.filter((c) => !isGroup(c)).length, @@ -150,6 +153,31 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) { } }; + // A category can be put back as it was, so it goes at once with an Undo + // (webmail 1.19 useDeleteCategory). + const deleteCategory = async (keyword: string) => { + if (renameBusy) return; + setRenameBusy(true); + let ids: string[]; + try { + ids = await removeKeyword(keyword); + setRenaming(null); + } catch { + toast.error(t('contacts.category_delete_failed', "Couldn't delete the category")); + return; + } finally { + setRenameBusy(false); + } + toast.success(t('contacts.category_deleted', 'Category "{name}" deleted', { name: keyword }), { + action: { + label: t('contacts.undo', 'Undo'), + onPress: () => { + void addKeyword(keyword, ids).catch(() => toast.error(t('contacts.toast.error_update', 'Failed to update contact'))); + }, + }, + }); + }; + const renderBook = (book: AddressBook & { count: number }) => ( + { void deleteCategory(kw.keyword); }} + hitSlop={6} + style={styles.renameBtn} + accessibilityRole="button" + accessibilityLabel={t('contacts.delete_category', 'Delete category')} + > + + setRenaming(null)} hitSlop={6} diff --git a/src/components/email/CalendarInvitationBanner.tsx b/src/components/email/CalendarInvitationBanner.tsx index 52c94a1d..01f2fb1b 100644 --- a/src/components/email/CalendarInvitationBanner.tsx +++ b/src/components/email/CalendarInvitationBanner.tsx @@ -3,7 +3,7 @@ import { View, Text, StyleSheet, Pressable, ActivityIndicator } from 'react-nati import { openExternalUrl } from '../../lib/open-url'; import { CalendarPlus, Check, HelpCircle, X, MapPin, Video, Clock, CalendarDays, AlertTriangle, - ShieldCheck, ShieldAlert, ChevronDown, + ShieldCheck, ShieldAlert, ChevronDown, Trash2, } from '../icons'; import { format, isSameDay } from 'date-fns'; import type { Calendar, Email, CalendarEvent } from '../../api/types'; @@ -32,6 +32,13 @@ import { canCreateEventsIn } from '../../lib/calendar-editability'; import { getCalendarColor, timePattern } from '../../lib/calendar-utils'; import { getDateFnsLocale } from '../../lib/calendar-locale'; import { useCalendarSubscriptionsStore } from '../../stores/calendar-subscriptions-store'; +import { + canRemoveCancelled as canRemoveCancelledMeeting, + planCancellationRemoval, + removeCancelledMeeting, + sameRecurrenceId, + type CancellationRemoval, +} from '../../lib/calendar-cancellation'; type BannerState = 'loading' | 'parsed' | 'done' | 'error'; type RsvpStatus = 'accepted' | 'tentative' | 'declined'; @@ -76,6 +83,8 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { const subscriptions = useCalendarSubscriptionsStore((s) => s.subscriptions); const importEvents = useCalendarStore((s) => s.importEvents); const rsvpEvent = useCalendarStore((s) => s.rsvpEvent); + const deleteEvent = useCalendarStore((s) => s.deleteEvent); + const updateEvent = useCalendarStore((s) => s.updateEvent); const attachment = React.useMemo(() => findCalendarAttachment(email), [email]); // Login address + identities + aliases, so invitations addressed to an // alias still show the RSVP buttons. Only an invitation looks the aliases up. @@ -97,6 +106,9 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { const [busy, setBusy] = React.useState(false); const [calendarId, setCalendarId] = React.useState(null); const [pickerOpen, setPickerOpen] = React.useState(false); + // What removing a cancelled meeting from the calendar takes, read from the + // events stored with its uid; null until known (webmail #1425). + const [cancelRemoval, setCancelRemoval] = React.useState(null); React.useEffect(() => { let cancelled = false; @@ -139,6 +151,17 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { return () => { cancelled = true; }; }, [invitationKey, enabled, jmapAccountId]); + const isCancellation = state !== 'loading' && method === 'cancel'; + React.useEffect(() => { + setCancelRemoval(null); + if (!isCancellation || !event?.uid) return; + let current = true; + findEventsByUid(event.uid) + .then((stored) => { if (current) setCancelRemoval(planCancellationRemoval(stored, event)); }) + .catch(() => { /* no remove action then */ }); + return () => { current = false; }; + }, [isCancellation, event]); + // Import into the account's default calendar; never into a shared calendar, // an iCal subscription (the next feed sync would delete the event) or a // read-only one. The user can still pick another writable calendar. @@ -255,6 +278,45 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { } }; + // A cancellation that fails authentication, or comes from someone other + // than the organizer without passing it, does not get to remove anything. + const senderAddress = email.from?.[0]?.email?.toLowerCase() ?? ''; + const userIsSender = !!senderAddress && currentUserEmails.some((a) => a.toLowerCase() === senderAddress); + const canRemoveCancelled = canRemoveCancelledMeeting({ + isCancellation, userIsSender, trustLevel: trust?.level, removal: cancelRemoval, + }); + const cancellationOutdated = isCancellation && cancelRemoval?.kind === 'outdated'; + + const handleRemoveCancelled = async () => { + const cancel = event; + if (busy || !cancel.uid) return; + setBusy(true); + setNotice(null); + try { + const removal = await removeCancelledMeeting(cancel, { + findEventsByUid: (uid) => findEventsByUid(uid), + deleteEvent, + updateEvent, + }); + if (removal.kind !== 'delete' && removal.kind !== 'exclude') { + setCancelRemoval(removal); + return; + } + // Occurrences of it in view go as well. + useCalendarStore.setState((s) => ({ + events: s.events.filter((e) => e.uid !== cancel.uid + || (Boolean(cancel.recurrenceId) && !sameRecurrenceId(e, cancel))), + })); + setServerMatch(null); + setCancelRemoval({ kind: 'none' }); + setNotice(t('calendar.invitation.removed_from_calendar', 'Removed from your calendar.')); + } catch { + setNotice(t('calendar.invitation.remove_error', 'Could not remove the event')); + } finally { + setBusy(false); + } + }; + const trustColor = trust?.level === 'warning' ? c.error : trust?.level === 'caution' ? c.warning : c.success; return ( @@ -270,6 +332,11 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { {method === 'cancel' && ( {t('calendar.invitation.cancelled', 'This event was cancelled')} )} + {cancellationOutdated && ( + + {t('calendar.invitation.cancel_outdated', 'Your calendar has a newer version of this event than this cancellation.')} + + )} {method === 'reply' && ( {t('calendar.invitation.reply', 'A participant replied to this invitation')} )} @@ -360,7 +427,21 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { icon={} disabled={busy} onPress={() => ensureImportedAndRsvp('declined')} c={c} styles={styles} /> - ) : !existing && method !== 'reply' ? ( + ) : canRemoveCancelled ? ( + { void handleRemoveCancelled(); }} + disabled={busy} + accessibilityRole="button" + > + {busy ? ( + + ) : ( + + )} + {t('calendar.invitation.remove_from_calendar', 'Remove from calendar')} + + ) : !existing && method !== 'reply' && method !== 'cancel' ? ( { void handleImport(); }} @@ -521,6 +602,19 @@ function makeStyles(c: ThemePalette) { backgroundColor: c.primary, }, importBtnText: { ...typography.bodyMedium, color: c.primaryForeground }, + removeBtn: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + paddingVertical: spacing.sm, + borderRadius: radius.sm, + borderWidth: 1, + borderColor: c.errorBorder, + backgroundColor: c.background, + }, + removeBtnText: { ...typography.bodyMedium, color: c.error }, warnRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs, marginTop: spacing.xs }, warnText: { ...typography.caption, color: c.warning }, }); diff --git a/src/components/filters/FilterRuleModal.tsx b/src/components/filters/FilterRuleModal.tsx index 503cf800..7968b597 100644 --- a/src/components/filters/FilterRuleModal.tsx +++ b/src/components/filters/FilterRuleModal.tsx @@ -42,6 +42,8 @@ import type { FilterActionType, } from '../../lib/sieve/types'; import { SafeAreaModal } from '../SafeAreaModal'; +import { useFilterStore } from '../../stores/filter-store'; +import { forwardsOfEditedRule, ruleForwardLimit } from '../../lib/sieve/forward-limit'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; const ALL_FIELDS: FilterConditionField[] = ['from', 'to', 'cc', 'subject', 'header', 'size', 'body', 'attachment', 'all']; @@ -72,12 +74,19 @@ interface FilterRuleModalProps { mailboxes: Mailbox[]; /** The server can confine a rule to a period (Sieve "date" and "relational"). */ periodsSupported?: boolean; + /** + * The server's cap on forwards per message, and the forwards a message can + * have collected when it reaches this rule / still collect below it (see + * lib/sieve/forward-limit.ts). Left out: read from the filter list the + * Filters settings show. + */ + forwardContext?: { maxRedirects: number | null | undefined; before: number; after: number }; onSave: (rule: FilterRule) => void; onClose: () => void; } export function FilterRuleModal({ - visible, rule: editedRule, initialRule, mailboxes, periodsSupported = false, onSave, onClose, + visible, rule: editedRule, initialRule, mailboxes, periodsSupported = false, forwardContext, onSave, onClose, }: FilterRuleModalProps) { const c = useColors(); const styles = useMemo(() => makeStyles(c), [c]); @@ -120,6 +129,25 @@ export function FilterRuleModal({ const mailboxTargets = useMemo(() => buildMailboxTargets(mailboxes), [mailboxes]); + // A message can collect only so many forwards on the server (#1155, #1208). + const listedRules = useFilterStore((s) => s.rules); + const listedForward = useFilterStore((s) => s.vacationForward); + const listedMaxRedirects = useFilterStore((s) => s.sieveCapabilities?.maxNumberRedirects); + const listedAround = useMemo( + () => forwardsOfEditedRule(listedRules, listedForward, editedRule?.id), + [listedRules, listedForward, editedRule?.id], + ); + const forwardLimit = ruleForwardLimit( + { actions, stopProcessing }, + forwardContext ? forwardContext.maxRedirects : listedMaxRedirects, + forwardContext ?? listedAround, + ); + const ruleForwardCount = actions.filter((a) => a.type === 'forward').length; + const actionOptionsFor = (action: FilterAction) => + action.type === 'forward' || forwardLimit.canAddForward + ? actionTypeOptions + : actionTypeOptions.filter((o) => o.value !== 'forward'); + const fieldOptions = useMemo( () => ALL_FIELDS.map((f) => ({ value: f, label: t(`settings.filters.condition_fields.${f}`, f) })), [t], @@ -407,7 +435,7 @@ export function FilterRuleModal({