mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
Merge branch 'cu-reader' into catch-up-2026-11
# Conflicts: # docs/native-sync-2026-11.md # docs/parity/11-webmail-1.19.md # locales/rn/en.json # src/stores/settings-store.ts
This commit is contained in:
@@ -137,7 +137,7 @@ current lines is in "Webmail bugs" at the end of this file.
|
|||||||
- [x] **U-133 no way out of a hung "Signing in"** — `P2` — `missing` — src/screens/LoginScreen.tsx:218 swallows Back while busy, SigningInStep has no cancel; abortable sign-in, M — fixed in 55d008a2
|
- [x] **U-133 no way out of a hung "Signing in"** — `P2` — `missing` — src/screens/LoginScreen.tsx:218 swallows Back while busy, SigningInStep has no cancel; abortable sign-in, M — fixed in 55d008a2
|
||||||
- [ ] **U-069 recipient suggestions hidden in phone landscape** — `P2` — `missing` — needs a full-screen picker, M (feature)
|
- [ ] **U-069 recipient suggestions hidden in phone landscape** — `P2` — `missing` — needs a full-screen picker, M (feature)
|
||||||
- [x] **U-045 German Prev/Next read "Zurück"/"Weiter"** — `P3` — `bug` — override in locales/rn/de.json — fixed in 25f9b810
|
- [x] **U-045 German Prev/Next read "Zurück"/"Weiter"** — `P3` — `bug` — override in locales/rn/de.json — fixed in 25f9b810
|
||||||
- [ ] **Plain-text mail dark background fixed at #0a0a0a** — `P3` — `partial` — src/lib/email-html.ts:963 ignores the theme
|
- [x] **Plain-text mail dark background fixed at #0a0a0a** — `P3` — `partial` — src/lib/email-html.ts:963 ignores the theme — fixed in 43973608
|
||||||
- [ ] **U-017 system alert vs Dialog, U-136 empty all-day strip, U-124 language order** — product calls, kept deferred unless you say otherwise
|
- [ ] **U-017 system alert vs Dialog, U-136 empty all-day strip, U-124 language order** — product calls, kept deferred unless you say otherwise
|
||||||
- U-016 scrim token: won't fix (no visible change). U-018 font scale resets navigation: React Native limitation. U-131: done (776447dd).
|
- U-016 scrim token: won't fix (no visible change). U-018 font scale resets navigation: React Native limitation. U-131: done (776447dd).
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ current lines is in "Webmail bugs" at the end of this file.
|
|||||||
### GitHub (bulwarkmail/native)
|
### GitHub (bulwarkmail/native)
|
||||||
|
|
||||||
- Fixed on main, unreleased (close after 0.1.87 ships): #80, #82, #83, #84, #86, #87, #89. #81 is fixed in code (01983a38), unchecked on iOS.
|
- Fixed on main, unreleased (close after 0.1.87 ships): #80, #82, #83, #84, #86, #87, #89. #81 is fixed in code (01983a38), unchecked on iOS.
|
||||||
- [ ] **#91 multi-select without long press** — `P3` — `missing` — EmailListScreen.tsx:190-218; tap on the avatar toggles selection
|
- [x] **#91 multi-select without long press** — `P3` — `missing` — EmailListScreen.tsx:190-218; tap on the avatar toggles selection — fixed in 6715df7f
|
||||||
- [x] **#90 custom calendar colours** — `P3` — `missing` — CalendarEditSheet.tsx:222 offers the palette only — fixed in d3f6d6ae
|
- [x] **#90 custom calendar colours** — `P3` — `missing` — CalendarEditSheet.tsx:222 offers the palette only — fixed in d3f6d6ae
|
||||||
- [ ] **#85 keep the shade open on Delete** — question 1
|
- [ ] **#85 keep the shade open on Delete** — question 1
|
||||||
- [ ] **#58 PGP / S/MIME** — `P3` — L, not in this run
|
- [ ] **#58 PGP / S/MIME** — `P3` — L, not in this run
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ Stalwart 1.0.0 items found in the same pass (1.0 SSO scopes, Files on 1.0, #1385
|
|||||||
- What RN does: reads it from the fragment (`src/lib/oauth.ts:247`); any app can claim a custom scheme.
|
- What RN does: reads it from the fragment (`src/lib/oauth.ts:247`); any app can claim a custom scheme.
|
||||||
- Fix hint: hand back a one-time code and redeem it like QR pairing (`src/lib/oauth.ts:435`, `/api/auth/pair/redeem`). Needs the webmail side first.
|
- Fix hint: hand back a one-time code and redeem it like QR pairing (`src/lib/oauth.ts:435`, `/api/auth/pair/redeem`). Needs the webmail side first.
|
||||||
|
|
||||||
- [ ] **Sender favicons are fetched from icons.duckduckgo.com on the device** — `P3` — `partial` (privacy)
|
- [x] **Sender favicons are fetched from icons.duckduckgo.com on the device** — `P3` — `partial` (privacy) — fixed in 8e10e73e (off by default; the push background task keeps its own default, see the reader report)
|
||||||
- What WEB does: proxies favicons on the server (`app/api/favicon/route.ts:484`).
|
- What WEB does: proxies favicons on the server (`app/api/favicon/route.ts:484`).
|
||||||
- What RN does: fetches directly, on by default (`src/lib/avatar-utils.ts:149`, `src/stores/settings-store.ts:312`), leaking sender domains and the device IP.
|
- What RN does: fetches directly, on by default (`src/lib/avatar-utils.ts:149`, `src/stores/settings-store.ts:312`), leaking sender domains and the device IP.
|
||||||
- Fix hint: default off, or use the paired webmail's proxy.
|
- Fix hint: default off, or use the paired webmail's proxy.
|
||||||
@@ -116,31 +116,31 @@ Stalwart 1.0.0 items found in the same pass (1.0 SSO scopes, Files on 1.0, #1385
|
|||||||
- What WEB does: creates Archive and reuses year/month folders (`stores/email-store.ts:1332`, `:2561`).
|
- What WEB does: creates Archive and reuses year/month folders (`stores/email-store.ts:1332`, `:2561`).
|
||||||
- What RN does: returns silently (`src/stores/email-store.ts:1594`, `:1729`) or throws (`src/api/unified-inbox.ts:546`).
|
- What RN does: returns silently (`src/stores/email-store.ts:1594`, `:1729`) or throws (`src/api/unified-inbox.ts:546`).
|
||||||
|
|
||||||
- [ ] **A server's own "all" mailbox is included in All mail/Unread (#1241)** — `P3` — `bug`
|
- [x] **A server's own "all" mailbox is included in All mail/Unread (#1241)** — `P3` — `bug` — fixed in 43bb5e47
|
||||||
- What WEB does: role `all` excluded (`lib/jmap/types.ts:1158`). What RN does: `src/api/unified-inbox.ts:34`, `:354`.
|
- What WEB does: role `all` excluded (`lib/jmap/types.ts:1158`). What RN does: `src/api/unified-inbox.ts:34`, `:354`.
|
||||||
|
|
||||||
- [ ] **Tag views and counts include Trash and Spam (#1156)** — `P3` — `bug`
|
- [x] **Tag views and counts include Trash and Spam (#1156)** — `P3` — `bug` — fixed in eb6bf6d3
|
||||||
- What WEB does: `lib/unified-mailbox.ts:104`, `:577`. What RN does: `src/api/tag-counts.ts:37-48`, `src/stores/email-store.ts:455`.
|
- What WEB does: `lib/unified-mailbox.ts:104`, `:577`. What RN does: `src/api/tag-counts.ts:37-48`, `src/stores/email-store.ts:455`.
|
||||||
|
|
||||||
- [x] **User folders named like system folders get system icons (#1235, native #80)** — `P3` — `bug` — fixed in 96c7c045
|
- [x] **User folders named like system folders get system icons (#1235, native #80)** — `P3` — `bug` — fixed in 96c7c045
|
||||||
- What WEB does: icons by role only (`components/layout/sidebar.tsx:168`). What RN does: name matching fallback (`src/components/SidebarDrawer.tsx:92`).
|
- What WEB does: icons by role only (`components/layout/sidebar.tsx:168`). What RN does: name matching fallback (`src/components/SidebarDrawer.tsx:92`).
|
||||||
|
|
||||||
- [ ] **Sort by "Date sent" falls back to received date in merged lists (#1219)** — `P3` — `bug`
|
- [x] **Sort by "Date sent" falls back to received date in merged lists (#1219)** — `P3` — `bug` — fixed in 04a962a6
|
||||||
- What WEB does: lists fetch sentAt (`lib/jmap/client.ts:443`). What RN does: EMAIL_LIST_PROPERTIES lacks it (`src/api/email.ts:22`; comparator `src/lib/message-list-order.ts:225`).
|
- What WEB does: lists fetch sentAt (`lib/jmap/client.ts:443`). What RN does: EMAIL_LIST_PROPERTIES lacks it (`src/api/email.ts:22`; comparator `src/lib/message-list-order.ts:225`).
|
||||||
|
|
||||||
- [ ] **Previews start with a style sheet (#1136)** — `P3` — `bug`
|
- [x] **Previews start with a style sheet (#1136)** — `P3` — `bug` — fixed in 219aef04 (list, unified list, conversation cards; notifications: platform)
|
||||||
- What WEB does: cleanPreview (`lib/utils.ts:320`, `:363`). What RN does: `src/screens/EmailListScreen.tsx:270`, `src/lib/push-background-task.ts:513`.
|
- What WEB does: cleanPreview (`lib/utils.ts:320`, `:363`). What RN does: `src/screens/EmailListScreen.tsx:270`, `src/lib/push-background-task.ts:513`.
|
||||||
|
|
||||||
- [ ] **Paperclip on messages with only inline images** — `P3` — `bug`
|
- [x] **Paperclip on messages with only inline images** — `P3` — `bug` — fixed in 4470a4ba (folder list)
|
||||||
- What WEB does: `components/email/attachment-chips.tsx:165`. What RN does: `src/screens/EmailListScreen.tsx:237`, `src/screens/UnifiedInboxScreen.tsx:358` use hasAttachment.
|
- What WEB does: `components/email/attachment-chips.tsx:165`. What RN does: `src/screens/EmailListScreen.tsx:237`, `src/screens/UnifiedInboxScreen.tsx:358` use hasAttachment.
|
||||||
|
|
||||||
- [ ] **List row grows when its attachment chips arrive** — `P3` — `bug`
|
- [x] **List row grows when its attachment chips arrive** — `P3` — `bug` — fixed in 4470a4ba
|
||||||
- What WEB does: reserves the row (7b25d3a95). What RN does: `src/components/email/ListAttachmentChips.tsx:89`.
|
- What WEB does: reserves the row (7b25d3a95). What RN does: `src/components/email/ListAttachmentChips.tsx:89`.
|
||||||
|
|
||||||
- [ ] **Cold start reopens the last folder or search instead of the inbox** — `P3` — `bug`
|
- [x] **Cold start reopens the last folder or search instead of the inbox** — `P3` — `bug` — fixed in 9067908f
|
||||||
- What WEB does: 64b39c504. What RN does: `src/stores/email-store.ts:2195`, `:829`.
|
- What WEB does: 64b39c504. What RN does: `src/stores/email-store.ts:2195`, `:829`.
|
||||||
|
|
||||||
- [ ] **List keeps its scroll position on folder, tag or search switch (#1138)** — `P3` — `bug` (from the code, not seen on a device)
|
- [x] **List keeps its scroll position on folder, tag or search switch (#1138)** — `P3` — `bug` (from the code, not seen on a device) — fixed in aa25d08d
|
||||||
- What RN does: `src/screens/EmailListScreen.tsx:1590`.
|
- What RN does: `src/screens/EmailListScreen.tsx:1590`.
|
||||||
|
|
||||||
- [ ] **Unified inbox live updates for other logins (#1350)** — `P3` — `partial`
|
- [ ] **Unified inbox live updates for other logins (#1350)** — `P3` — `partial`
|
||||||
@@ -152,7 +152,7 @@ Stalwart 1.0.0 items found in the same pass (1.0 SSO scopes, Files on 1.0, #1385
|
|||||||
- [ ] **Hide shared mail accounts (#1228); remove from this folder (#615); folder name on all-folder results (#1172); own folders section (#1205); per-folder counter (#982); hide account dot (#912)** — `P3` — `missing`
|
- [ ] **Hide shared mail accounts (#1228); remove from this folder (#615); folder name on all-folder results (#1172); own folders section (#1205); per-folder counter (#982); hide account dot (#912)** — `P3` — `missing`
|
||||||
- What WEB does: `lib/shared-mail-visibility.ts:19`, `hooks/use-remove-from-folder.ts:15`, `stores/settings-store.ts:695`. What RN does: `src/components/SidebarDrawer.tsx:124-150`, `:672`; `src/screens/EmailListScreen.tsx:103`.
|
- What WEB does: `lib/shared-mail-visibility.ts:19`, `hooks/use-remove-from-folder.ts:15`, `stores/settings-store.ts:695`. What RN does: `src/components/SidebarDrawer.tsx:124-150`, `:672`; `src/screens/EmailListScreen.tsx:103`.
|
||||||
|
|
||||||
- [ ] **Search folder picker is flat and capped at 12** — `P3` — `partial`
|
- [x] **Search folder picker is flat and capped at 12** — `P3` — `partial` — fixed in e5c9433a
|
||||||
- What WEB does: `lib/search-scope-folders.ts`. What RN does: `src/screens/EmailListScreen.tsx:1729-1740` (`slice(0, 12)`).
|
- What WEB does: `lib/search-scope-folders.ts`. What RN does: `src/screens/EmailListScreen.tsx:1729-1740` (`slice(0, 12)`).
|
||||||
|
|
||||||
- [ ] **Tags without a definition are grey; no account tint (#1052); tag visibility, nesting, order** — `P3` — `partial`
|
- [ ] **Tags without a definition are grey; no account tint (#1052); tag visibility, nesting, order** — `P3` — `partial`
|
||||||
@@ -167,7 +167,7 @@ Stalwart 1.0.0 items found in the same pass (1.0 SSO scopes, Files on 1.0, #1385
|
|||||||
- [ ] **RTL swipe sides** — `P3` — `partial` (old 08 item; web doesn't swap either)
|
- [ ] **RTL swipe sides** — `P3` — `partial` (old 08 item; web doesn't swap either)
|
||||||
- What RN does: `src/components/SwipeableRow.tsx:353`.
|
- What RN does: `src/components/SwipeableRow.tsx:353`.
|
||||||
|
|
||||||
- [ ] **`/mail/folder/<ref>` deep link opens only the tab** — `P3` — `bug`
|
- [x] **`/mail/folder/<ref>` deep link opens only the tab** — `P3` — `bug` — fixed in ed490b71 (folder of the active account; `?account=` switches first as before)
|
||||||
- What RN does: `src/navigation/linking.ts:134` parses it, `:285-287` drops it.
|
- What RN does: `src/navigation/linking.ts:134` parses it, `:285-287` drops it.
|
||||||
|
|
||||||
### Reading mail
|
### Reading mail
|
||||||
@@ -189,10 +189,10 @@ Stalwart 1.0.0 items found in the same pass (1.0 SSO scopes, Files on 1.0, #1385
|
|||||||
- What WEB does: `lib/verification-code.ts:275`, `components/email/email-viewer.tsx:1378`, setting at `stores/settings-store.ts:540`.
|
- What WEB does: `lib/verification-code.ts:275`, `components/email/email-viewer.tsx:1378`, setting at `stores/settings-store.ts:540`.
|
||||||
- What RN does: nothing (`src/components/email/MessageHeader.tsx`, `src/screens/EmailListScreen.tsx`).
|
- What RN does: nothing (`src/components/email/MessageHeader.tsx`, `src/screens/EmailListScreen.tsx`).
|
||||||
|
|
||||||
- [ ] **A mail full of blockquotes freezes the reader** — `P3` — `bug`
|
- [x] **A mail full of blockquotes freezes the reader** — `P3` — `bug` — fixed in 3243714d
|
||||||
- What WEB does: single walk (`lib/quote-collapse.ts:92`). What RN does: quadratic walk (`src/lib/quote-collapse.ts:95-112`).
|
- What WEB does: single walk (`lib/quote-collapse.ts:92`). What RN does: quadratic walk (`src/lib/quote-collapse.ts:95-112`).
|
||||||
|
|
||||||
- [ ] **Attachment names keep direction-override characters** — `P3` — `bug`
|
- [x] **Attachment names keep direction-override characters** — `P3` — `bug` — fixed in a930d177
|
||||||
- What WEB does: `lib/bidi.ts:10`. What RN does: `src/lib/attachment-display.ts:34`, `src/lib/email-export.ts:56`.
|
- What WEB does: `lib/bidi.ts:10`. What RN does: `src/lib/attachment-display.ts:34`, `src/lib/email-export.ts:56`.
|
||||||
|
|
||||||
- [x] **Part names that are paths (#1371, #1373)** — `P3` — `bug` — fixed in 4d545f8f
|
- [x] **Part names that are paths (#1371, #1373)** — `P3` — `bug` — fixed in 4d545f8f
|
||||||
@@ -201,7 +201,7 @@ Stalwart 1.0.0 items found in the same pass (1.0 SSO scopes, Files on 1.0, #1385
|
|||||||
- [ ] **Dark mode recolours instead of inverting** — `P3` — `missing`
|
- [ ] **Dark mode recolours instead of inverting** — `P3` — `missing`
|
||||||
- What WEB does: `lib/email-dark-mode.ts:48`, `:71`. What RN does: `filter: invert(1) hue-rotate(180deg)` (`src/lib/email-html.ts:500`); mail's own dark styles follow the system scheme (`:640`).
|
- What WEB does: `lib/email-dark-mode.ts:48`, `:71`. What RN does: `filter: invert(1) hue-rotate(180deg)` (`src/lib/email-html.ts:500`); mail's own dark styles follow the system scheme (`:640`).
|
||||||
|
|
||||||
- [ ] **"via" badge repeats the sender's address** — `P3` — `bug`
|
- [x] **"via" badge repeats the sender's address** — `P3` — `bug` — fixed in fac54b41
|
||||||
- What WEB does: `components/email/email-identity-badge.tsx:69`. What RN does: `src/components/email/MessageHeader.tsx:116-117`, `:223`.
|
- What WEB does: `components/email/email-identity-badge.tsx:69`. What RN does: `src/components/email/MessageHeader.tsx:116-117`, `:223`.
|
||||||
|
|
||||||
- [ ] **Fixed-width tables don't reflow (#1020)** — `P3` — `partial`
|
- [ ] **Fixed-width tables don't reflow (#1020)** — `P3` — `partial`
|
||||||
@@ -210,7 +210,7 @@ Stalwart 1.0.0 items found in the same pass (1.0 SSO scopes, Files on 1.0, #1385
|
|||||||
- [ ] **Invitation banner: View in calendar, collapse, counter-proposal review** — `P3` — `missing` (old 03 item)
|
- [ ] **Invitation banner: View in calendar, collapse, counter-proposal review** — `P3` — `missing` (old 03 item)
|
||||||
- What WEB does: `components/email/calendar-invitation-banner.tsx:727`, `:463`, `:245`. What RN does: `src/components/email/CalendarInvitationBanner.tsx:153`.
|
- What WEB does: `components/email/calendar-invitation-banner.tsx:727`, `:463`, `:245`. What RN does: `src/components/email/CalendarInvitationBanner.tsx:153`.
|
||||||
|
|
||||||
- [ ] **Quick reply can be turned off (#1370)** — `P3` — `missing`
|
- [x] **Quick reply can be turned off (#1370)** — `P3` — `missing` — fixed in 56e4a1e0 (setting `quickReplyEnabled`; the switch is the platform agent's)
|
||||||
- What WEB does: `stores/settings-store.ts:560`. What RN does: always rendered (`src/screens/EmailThreadScreen.tsx:1233`).
|
- What WEB does: `stores/settings-store.ts:560`. What RN does: always rendered (`src/screens/EmailThreadScreen.tsx:1233`).
|
||||||
|
|
||||||
### Composer and sending
|
### Composer and sending
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Classification: `bug` = fixed in the webmail, still live in the app; `missing`;
|
|||||||
|
|
||||||
### Reading mail
|
### Reading mail
|
||||||
|
|
||||||
- [ ] **A forged From keeps the real sender's favicon and contact photo** — `P2` — `bug`
|
- [x] **A forged From keeps the real sender's favicon and contact photo** — `P2` — `bug` — fixed in 0b0fc021
|
||||||
- What WEB does: when the sender check fails or is unverified, the avatar shows a warning shield instead of a photo, logo or initials, in the reader, the conversation and the list (`components/ui/avatar.tsx:323`, `hooks/use-sender-warning.ts:13`, `components/email/email-viewer.tsx:721`). The list fetch asks for `header:Authentication-Results:all` (`lib/jmap/client.ts:461`). 605a3983b (#1439).
|
- What WEB does: when the sender check fails or is unverified, the avatar shows a warning shield instead of a photo, logo or initials, in the reader, the conversation and the list (`components/ui/avatar.tsx:323`, `hooks/use-sender-warning.ts:13`, `components/email/email-viewer.tsx:721`). The list fetch asks for `header:Authentication-Results:all` (`lib/jmap/client.ts:461`). 605a3983b (#1439).
|
||||||
- What RN does: MessageHeader works out the verdict (`src/components/email/MessageHeader.tsx:118`) but renders SenderAvatar with the claimed address (`:175`); the same in thread cards (`src/components/email/ThreadMessageCard.tsx:50`) and list rows (`src/screens/EmailListScreen.tsx:213`). SenderAvatar picks a contact photo or the domain favicon (`src/components/SenderAvatar.tsx:62-65`). List rows have no auth data (`src/api/email.ts:23-26`).
|
- What RN does: MessageHeader works out the verdict (`src/components/email/MessageHeader.tsx:118`) but renders SenderAvatar with the claimed address (`:175`); the same in thread cards (`src/components/email/ThreadMessageCard.tsx:50`) and list rows (`src/screens/EmailListScreen.tsx:213`). SenderAvatar picks a contact photo or the domain favicon (`src/components/SenderAvatar.tsx:62-65`). List rows have no auth data (`src/api/email.ts:23-26`).
|
||||||
- Fix hint: pass a sender warning from getSenderVerification into SenderAvatar and draw a shield; for list rows add `header:Authentication-Results:all` to the list properties.
|
- Fix hint: pass a sender warning from getSenderVerification into SenderAvatar and draw a shield; for list rows add `header:Authentication-Results:all` to the list properties.
|
||||||
@@ -29,17 +29,17 @@ Classification: `bug` = fixed in the webmail, still live in the app; `missing`;
|
|||||||
- What RN does: nothing; the only banners above the body are unsubscribe and invitation (`src/components/email/MessageContent.tsx:104`, `:112`).
|
- What RN does: nothing; the only banners above the body are unsubscribe and invitation (`src/components/email/MessageContent.tsx:104`, `:112`).
|
||||||
- Fix hint: port `lib/structured-mail/` with an RN-side HTML parser passed through `parseHtml` (`lib/structured-mail/index.ts:4-5`, `:21`) and build RN cards. The admin switch needs the org policy, so default on.
|
- Fix hint: port `lib/structured-mail/` with an RN-side HTML parser passed through `parseHtml` (`lib/structured-mail/index.ts:4-5`, `:21`) and build RN cards. The admin switch needs the org policy, so default on.
|
||||||
|
|
||||||
- [ ] **"Plain Text Only" says it also changes reading, but the reader ignores it** — `P2` — `bug`
|
- [x] **"Plain Text Only" says it also changes reading, but the reader ignores it** — `P2` — `bug` — fixed in 7dc45778
|
||||||
- What WEB does: reads HTML mail as plain text by default or per message (`lib/html-to-readable-text.ts:39`, `components/email/email-viewer.tsx`; 1e929b0a9).
|
- What WEB does: reads HTML mail as plain text by default or per message (`lib/html-to-readable-text.ts:39`, `components/email/email-viewer.tsx`; 1e929b0a9).
|
||||||
- What RN does: the switch reads "Compose and read in plain text only" (`src/components/settings/ReadingSettings.tsx:208-210`), but only the composer and the placeholder check read plainTextMode (`src/lib/compose-html.ts:175`, `src/lib/placeholder-check.ts:157`); EmailBodyView never does (`src/components/EmailBodyView.tsx:529-537`).
|
- What RN does: the switch reads "Compose and read in plain text only" (`src/components/settings/ReadingSettings.tsx:208-210`), but only the composer and the placeholder check read plainTextMode (`src/lib/compose-html.ts:175`, `src/lib/placeholder-check.ts:157`); EmailBodyView never does (`src/components/EmailBodyView.tsx:529-537`).
|
||||||
- Fix hint: render the text part (or the HTML turned into text) when the setting is on, and add a per-message "Show as plain text".
|
- Fix hint: render the text part (or the HTML turned into text) when the setting is on, and add a per-message "Show as plain text".
|
||||||
|
|
||||||
- [ ] **Mail body ignores the app's text size and iOS Dynamic Type** — `P2` — `missing`
|
- [x] **Mail body ignores the app's text size and iOS Dynamic Type** — `P2` — `missing` — fixed in 7b551fc3, a3650ebf
|
||||||
- What WEB does: bodies follow the chosen text size, with a separate mail text size and a minimum font size (`lib/mail-reading.ts:32`, `:42`; c07ba1a37).
|
- What WEB does: bodies follow the chosen text size, with a separate mail text size and a minimum font size (`lib/mail-reading.ts:32`, `:42`; c07ba1a37).
|
||||||
- What RN does: the body is fixed at 14 px for HTML and plain text (`src/lib/email-html.ts:695`, `:979`); the app's text size scales only the app's own UI (`src/theme/dynamic.ts:48-69`). Android's WebView may follow the system font size (no textZoom set; unverified), iOS doesn't. Pinch zoom exists (`src/components/EmailBodyView.tsx:408`).
|
- What RN does: the body is fixed at 14 px for HTML and plain text (`src/lib/email-html.ts:695`, `:979`); the app's text size scales only the app's own UI (`src/theme/dynamic.ts:48-69`). Android's WebView may follow the system font size (no textZoom set; unverified), iOS doesn't. Pinch zoom exists (`src/components/EmailBodyView.tsx:408`).
|
||||||
- Fix hint: scale the body's base font size by the app setting and `PixelRatio.getFontScale()`.
|
- Fix hint: scale the body's base font size by the app setting and `PixelRatio.getFontScale()`.
|
||||||
|
|
||||||
- [ ] **Reading aids: paused animated images, reduce motion in mail, reader view, read aloud, reading width** — `P3` — `missing`
|
- [ ] **Reading aids: paused animated images, reduce motion in mail, reader view, read aloud, reading width** — `P3` — `missing` — reduce motion and paused animated images fixed in 6d701c4b; not done: reader view, read aloud and reading width are left to the OS
|
||||||
- What WEB does: animations in mail stop with reduce motion (24a13cc1f), animated images pause behind a play button (bb7869798), plus reader view, read aloud, reading mask and width (caed2d313, af3019ea2, a982c3f23, 8cc0f8f25, dcc0d600f).
|
- What WEB does: animations in mail stop with reduce motion (24a13cc1f), animated images pause behind a play button (bb7869798), plus reader view, read aloud, reading mask and width (caed2d313, af3019ea2, a982c3f23, 8cc0f8f25, dcc0d600f).
|
||||||
- What RN does: none. The app reads reduce motion (`src/theme/dynamic.ts:92`) but doesn't pass it into mail.
|
- What RN does: none. The app reads reduce motion (`src/theme/dynamic.ts:92`) but doesn't pass it into mail.
|
||||||
- Fix hint: pass reduce motion into the body CSS (animation off, GIFs paused) first; read aloud and reader view are left to the OS.
|
- Fix hint: pass reduce motion into the body CSS (animation off, GIFs paused) first; read aloud and reader view are left to the OS.
|
||||||
@@ -49,54 +49,54 @@ Classification: `bug` = fixed in the webmail, still live in the app; `missing`;
|
|||||||
- What RN does: contact photo, DuckDuckGo favicon, initials (`src/components/SenderAvatar.tsx:38`, `:62-65`; `src/lib/avatar-utils.ts:149`).
|
- What RN does: contact photo, DuckDuckGo favicon, initials (`src/components/SenderAvatar.tsx:38`, `:62-65`; `src/lib/avatar-utils.ts:149`).
|
||||||
- Fix hint: use the paired webmail's `/api/bimi` once the app keeps the webmail URL (same blocker as the org policy, file 10).
|
- Fix hint: use the paired webmail's `/api/bimi` once the app keeps the webmail URL (same blocker as the org policy, file 10).
|
||||||
|
|
||||||
- [ ] **A DKIM pass reported only by `header.i` doesn't count (#1420)** — `P3` — `bug`
|
- [x] **A DKIM pass reported only by `header.i` doesn't count (#1420)** — `P3` — `bug` — fixed in 49d43729
|
||||||
- What WEB does: takes the signer domain from `header.i` when `header.d` is missing (`lib/email-headers.ts:282`, `:346`). f8f2e93cd.
|
- What WEB does: takes the signer domain from `header.i` when `header.d` is missing (`lib/email-headers.ts:282`, `:346`). f8f2e93cd.
|
||||||
- What RN does: reads `header.d` only (`src/lib/email-headers.ts:397`); such mail shows as unverified and its invitations lose trust.
|
- What RN does: reads `header.d` only (`src/lib/email-headers.ts:397`); such mail shows as unverified and its invitations lose trust.
|
||||||
- Fix hint: port identityDomain.
|
- Fix hint: port identityDomain.
|
||||||
|
|
||||||
- [ ] **Reply and quick reply are offered on drafts and scheduled messages in a conversation** — `P3` — `bug`
|
- [x] **Reply and quick reply are offered on drafts and scheduled messages in a conversation** — `P3` — `bug` — fixed in 35297379
|
||||||
- What WEB does: hides Reply and Reply all on a conversation's drafts and scheduled messages (`components/email/email-viewer.tsx:881`). 8b29254a6 (#1404).
|
- What WEB does: hides Reply and Reply all on a conversation's drafts and scheduled messages (`components/email/email-viewer.tsx:881`). 8b29254a6 (#1404).
|
||||||
- What RN does: every card is replyable (`src/screens/EmailThreadScreen.tsx:1212`, `src/components/email/ThreadMessageCard.tsx:68-85`); quick reply targets the last member, which can be the user's own draft (`src/screens/EmailThreadScreen.tsx:1124`).
|
- What RN does: every card is replyable (`src/screens/EmailThreadScreen.tsx:1212`, `src/components/email/ThreadMessageCard.tsx:68-85`); quick reply targets the last member, which can be the user's own draft (`src/screens/EmailThreadScreen.tsx:1124`).
|
||||||
- Fix hint: `replyable={false}` for `$draft` and scheduled members; point quick reply at the newest message that is neither.
|
- Fix hint: `replyable={false}` for `$draft` and scheduled members; point quick reply at the newest message that is neither.
|
||||||
|
|
||||||
- [ ] **Bounce and receipt report parts show as list chips and go along on forward** — `P3` — `bug` — forward part fixed in 482e9a20; list chips and the global- types in isReportPart are the reader pass
|
- [x] **Bounce and receipt report parts show as list chips and go along on forward** — `P3` — `bug` — fixed in 7772da84 (list chips, global- types), 482e9a20 (forward, compose)
|
||||||
- What WEB does: leaves `message/delivery-status`, `message/disposition-notification` and their `global-` forms out of list chips and forwards (`lib/attachment-visibility.ts:73`, `components/email/email-composer.tsx:985`). 8a09ba573.
|
- What WEB does: leaves `message/delivery-status`, `message/disposition-notification` and their `global-` forms out of list chips and forwards (`lib/attachment-visibility.ts:73`, `components/email/email-composer.tsx:985`). 8a09ba573.
|
||||||
- What RN does: the reader hides them (`src/lib/attachment-display.ts:56`, `:130`), list chips and forwards don't (`src/lib/list-attachments.ts:21`, `src/lib/reply-compose.ts:14-19`); isReportPart misses the `global-` types.
|
- What RN does: the reader hides them (`src/lib/attachment-display.ts:56`, `:130`), list chips and forwards don't (`src/lib/list-attachments.ts:21`, `src/lib/reply-compose.ts:14-19`); isReportPart misses the `global-` types.
|
||||||
- Fix hint: filter isReportPart in both places and add the two global types.
|
- Fix hint: filter isReportPart in both places and add the two global types.
|
||||||
|
|
||||||
- [ ] **Attachment icons in the reader aren't coloured by file type** — `P3` — `partial`
|
- [x] **Attachment icons in the reader aren't coloured by file type** — `P3` — `partial` — fixed in 4849cf23
|
||||||
- What WEB does: reader chips use the list's per-type icon and colour (`components/email/attachment-chips.tsx`). a78d03a18.
|
- What WEB does: reader chips use the list's per-type icon and colour (`components/email/attachment-chips.tsx`). a78d03a18.
|
||||||
- What RN does: list chips are typed (`src/components/email/ListAttachmentChips.tsx:96`), reader chips use a plain paperclip (`src/components/email/AttachmentChips.tsx:244`).
|
- What RN does: list chips are typed (`src/components/email/ListAttachmentChips.tsx:96`), reader chips use a plain paperclip (`src/components/email/AttachmentChips.tsx:244`).
|
||||||
- Fix hint: reuse attachmentKind in AttachmentChips.
|
- Fix hint: reuse attachmentKind in AttachmentChips.
|
||||||
|
|
||||||
- [ ] **Avatar initials are always white, even on light colours** — `P3` — `bug`
|
- [x] **Avatar initials are always white, even on light colours** — `P3` — `bug` — fixed in 6f66fa06
|
||||||
- What WEB does: picks black or white initials by contrast (`components/ui/avatar.tsx:174-182`; 2e13accad).
|
- What WEB does: picks black or white initials by contrast (`components/ui/avatar.tsx:174-182`; 2e13accad).
|
||||||
- What RN does: always `#ffffff` (`src/components/SenderAvatar.tsx:130-133`); several palette colours give 3.1 to 3.7:1 (`src/lib/avatar-utils.ts:19-20`, `:47-48`).
|
- What RN does: always `#ffffff` (`src/components/SenderAvatar.tsx:130-133`); several palette colours give 3.1 to 3.7:1 (`src/lib/avatar-utils.ts:19-20`, `:47-48`).
|
||||||
- Fix hint: port initialsColor.
|
- Fix hint: port initialsColor.
|
||||||
|
|
||||||
### Mail list and search
|
### Mail list and search
|
||||||
|
|
||||||
- [ ] **Search results can't be moved into the open folder (#1452)** — `P3` — `bug`
|
- [x] **Search results can't be moved into the open folder (#1452)** — `P3` — `bug` — fixed in 9a4515f9
|
||||||
- What WEB does: a search across folders keeps the open folder selected, and hits from other folders can move into it (`lib/move-targets.ts:71`). dafdda534.
|
- What WEB does: a search across folders keeps the open folder selected, and hits from other folders can move into it (`lib/move-targets.ts:71`). dafdda534.
|
||||||
- What RN does: the move sheet greys out the current folder (`src/components/MoveSheet.tsx:110-115`) and the single-row pick returns on it (`src/screens/EmailListScreen.tsx:1904-1918`), even for rows from other folders.
|
- What RN does: the move sheet greys out the current folder (`src/components/MoveSheet.tsx:110-115`) and the single-row pick returns on it (`src/screens/EmailListScreen.tsx:1904-1918`), even for rows from other folders.
|
||||||
- Fix hint: in search mode mark a folder current only when the rows are already in it.
|
- Fix hint: in search mode mark a folder current only when the rows are already in it.
|
||||||
|
|
||||||
- [ ] **No Undo for read/unread, star and tag changes** — `P3` — `missing`
|
- [x] **No Undo for read/unread, star and tag changes** — `P3` — `missing` — fixed in da1da0ec
|
||||||
- What WEB does: the toast offers Undo and puts back each message's previous keywords (`lib/email-action-toast.ts:86`). ee17c0dba.
|
- What WEB does: the toast offers Undo and puts back each message's previous keywords (`lib/email-action-toast.ts:86`). ee17c0dba.
|
||||||
- What RN does: UndoEntry covers archive, delete, move and spam only (`src/stores/email-store.ts:322`).
|
- What RN does: UndoEntry covers archive, delete, move and spam only (`src/stores/email-store.ts:322`).
|
||||||
- Fix hint: a `keywords` undo kind with the original keywords per item.
|
- Fix hint: a `keywords` undo kind with the original keywords per item.
|
||||||
|
|
||||||
- [ ] **No Sort menu in the list header** — `P3` — `partial`
|
- [x] **No Sort menu in the list header** — `P3` — `partial` — fixed in 1cbe28b1, beff8744
|
||||||
- What WEB does: Sort menu with the order presets in the list header (`components/email/mail-list-menus.tsx`). e3f1ccce7.
|
- What WEB does: Sort menu with the order presets in the list header (`components/email/mail-list-menus.tsx`). e3f1ccce7.
|
||||||
- What RN does: the header only flips the direction (`src/screens/EmailListScreen.tsx:1193`); presets live in Settings > Layout (`src/components/settings/LayoutSettings.tsx:88-104`).
|
- What RN does: the header only flips the direction (`src/screens/EmailListScreen.tsx:1193`); presets live in Settings > Layout (`src/components/settings/LayoutSettings.tsx:88-104`).
|
||||||
- Fix hint: a sort button that opens the presets.
|
- Fix hint: a sort button that opens the presets.
|
||||||
|
|
||||||
- [ ] **Tag counts don't use Keyword/get where the server counts keywords (#1399)** — `P3` — `missing`
|
- [x] **Tag counts don't use Keyword/get where the server counts keywords (#1399)** — `P3` — `missing` — fixed in eb6bf6d3
|
||||||
- What WEB does: one Keyword/get when the capability has `supportsCounts`, else two Email/query per tag (`lib/jmap/client.ts:2721`, `:2737`).
|
- What WEB does: one Keyword/get when the capability has `supportsCounts`, else two Email/query per tag (`lib/jmap/client.ts:2721`, `:2737`).
|
||||||
- What RN does: always two queries per tag (`src/api/tag-counts.ts:22-40`).
|
- What RN does: always two queries per tag (`src/api/tag-counts.ts:22-40`).
|
||||||
- Fix hint: same check and fallback. Stalwart doesn't advertise counts, so low value.
|
- Fix hint: same check and fallback. Stalwart doesn't advertise counts, so low value.
|
||||||
|
|
||||||
- [ ] **The folder list doesn't say which folder is open, and the unread count reads "Show unread"** — `P3` — `bug`
|
- [x] **The folder list doesn't say which folder is open, and the unread count reads "Show unread"** — `P3` — `bug` — fixed in e3a34eb1
|
||||||
- What WEB does: says which folder is open and how many are unread (7c282f1f4, 80a2a212a).
|
- What WEB does: says which folder is open and how many are unread (7c282f1f4, 80a2a212a).
|
||||||
- What RN does: folder rows have no selected state (`src/components/SidebarDrawer.tsx:170-180`); the count is a button labelled "Show unread" (`:126-133`).
|
- What RN does: folder rows have no selected state (`src/components/SidebarDrawer.tsx:170-180`); the count is a button labelled "Show unread" (`:126-133`).
|
||||||
- Fix hint: `accessibilityState.selected` and a label like "Inbox, 3 unread".
|
- Fix hint: `accessibilityState.selected` and a label like "Inbox, 3 unread".
|
||||||
@@ -113,12 +113,12 @@ Classification: `bug` = fixed in the webmail, still live in the app; `missing`;
|
|||||||
- What RN does: seeds the full quote into the editor (`src/screens/ComposeScreen.tsx:562-615`).
|
- What RN does: seeds the full quote into the editor (`src/screens/ComposeScreen.tsx:562-615`).
|
||||||
- Fix hint: hide the quoted block in the editor behind a toggle, keep it in the sent body.
|
- Fix hint: hide the quoted block in the editor behind a toggle, keep it in the sent body.
|
||||||
|
|
||||||
- [ ] **Tag picker can't make a tag; template picker can't open the template manager** — `P3` — `missing` — template half fixed in 2c1f266f; not done: the tag half lives in TagSheet and the thread tag sheet (reader files)
|
- [x] **Tag picker can't make a tag; template picker can't open the template manager** — `P3` — `missing` — fixed in e09cd395 (new tag), 2c1f266f (template manager, compose)
|
||||||
- What WEB does: "New tag" in the tag picker (`components/email/tag-picker.tsx:214`), "Manage" in the template picker (`components/templates/template-picker.tsx:227`). 19676a14f.
|
- What WEB does: "New tag" in the tag picker (`components/email/tag-picker.tsx:214`), "Manage" in the template picker (`components/templates/template-picker.tsx:227`). 19676a14f.
|
||||||
- What RN does: tag sheets list existing tags only (`src/screens/EmailThreadScreen.tsx:1489`, `src/components/TagSheet.tsx`); the template sheet shows "No templates yet" with no way on (`src/components/TemplateSheet.tsx:148-150`).
|
- What RN does: tag sheets list existing tags only (`src/screens/EmailThreadScreen.tsx:1489`, `src/components/TagSheet.tsx`); the template sheet shows "No templates yet" with no way on (`src/components/TemplateSheet.tsx:148-150`).
|
||||||
- Fix hint: a "New tag" row and a "Manage templates" link.
|
- Fix hint: a "New tag" row and a "Manage templates" link.
|
||||||
|
|
||||||
- [ ] **No optional confirm before sending, deleting or archiving** — `P3` — `missing` — send part fixed in db8eb731 (composer and quick reply); delete and archive are the reader pass
|
- [x] **No optional confirm before sending, deleting or archiving** — `P3` — `missing` — fixed in db8eb731 (send, composer and quick reply), 9794dc03 (delete and archive)
|
||||||
- What WEB does: `sendConfirmation`, `confirmDelete`, `confirmArchive` (`components/email/email-composer.tsx:501`, `lib/mail-removal-confirm.ts:24`, `lib/accessibility-settings.ts:93-94`). 4d91d9b29, 374ae9c8d.
|
- What WEB does: `sendConfirmation`, `confirmDelete`, `confirmArchive` (`components/email/email-composer.tsx:501`, `lib/mail-removal-confirm.ts:24`, `lib/accessibility-settings.ts:93-94`). 4d91d9b29, 374ae9c8d.
|
||||||
- What RN does: asks only before a permanent delete (`src/lib/delete-confirm.ts:27`).
|
- What RN does: asks only before a permanent delete (`src/lib/delete-confirm.ts:27`).
|
||||||
- Fix hint: three settings and an Alert in the send, delete and archive paths, swipes and quick reply included.
|
- Fix hint: three settings and an Alert in the send, delete and archive paths, swipes and quick reply included.
|
||||||
|
|||||||
+39
-1
@@ -453,6 +453,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"email_list": {
|
"email_list": {
|
||||||
|
"batch_actions": {
|
||||||
|
"select_row": "Select {sender}: {subject}"
|
||||||
|
},
|
||||||
|
"confirm_remove": {
|
||||||
|
"archive": "Archive",
|
||||||
|
"archive_message": "{count, plural, one {It moves to your archive.} other {They move to your archive.}}",
|
||||||
|
"archive_title": "{count, plural, one {Archive this email?} other {Archive # emails?}}",
|
||||||
|
"delete": "Delete",
|
||||||
|
"delete_message": "{count, plural, one {It moves to the trash.} other {They move to the trash.}}",
|
||||||
|
"delete_title": "{count, plural, one {Delete this email?} other {Delete # emails?}}"
|
||||||
|
},
|
||||||
"cross_account_move_offline": "Moving between accounts needs a connection",
|
"cross_account_move_offline": "Moving between accounts needs a connection",
|
||||||
"emails_archived_count": "{count, plural, one {# email} other {# emails}} archived",
|
"emails_archived_count": "{count, plural, one {# email} other {# emails}} archived",
|
||||||
"emails_moved_to": "{count, plural, one {# email} other {# emails}} moved to {mailbox}",
|
"emails_moved_to": "{count, plural, one {# email} other {# emails}} moved to {mailbox}",
|
||||||
@@ -490,6 +501,17 @@
|
|||||||
"scope_this_folder": "This folder",
|
"scope_this_folder": "This folder",
|
||||||
"search_placeholder": "Search mail...",
|
"search_placeholder": "Search mail...",
|
||||||
"showing_cached": "Showing cached mail",
|
"showing_cached": "Showing cached mail",
|
||||||
|
"sort": {
|
||||||
|
"custom": "Custom order (in Settings)",
|
||||||
|
"largest": "Largest first",
|
||||||
|
"newest": "Newest first",
|
||||||
|
"oldest": "Oldest first",
|
||||||
|
"sender": "Sender (A–Z)",
|
||||||
|
"starred": "Starred first",
|
||||||
|
"subject": "Subject (A–Z)",
|
||||||
|
"title": "Sort",
|
||||||
|
"unread": "Unread first"
|
||||||
|
},
|
||||||
"sort_toggle_hint": "Reverses the mail sort order",
|
"sort_toggle_hint": "Reverses the mail sort order",
|
||||||
"swipe_move": "Move",
|
"swipe_move": "Move",
|
||||||
"swipe_spam": "Spam",
|
"swipe_spam": "Spam",
|
||||||
@@ -540,6 +562,10 @@
|
|||||||
"load_failed": "Failed to load email",
|
"load_failed": "Failed to load email",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"not_spam_short": "Not spam",
|
"not_spam_short": "Not spam",
|
||||||
|
"new_tag": "New tag",
|
||||||
|
"new_tag_placeholder": "Tag name",
|
||||||
|
"pause_animation": "Pause animation",
|
||||||
|
"play_animation": "Play animation",
|
||||||
"preview": {
|
"preview": {
|
||||||
"archive_files": "{count, plural, one {# file} other {# files}}",
|
"archive_files": "{count, plural, one {# file} other {# files}}",
|
||||||
"archive_first_files": "The first {count} files",
|
"archive_first_files": "The first {count} files",
|
||||||
@@ -570,7 +596,10 @@
|
|||||||
"sender_actions": "Sender…",
|
"sender_actions": "Sender…",
|
||||||
"set_color": "Set tag",
|
"set_color": "Set tag",
|
||||||
"show_all_count": "Show all ({count})",
|
"show_all_count": "Show all ({count})",
|
||||||
|
"show_as_plain_text": "Show as plain text",
|
||||||
|
"show_formatted": "Show formatted",
|
||||||
"show_less": "Show less",
|
"show_less": "Show less",
|
||||||
|
"shown_as_plain_text": "This message is shown as plain text.",
|
||||||
"smime_unsupported": "Signed/encrypted with S/MIME - not supported on mobile",
|
"smime_unsupported": "Signed/encrypted with S/MIME - not supported on mobile",
|
||||||
"source_too_large": "The message is too large to display in full. Share the full source instead.",
|
"source_too_large": "The message is too large to display in full. Share the full source instead.",
|
||||||
"spam_short": "Spam",
|
"spam_short": "Spam",
|
||||||
@@ -639,6 +668,8 @@
|
|||||||
"tracking_number": "Tracking number",
|
"tracking_number": "Tracking number",
|
||||||
"until_time": "until {time}"
|
"until_time": "until {time}"
|
||||||
},
|
},
|
||||||
|
"tag_exists": "A tag with this name already exists",
|
||||||
|
"tag_name_invalid": "Choose a shorter name",
|
||||||
"thread_loading": "Loading conversation...",
|
"thread_loading": "Loading conversation...",
|
||||||
"undo_send_failed": "Undo send failed",
|
"undo_send_failed": "Undo send failed",
|
||||||
"unsubscribe_banner": {
|
"unsubscribe_banner": {
|
||||||
@@ -844,6 +875,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
|
"emails_marked_read": "{count, plural, one {# email marked as read} other {# emails marked as read}}",
|
||||||
|
"emails_marked_unread": "{count, plural, one {# email marked as unread} other {# emails marked as unread}}",
|
||||||
|
"tag_removed_count": "{count, plural, one {Tag removed from # email} other {Tag removed from # emails}}",
|
||||||
"vacation_filters_off": "Out of office settings saved, but your filters are switched off. Save again to turn them back on."
|
"vacation_filters_off": "Out of office settings saved, but your filters are switched off. Save again to turn them back on."
|
||||||
},
|
},
|
||||||
"offline": {
|
"offline": {
|
||||||
@@ -1609,6 +1643,9 @@
|
|||||||
"directory_restricted": "Your server doesn't allow browsing its user directory, so only your groups and people who have shared with you are listed. An administrator can turn on directory queries in Stalwart's sharing settings.",
|
"directory_restricted": "Your server doesn't allow browsing its user directory, so only your groups and people who have shared with you are listed. An administrator can turn on directory queries in Stalwart's sharing settings.",
|
||||||
"unknown_user": "Unknown user"
|
"unknown_user": "Unknown user"
|
||||||
},
|
},
|
||||||
|
"shell": {
|
||||||
|
"name_unread": "{name}, {count} unread"
|
||||||
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"account_error": "Needs attention",
|
"account_error": "Needs attention",
|
||||||
"remove_account_hint": "Long-press to remove this account",
|
"remove_account_hint": "Long-press to remove this account",
|
||||||
@@ -1620,7 +1657,8 @@
|
|||||||
"sign_out_confirm_message": "The account is removed from this device. You can sign in again at any time.",
|
"sign_out_confirm_message": "The account is removed from this device. You can sign in again at any time.",
|
||||||
"sign_out_confirm_title": "Sign out?",
|
"sign_out_confirm_title": "Sign out?",
|
||||||
"sign_out_confirm_title_account": "Sign out of {account}?",
|
"sign_out_confirm_title_account": "Sign out of {account}?",
|
||||||
"tab_unavailable": "{name} (unavailable)"
|
"tab_unavailable": "{name} (unavailable)",
|
||||||
|
"unread_filter": "Show unread only ({count})"
|
||||||
},
|
},
|
||||||
"structured_mail_calendar": {
|
"structured_mail_calendar": {
|
||||||
"action_failed": "Couldn't change your calendar. Try again.",
|
"action_failed": "Couldn't change your calendar. Try again.",
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../jmap-client', () => ({ jmapClient: { accountId: 'acc-1', request: vi.fn() } }));
|
||||||
|
|
||||||
|
import { EMAIL_FULL_PROPERTIES, EMAIL_LIST_PROPERTIES, THREAD_HEADER_PROPERTIES } from '../email';
|
||||||
|
|
||||||
|
// Sort by "Date sent" compares sentAt on the device for a list merged from
|
||||||
|
// several accounts, but list rows never had it, so the order fell back to
|
||||||
|
// the received date (webmail #1219).
|
||||||
|
describe('list properties', () => {
|
||||||
|
it('include sentAt, once', () => {
|
||||||
|
for (const list of [EMAIL_LIST_PROPERTIES, EMAIL_FULL_PROPERTIES, THREAD_HEADER_PROPERTIES]) {
|
||||||
|
expect(list.filter((p) => p === 'sentAt')).toHaveLength(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,7 +37,11 @@ import {
|
|||||||
searchEmails,
|
searchEmails,
|
||||||
sendEmail,
|
sendEmail,
|
||||||
MAILBOX_PROPERTIES,
|
MAILBOX_PROPERTIES,
|
||||||
|
queryEmailPage,
|
||||||
} from '../email';
|
} from '../email';
|
||||||
|
import { senderWarningOf } from '../../lib/email-headers';
|
||||||
|
import { scheduledEmailIdsInThread } from '../thread-scheduled';
|
||||||
|
import { fetchTagCounts, KEYWORDS_CAPABILITY } from '../tag-counts';
|
||||||
import { uploadBytes } from '../blob';
|
import { uploadBytes } from '../blob';
|
||||||
import { getIdentities, updateIdentity } from '../identity';
|
import { getIdentities, updateIdentity } from '../identity';
|
||||||
import { isJMAPSetError } from '../jmap-result';
|
import { isJMAPSetError } from '../jmap-result';
|
||||||
@@ -460,4 +464,75 @@ live('live Stalwart: mail, contacts, search (0.16 and 1.0)', () => {
|
|||||||
}
|
}
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('mail list (reader pass)', () => {
|
||||||
|
it('lists the Authentication-Results headers so a forged From gets the warning shield (bulwarkmail/webmail#1439)', async () => {
|
||||||
|
const subject = `forged ${mailTag}`;
|
||||||
|
const raw = [
|
||||||
|
'Authentication-Results: mx.example.org; spf=fail smtp.mailfrom=evil.example; dkim=none; dmarc=fail header.from=bank.example',
|
||||||
|
'From: Bank <ceo@bank.example>',
|
||||||
|
`To: ${USER}`,
|
||||||
|
`Subject: ${subject}`,
|
||||||
|
`Date: ${new Date().toUTCString()}`,
|
||||||
|
`Message-ID: <forged-${RUN}@live.test>`,
|
||||||
|
'MIME-Version: 1.0',
|
||||||
|
'Content-Type: text/plain; charset=utf-8',
|
||||||
|
'',
|
||||||
|
'hello',
|
||||||
|
'',
|
||||||
|
].join('\r\n');
|
||||||
|
const { blobId } = await uploadBytes(new TextEncoder().encode(raw), 'message/rfc822');
|
||||||
|
const id = await importEmailBlob(blobId, scratch, {});
|
||||||
|
const page = await queryEmailPage(scratch, { limit: 10 });
|
||||||
|
const row = page.list.find((e) => e.id === id);
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
expect(senderWarningOf(row!)?.status).toBe('failed');
|
||||||
|
// The Date header, for sorting by date sent in merged lists (bulwarkmail/webmail#1219).
|
||||||
|
expect(typeof row!.sentAt).toBe('string');
|
||||||
|
// The full fetch reads the same verdict from `headers`.
|
||||||
|
const [full] = await getFullEmails([id]);
|
||||||
|
expect(senderWarningOf(full)?.status).toBe('failed');
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it("finds a conversation's message that waits for a scheduled send (bulwarkmail/webmail#1404)", async () => {
|
||||||
|
const [identity] = await getIdentities();
|
||||||
|
const result = await sendEmail(
|
||||||
|
{ from: [{ email: USER }], to: [{ email: USER }], subject: `scheduled reply ${mailTag}`, textBody: 'live' },
|
||||||
|
identity.id, sent, 3600, { draftsMailboxId: drafts },
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const [filed] = await getFullEmails([result.emailId!]);
|
||||||
|
const ids = await scheduledEmailIdsInThread(filed.threadId);
|
||||||
|
expect([...ids]).toEqual([result.emailId]);
|
||||||
|
} finally {
|
||||||
|
await cancelScheduledSend(result.emailSubmissionId!).catch(() => undefined);
|
||||||
|
}
|
||||||
|
// Once cancelled it can be replied to again.
|
||||||
|
const [filed] = await getFullEmails([result.emailId!]);
|
||||||
|
expect((await scheduledEmailIdsInThread(filed.threadId)).size).toBe(0);
|
||||||
|
}, 90_000);
|
||||||
|
|
||||||
|
it('leaves Trash out of the tag counts (bulwarkmail/webmail#1156)', async (ctx) => {
|
||||||
|
const boxes = await getMailboxes();
|
||||||
|
const trash = boxes.find((b) => b.role === 'trash')!.id;
|
||||||
|
const tag = 'readerpass';
|
||||||
|
const keyword = `$label:${tag}`;
|
||||||
|
const live = await importMessage(scratch, `tag live ${mailTag}`, 'a');
|
||||||
|
const gone = await importMessage(trash, `tag deleted ${mailTag}`, 'b', { $seen: true });
|
||||||
|
// Set afterwards: 0.16 drops the keywords Email/import is given.
|
||||||
|
await patchKeywordsForEmails([live, gone], { [keyword]: true });
|
||||||
|
const flags = await getEmailFlags([live]);
|
||||||
|
if (!flags.list[0]?.keywords?.[keyword]) {
|
||||||
|
// Seen on the 0.16.27 test server: Email/set answers "updated" but
|
||||||
|
// keeps only system keywords for this account. Nothing to count.
|
||||||
|
ctx.skip('the server did not keep the tag keyword');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const all = await eventually(() => fetchTagCounts([tag]), ([c]) => c.total === 2);
|
||||||
|
expect(all).toEqual([{ id: tag, total: 2, unread: 1 }]);
|
||||||
|
expect(await fetchTagCounts([tag], [undefined], { '': [trash] })).toEqual([{ id: tag, total: 1, unread: 1 }]);
|
||||||
|
// Stalwart answers Keyword/get for no keyword counters (no capability).
|
||||||
|
expect(client.currentSession?.capabilities?.[KEYWORDS_CAPABILITY]).toBeUndefined();
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ vi.mock('../jmap-client', () => ({
|
|||||||
accountId: 'c',
|
accountId: 'c',
|
||||||
request: vi.fn(),
|
request: vi.fn(),
|
||||||
getMaxCallsInRequest: vi.fn(() => 16),
|
getMaxCallsInRequest: vi.fn(() => 16),
|
||||||
|
currentSession: { capabilities: {} as Record<string, unknown> },
|
||||||
|
hasAccountCapability: vi.fn(() => true),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { jmapClient } from '../jmap-client';
|
import { jmapClient } from '../jmap-client';
|
||||||
import { fetchTagCounts } from '../tag-counts';
|
import { fetchTagCounts, KEYWORDS_CAPABILITY } from '../tag-counts';
|
||||||
|
|
||||||
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||||
const mockMaxCalls = jmapClient.getMaxCallsInRequest as ReturnType<typeof vi.fn>;
|
const mockMaxCalls = jmapClient.getMaxCallsInRequest as ReturnType<typeof vi.fn>;
|
||||||
@@ -30,6 +32,7 @@ function answer(totals: Record<string, { total: number; unread: number }>, faili
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockMaxCalls.mockReturnValue(16);
|
mockMaxCalls.mockReturnValue(16);
|
||||||
|
jmapClient.currentSession!.capabilities = {};
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('fetchTagCounts', () => {
|
describe('fetchTagCounts', () => {
|
||||||
@@ -39,11 +42,11 @@ describe('fetchTagCounts', () => {
|
|||||||
expect(await fetchTagCounts(['red'])).toEqual([{ id: 'red', total: 2, unread: 1 }]);
|
expect(await fetchTagCounts(['red'])).toEqual([{ id: 'red', total: 2, unread: 1 }]);
|
||||||
const calls = mockRequest.mock.calls[0][0];
|
const calls = mockRequest.mock.calls[0][0];
|
||||||
expect(calls).toEqual([
|
expect(calls).toEqual([
|
||||||
['Email/query', { accountId: 'c', filter: { hasKeyword: '$label:red' }, limit: 0, calculateTotal: true }, 't0:red'],
|
['Email/query', { accountId: 'c', filter: { hasKeyword: '$label:red' }, limit: 1, calculateTotal: true }, 't0:red'],
|
||||||
['Email/query', {
|
['Email/query', {
|
||||||
accountId: 'c',
|
accountId: 'c',
|
||||||
filter: { operator: 'AND', conditions: [{ hasKeyword: '$label:red' }, { notKeyword: '$seen' }] },
|
filter: { operator: 'AND', conditions: [{ hasKeyword: '$label:red' }, { notKeyword: '$seen' }] },
|
||||||
limit: 0,
|
limit: 1,
|
||||||
calculateTotal: true,
|
calculateTotal: true,
|
||||||
}, 'u0:red'],
|
}, 'u0:red'],
|
||||||
]);
|
]);
|
||||||
@@ -77,4 +80,53 @@ describe('fetchTagCounts', () => {
|
|||||||
|
|
||||||
expect(await fetchTagCounts(['red'], [undefined, 'team'])).toEqual([{ id: 'red', total: 2, unread: 1 }]);
|
expect(await fetchTagCounts(['red'], [undefined, 'team'])).toEqual([{ id: 'red', total: 2, unread: 1 }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The tag view leaves Trash and Junk out; its badge counted them (#1156).
|
||||||
|
it("leaves each account's Trash and Junk out of the counts", async () => {
|
||||||
|
mockRequest.mockImplementation(async (calls: Array<[string, Record<string, unknown>, string]>) => ({
|
||||||
|
methodResponses: calls.map(([, , callId]) => ['Email/query', { ids: [], total: 1 }, callId]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await fetchTagCounts(['red'], [undefined, 'team'], { '': ['trash', 'junk'], team: ['t-trash'] });
|
||||||
|
|
||||||
|
const filters = mockRequest.mock.calls[0][0].map((c: unknown[]) => (c[1] as { filter: unknown }).filter);
|
||||||
|
expect(filters).toEqual([
|
||||||
|
{ operator: 'AND', conditions: [{ hasKeyword: '$label:red' }, { inMailboxOtherThan: ['trash', 'junk'] }] },
|
||||||
|
{ operator: 'AND', conditions: [{ hasKeyword: '$label:red' }, { notKeyword: '$seen' }, { inMailboxOtherThan: ['trash', 'junk'] }] },
|
||||||
|
{ operator: 'AND', conditions: [{ hasKeyword: '$label:red' }, { inMailboxOtherThan: ['t-trash'] }] },
|
||||||
|
{ operator: 'AND', conditions: [{ hasKeyword: '$label:red' }, { notKeyword: '$seen' }, { inMailboxOtherThan: ['t-trash'] }] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// One Keyword/get where the server counts keywords (#1399), else the queries.
|
||||||
|
it('asks Keyword/get when the server counts keywords, and falls back to queries when it fails', async () => {
|
||||||
|
jmapClient.currentSession!.capabilities = { [KEYWORDS_CAPABILITY]: { supportsCounts: true } };
|
||||||
|
mockRequest.mockImplementation(async (calls: Array<[string, Record<string, unknown>, string]>) => {
|
||||||
|
if (calls[0][0] === 'Keyword/get') {
|
||||||
|
return { methodResponses: [['Keyword/get', { list: [{ id: '$LABEL:Red', total: 7, unread: 3 }] }, '0']] };
|
||||||
|
}
|
||||||
|
return { methodResponses: calls.map(([, , callId]) => ['Email/query', { ids: [], total: 1 }, callId]) };
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await fetchTagCounts(['red', 'blue'])).toEqual([
|
||||||
|
{ id: 'red', total: 7, unread: 3 },
|
||||||
|
{ id: 'blue', total: 0, unread: 0 },
|
||||||
|
]);
|
||||||
|
expect(mockRequest).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockRequest.mock.calls[0][0][0]).toEqual(['Keyword/get', { accountId: 'c', ids: ['$label:red', '$label:blue'] }, '0']);
|
||||||
|
|
||||||
|
mockRequest.mockClear();
|
||||||
|
mockRequest.mockImplementation(async (calls: Array<[string, Record<string, unknown>, string]>) => (
|
||||||
|
calls[0][0] === 'Keyword/get'
|
||||||
|
? { methodResponses: [['error', { type: 'unknownMethod' }, '0']] }
|
||||||
|
: { methodResponses: calls.map(([, , callId]) => ['Email/query', { ids: [], total: 2 }, callId]) }
|
||||||
|
));
|
||||||
|
expect(await fetchTagCounts(['red'])).toEqual([{ id: 'red', total: 2, unread: 2 }]);
|
||||||
|
|
||||||
|
// Without supportsCounts nothing asks Keyword/get.
|
||||||
|
jmapClient.currentSession!.capabilities = { [KEYWORDS_CAPABILITY]: {} };
|
||||||
|
mockRequest.mockClear();
|
||||||
|
await fetchTagCounts(['red']);
|
||||||
|
expect(mockRequest.mock.calls[0][0][0][0]).toBe('Email/query');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,6 +45,15 @@ describe('buildFilter (aggregated views)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("buildFilter: a server's own all mailbox (bulwarkmail/webmail#1241)", () => {
|
||||||
|
it('is left out of All mail and Unread', () => {
|
||||||
|
const withAll = [...MAILBOXES, mailbox('every', 'all')];
|
||||||
|
expect(buildFilter(withAll, { view: 'all' })).toEqual({
|
||||||
|
operator: 'OR', conditions: [{ inMailbox: 'in' }, { inMailbox: 'projects' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('buildFilter: searching from All mail', () => {
|
describe('buildFilter: searching from All mail', () => {
|
||||||
const all = [...MAILBOXES, mailbox('arch', 'archive'), mailbox('drafts', 'drafts'), mailbox('spam', 'junk')];
|
const all = [...MAILBOXES, mailbox('arch', 'archive'), mailbox('drafts', 'drafts'), mailbox('spam', 'junk')];
|
||||||
|
|
||||||
|
|||||||
+18
-5
@@ -19,19 +19,32 @@ import { hasTruncatedDisplayedBody } from '../lib/email-body';
|
|||||||
import { withInlineBodyParts } from '../lib/email-inline-parts';
|
import { withInlineBodyParts } from '../lib/email-inline-parts';
|
||||||
import { stalwartStateWentBack } from './stalwart-state';
|
import { stalwartStateWentBack } from './stalwart-state';
|
||||||
import { isTransientNetworkError } from '../lib/network-error';
|
import { isTransientNetworkError } from '../lib/network-error';
|
||||||
|
import { LIST_AUTH_RESULTS_PROPERTY } from '../lib/email-headers';
|
||||||
|
|
||||||
export const EMAIL_LIST_PROPERTIES = [
|
const EMAIL_SUMMARY_PROPERTIES = [
|
||||||
'id', 'threadId', 'mailboxIds', 'keywords', 'size',
|
'id', 'threadId', 'mailboxIds', 'keywords', 'size',
|
||||||
'receivedAt', 'from', 'to', 'cc', 'subject', 'preview', 'hasAttachment',
|
'receivedAt', 'from', 'to', 'cc', 'subject', 'preview', 'hasAttachment',
|
||||||
|
// Sorting by "Date sent" compares it on the device for a list merged from
|
||||||
|
// several accounts; without it the order fell back to receivedAt (#1219).
|
||||||
|
'sentAt',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const EMAIL_LIST_PROPERTIES = [
|
||||||
|
...EMAIL_SUMMARY_PROPERTIES,
|
||||||
|
// The sender check, so a list row shows a warning shield instead of the
|
||||||
|
// favicon or photo of a forged From (webmail #1439).
|
||||||
|
LIST_AUTH_RESULTS_PROPERTY,
|
||||||
];
|
];
|
||||||
|
|
||||||
// The viewer derives reply threading (In-Reply-To/References), Reply-To
|
// The viewer derives reply threading (In-Reply-To/References), Reply-To
|
||||||
// handling, SPF/DKIM/DMARC chips, List-Unsubscribe and read-receipt requests
|
// handling, SPF/DKIM/DMARC chips, List-Unsubscribe and read-receipt requests
|
||||||
// from these; without `messageId`/`headers` none of that can work.
|
// from these; without `messageId`/`headers` none of that can work. The full
|
||||||
|
// `headers` carry Authentication-Results, so the list's header property is
|
||||||
|
// left out.
|
||||||
export const EMAIL_FULL_PROPERTIES = [
|
export const EMAIL_FULL_PROPERTIES = [
|
||||||
...EMAIL_LIST_PROPERTIES,
|
...EMAIL_SUMMARY_PROPERTIES,
|
||||||
'bodyStructure', 'textBody', 'htmlBody', 'bodyValues',
|
'bodyStructure', 'textBody', 'htmlBody', 'bodyValues',
|
||||||
'attachments', 'blobId', 'bcc', 'replyTo', 'sentAt',
|
'attachments', 'blobId', 'bcc', 'replyTo',
|
||||||
'messageId', 'inReplyTo', 'references', 'headers',
|
'messageId', 'inReplyTo', 'references', 'headers',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -1014,7 +1027,7 @@ export async function getEmailFlags(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// What a collapsed conversation card shows; `sentAt` is the date it prints.
|
// What a collapsed conversation card shows; `sentAt` is the date it prints.
|
||||||
export const THREAD_HEADER_PROPERTIES = [...EMAIL_LIST_PROPERTIES, 'sentAt'];
|
export const THREAD_HEADER_PROPERTIES = EMAIL_LIST_PROPERTIES;
|
||||||
|
|
||||||
/** A conversation's members, oldest first, without bodies. */
|
/** A conversation's members, oldest first, without bodies. */
|
||||||
export interface ThreadHeaders {
|
export interface ThreadHeaders {
|
||||||
|
|||||||
+64
-6
@@ -1,5 +1,8 @@
|
|||||||
import { jmapClient } from './jmap-client';
|
import { jmapClient } from './jmap-client';
|
||||||
import type { JMAPMethodCall } from './types';
|
import { CAPABILITIES, type JMAPMethodCall } from './types';
|
||||||
|
|
||||||
|
/** Bulwark's keyword counters (webmail lib/jmap/client.ts KEYWORDS_CAPABILITY). */
|
||||||
|
export const KEYWORDS_CAPABILITY = 'https://bulwarkmail.com/ns/jmap/keywords';
|
||||||
|
|
||||||
export interface TagCount {
|
export interface TagCount {
|
||||||
/** Tag id (the part after `$label:`). */
|
/** Tag id (the part after `$label:`). */
|
||||||
@@ -17,33 +20,61 @@ export interface TagCount {
|
|||||||
* allows. Port of the webmail's `fetchTagCounts` (stores/email-store.ts). A
|
* allows. Port of the webmail's `fetchTagCounts` (stores/email-store.ts). A
|
||||||
* call that fails, such as for an account that can't be reached, counts 0
|
* call that fails, such as for an account that can't be reached, counts 0
|
||||||
* rather than sinking the rest.
|
* rather than sinking the rest.
|
||||||
|
*
|
||||||
|
* `excluded` names each account's Trash and Junk (key '' = the own account):
|
||||||
|
* the counts leave them out like the tag view does (webmail #1156). An
|
||||||
|
* account whose server counts keywords itself (Bulwark's keywords capability
|
||||||
|
* with `supportsCounts`) is asked once with Keyword/get instead (#1399); its
|
||||||
|
* counters cover the whole account, as the webmail's do.
|
||||||
*/
|
*/
|
||||||
export async function fetchTagCounts(
|
export async function fetchTagCounts(
|
||||||
tagIds: string[],
|
tagIds: string[],
|
||||||
accountIds: Array<string | undefined> = [undefined],
|
accountIds: Array<string | undefined> = [undefined],
|
||||||
|
excluded: Record<string, string[]> = {},
|
||||||
): Promise<TagCount[]> {
|
): Promise<TagCount[]> {
|
||||||
if (tagIds.length === 0) return [];
|
if (tagIds.length === 0) return [];
|
||||||
const perRequest = Math.max(2, jmapClient.getMaxCallsInRequest());
|
const perRequest = Math.max(2, jmapClient.getMaxCallsInRequest());
|
||||||
const counts = new Map<string, TagCount>(tagIds.map((id) => [id, { id, total: 0, unread: 0 }]));
|
const counts = new Map<string, TagCount>(tagIds.map((id) => [id, { id, total: 0, unread: 0 }]));
|
||||||
|
|
||||||
|
const queried: Array<string | undefined> = [];
|
||||||
|
for (const override of accountIds) {
|
||||||
|
const counted = countsKeywords(override ?? jmapClient.accountId)
|
||||||
|
? await tagCountsFromKeywords(tagIds, override ?? jmapClient.accountId)
|
||||||
|
: null;
|
||||||
|
if (!counted) {
|
||||||
|
queried.push(override);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const c of counted) {
|
||||||
|
const entry = counts.get(c.id)!;
|
||||||
|
entry.total += c.total;
|
||||||
|
entry.unread += c.unread;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const calls: JMAPMethodCall[] = [];
|
const calls: JMAPMethodCall[] = [];
|
||||||
const targets = new Map<string, { id: string; kind: 'total' | 'unread' }>();
|
const targets = new Map<string, { id: string; kind: 'total' | 'unread' }>();
|
||||||
accountIds.forEach((override, index) => {
|
queried.forEach((override, index) => {
|
||||||
const accountId = override ?? jmapClient.accountId;
|
const accountId = override ?? jmapClient.accountId;
|
||||||
|
const skip = excluded[override ?? ''] ?? [];
|
||||||
|
const exclusion = skip.length > 0 ? [{ inMailboxOtherThan: skip }] : [];
|
||||||
for (const id of tagIds) {
|
for (const id of tagIds) {
|
||||||
const keyword = `$label:${id}`;
|
const keyword = `$label:${id}`;
|
||||||
targets.set(`t${index}:${id}`, { id, kind: 'total' });
|
targets.set(`t${index}:${id}`, { id, kind: 'total' });
|
||||||
calls.push(['Email/query', {
|
calls.push(['Email/query', {
|
||||||
accountId,
|
accountId,
|
||||||
filter: { hasKeyword: keyword },
|
filter: exclusion.length > 0
|
||||||
limit: 0,
|
? { operator: 'AND', conditions: [{ hasKeyword: keyword }, ...exclusion] }
|
||||||
|
: { hasKeyword: keyword },
|
||||||
|
// 1, not 0: Stalwart takes 0 as "no limit" and returns every id.
|
||||||
|
limit: 1,
|
||||||
calculateTotal: true,
|
calculateTotal: true,
|
||||||
}, `t${index}:${id}`]);
|
}, `t${index}:${id}`]);
|
||||||
targets.set(`u${index}:${id}`, { id, kind: 'unread' });
|
targets.set(`u${index}:${id}`, { id, kind: 'unread' });
|
||||||
calls.push(['Email/query', {
|
calls.push(['Email/query', {
|
||||||
accountId,
|
accountId,
|
||||||
filter: { operator: 'AND', conditions: [{ hasKeyword: keyword }, { notKeyword: '$seen' }] },
|
filter: { operator: 'AND', conditions: [{ hasKeyword: keyword }, { notKeyword: '$seen' }, ...exclusion] },
|
||||||
limit: 0,
|
limit: 1,
|
||||||
calculateTotal: true,
|
calculateTotal: true,
|
||||||
}, `u${index}:${id}`]);
|
}, `u${index}:${id}`]);
|
||||||
}
|
}
|
||||||
@@ -63,3 +94,30 @@ export async function fetchTagCounts(
|
|||||||
}
|
}
|
||||||
return tagIds.map((id) => counts.get(id)!);
|
return tagIds.map((id) => counts.get(id)!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether the server reports every keyword's total and unread in Keyword/get. */
|
||||||
|
function countsKeywords(accountId: string): boolean {
|
||||||
|
const capability = jmapClient.currentSession?.capabilities?.[KEYWORDS_CAPABILITY] as { supportsCounts?: unknown } | undefined;
|
||||||
|
return capability?.supportsCounts === true && jmapClient.hasAccountCapability(KEYWORDS_CAPABILITY, accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Counts from the server's keyword counters, or null so the caller queries instead. */
|
||||||
|
async function tagCountsFromKeywords(tagIds: string[], accountId: string): Promise<TagCount[] | null> {
|
||||||
|
try {
|
||||||
|
const res = await jmapClient.request(
|
||||||
|
[['Keyword/get', { accountId, ids: tagIds.map((id) => `$label:${id}`) }, '0']],
|
||||||
|
[CAPABILITIES.CORE, KEYWORDS_CAPABILITY],
|
||||||
|
);
|
||||||
|
const [name, body] = res.methodResponses?.[0] ?? [];
|
||||||
|
if (name !== 'Keyword/get' || !Array.isArray(body?.list)) return null;
|
||||||
|
// Keywords are case-insensitive (RFC 8621 4.1.1).
|
||||||
|
const byId = new Map((body.list as Array<{ id: string; total?: number; unread?: number }>)
|
||||||
|
.map((k) => [k.id.toLowerCase(), k]));
|
||||||
|
return tagIds.map((id) => {
|
||||||
|
const k = byId.get(`$label:${id}`.toLowerCase());
|
||||||
|
return { id, total: k?.total ?? 0, unread: k?.unread ?? 0 };
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { jmapClient } from './jmap-client';
|
||||||
|
import { SUBMISSION_ONLY_USING } from './submissions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ids of a conversation's messages that wait for a scheduled send: a pending
|
||||||
|
* EmailSubmission whose send time is still ahead. The conversation hides
|
||||||
|
* Reply on them and points the quick reply past them, as it does for drafts
|
||||||
|
* (webmail 8b29254a6, #1404). Empty when the account can't schedule or the
|
||||||
|
* server can't answer: the cards then stay as they were.
|
||||||
|
*/
|
||||||
|
export async function scheduledEmailIdsInThread(threadId: string, accountId?: string): Promise<Set<string>> {
|
||||||
|
const account = accountId ?? jmapClient.accountId;
|
||||||
|
if (!account || !jmapClient.hasDelayedSend(account)) return new Set();
|
||||||
|
try {
|
||||||
|
const res = await jmapClient.request([
|
||||||
|
['EmailSubmission/query', { accountId: account, filter: { threadIds: [threadId], undoStatus: 'pending' } }, '0'],
|
||||||
|
['EmailSubmission/get', {
|
||||||
|
accountId: account,
|
||||||
|
'#ids': { resultOf: '0', name: 'EmailSubmission/query', path: '/ids' },
|
||||||
|
properties: ['emailId', 'sendAt', 'undoStatus'],
|
||||||
|
}, '1'],
|
||||||
|
], SUBMISSION_ONLY_USING);
|
||||||
|
const [name, body] = res.methodResponses?.[1] ?? [];
|
||||||
|
if (name !== 'EmailSubmission/get') return new Set();
|
||||||
|
const now = Date.now();
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const s of (body.list as Array<{ emailId?: string; sendAt?: string; undoStatus?: string }>) ?? []) {
|
||||||
|
if (!s.emailId || s.undoStatus !== 'pending') continue;
|
||||||
|
const at = s.sendAt ? new Date(s.sendAt).getTime() : NaN;
|
||||||
|
if (Number.isFinite(at) && at > now) ids.add(s.emailId);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { keywordPointer, mailboxPointer } from './patch-pointer';
|
|||||||
import { secureFetch } from '../lib/client-cert';
|
import { secureFetch } from '../lib/client-cert';
|
||||||
import { freshStoredCredentials } from '../lib/stored-token-refresh';
|
import { freshStoredCredentials } from '../lib/stored-token-refresh';
|
||||||
import { textConditions } from '../lib/search-utils';
|
import { textConditions } from '../lib/search-utils';
|
||||||
|
import { LIST_AUTH_RESULTS_PROPERTY } from '../lib/email-headers';
|
||||||
|
|
||||||
// Aggregated views across accounts ("All inboxes", "All Sent", All mail /
|
// Aggregated views across accounts ("All inboxes", "All Sent", All mail /
|
||||||
// Unread / Starred). Because the JMAP client is a single-account singleton
|
// Unread / Starred). Because the JMAP client is a single-account singleton
|
||||||
@@ -20,7 +21,9 @@ import { textConditions } from '../lib/search-utils';
|
|||||||
|
|
||||||
const EMAIL_LIST_PROPERTIES = [
|
const EMAIL_LIST_PROPERTIES = [
|
||||||
'id', 'threadId', 'mailboxIds', 'keywords', 'size',
|
'id', 'threadId', 'mailboxIds', 'keywords', 'size',
|
||||||
'receivedAt', 'from', 'to', 'cc', 'subject', 'preview', 'hasAttachment',
|
'receivedAt', 'from', 'to', 'cc', 'subject', 'preview', 'hasAttachment', 'sentAt',
|
||||||
|
// The sender check, for the warning shield on a forged From (#1439).
|
||||||
|
LIST_AUTH_RESULTS_PROPERTY,
|
||||||
];
|
];
|
||||||
|
|
||||||
const SESSION_CACHE_TTL_MS = 10 * 60 * 1000;
|
const SESSION_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||||
@@ -29,9 +32,13 @@ const MAILBOX_CACHE_TTL_MS = 60 * 1000;
|
|||||||
export type UnifiedRole = 'inbox' | 'sent' | 'drafts' | 'junk' | 'archive' | 'trash';
|
export type UnifiedRole = 'inbox' | 'sent' | 'drafts' | 'junk' | 'archive' | 'trash';
|
||||||
export type CrossView = 'all' | 'unread' | 'starred';
|
export type CrossView = 'all' | 'unread' | 'starred';
|
||||||
|
|
||||||
/** Roles left out of the cross-account All mail / Unread / Starred views. */
|
/**
|
||||||
|
* Roles left out of the cross-account All mail / Unread / Starred views. A
|
||||||
|
* server's own "all" mailbox holds every message again, so listing it beside
|
||||||
|
* the real folders doubled them (webmail lib/jmap/types.ts, #1241).
|
||||||
|
*/
|
||||||
export const CROSS_EXCLUDED_ROLES: ReadonlySet<string> = new Set([
|
export const CROSS_EXCLUDED_ROLES: ReadonlySet<string> = new Set([
|
||||||
'junk', 'spam', 'sent', 'archive', 'trash', 'drafts',
|
'junk', 'spam', 'sent', 'archive', 'trash', 'drafts', 'all',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export interface UnifiedEmail extends Email {
|
export interface UnifiedEmail extends Email {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, StyleSheet, Linking, Platform, Text, Pressable, useWindowDimensions } from 'react-native';
|
import { View, StyleSheet, Linking, Platform, Text, Pressable, PixelRatio, useWindowDimensions } from 'react-native';
|
||||||
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
|
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
@@ -14,11 +14,15 @@ import { parseMailtoUrl } from '../lib/unsubscribe';
|
|||||||
import { fetchInlineImageDataUri } from '../lib/email-export';
|
import { fetchInlineImageDataUri } from '../lib/email-export';
|
||||||
import { isSenderContentTrusted, isTrustedSendersSyncOn } from '../lib/trusted-senders';
|
import { isSenderContentTrusted, isTrustedSendersSyncOn } from '../lib/trusted-senders';
|
||||||
import { senderVerificationOf } from '../lib/email-headers';
|
import { senderVerificationOf } from '../lib/email-headers';
|
||||||
|
import { plainTextReading } from '../lib/html-to-readable-text';
|
||||||
|
import { buildMailTextSizeScript, mailTextFactor } from '../lib/mail-reading';
|
||||||
|
import { buildPauseAnimatedImagesScript, withReducedMotion } from '../lib/mail-motion';
|
||||||
import { useHasContacts } from '../lib/capabilities';
|
import { useHasContacts } from '../lib/capabilities';
|
||||||
import { useSettingsStore } from '../stores/settings-store';
|
import { useSettingsStore } from '../stores/settings-store';
|
||||||
import { useContactsStore } from '../stores/contacts-store';
|
import { useContactsStore } from '../stores/contacts-store';
|
||||||
import { useLocaleStore } from '../stores/locale-store';
|
import { useLocaleStore } from '../stores/locale-store';
|
||||||
import { spacing, typography, type ThemePalette } from '../theme/tokens';
|
import { spacing, typography, type ThemePalette } from '../theme/tokens';
|
||||||
|
import { useShouldAnimate, useTypography } from '../theme/dynamic';
|
||||||
import { useColors, useResolvedTheme } from '../theme/colors';
|
import { useColors, useResolvedTheme } from '../theme/colors';
|
||||||
import { perfEndAfterPaint, perfLap } from '../lib/perf-marks';
|
import { perfEndAfterPaint, perfLap } from '../lib/perf-marks';
|
||||||
import type { RootStackParamList } from '../navigation/types';
|
import type { RootStackParamList } from '../navigation/types';
|
||||||
@@ -54,6 +58,13 @@ interface EmailBodyViewProps {
|
|||||||
* page below the header) rather than a guessed height.
|
* page below the header) rather than a guessed height.
|
||||||
*/
|
*/
|
||||||
fill?: boolean;
|
fill?: boolean;
|
||||||
|
/**
|
||||||
|
* Per-message "Show as plain text" from the More sheet; null or undefined
|
||||||
|
* follows the "Plain Text Only" setting.
|
||||||
|
*/
|
||||||
|
plainView?: boolean | null;
|
||||||
|
/** "Show formatted" in the plain-text note: turn the per-message view off. */
|
||||||
|
onShowFormatted?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dark-mode re-inversion, mirroring the webmail's handleIframeLoad pass. The
|
// Dark-mode re-inversion, mirroring the webmail's handleIframeLoad pass. The
|
||||||
@@ -521,6 +532,7 @@ const PINCH_ZOOM = `
|
|||||||
|
|
||||||
export default function EmailBodyView({
|
export default function EmailBodyView({
|
||||||
email, senderEmail, jmapAccountId, onSwipe, onZoomChange, themeOverride, bodyOverride, onSettled, fill,
|
email, senderEmail, jmapAccountId, onSwipe, onZoomChange, themeOverride, bodyOverride, onSettled, fill,
|
||||||
|
plainView: plainViewOverride, onShowFormatted,
|
||||||
}: EmailBodyViewProps) {
|
}: EmailBodyViewProps) {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
@@ -535,6 +547,16 @@ export default function EmailBodyView({
|
|||||||
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
|
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
|
||||||
const plainTextFont = useSettingsStore((s) => s.plainTextFont);
|
const plainTextFont = useSettingsStore((s) => s.plainTextFont);
|
||||||
const messageSpacing = useSettingsStore((s) => s.messageSpacing);
|
const messageSpacing = useSettingsStore((s) => s.messageSpacing);
|
||||||
|
const plainTextMode = useSettingsStore((s) => s.plainTextMode);
|
||||||
|
// Body text follows the app's text size and the system font scale (webmail
|
||||||
|
// c07ba1a37); it was fixed at 14 px. The app's factor is read off its
|
||||||
|
// scaled body style, so it follows whatever steps the text size setting has.
|
||||||
|
const dyn = useTypography();
|
||||||
|
const textFactor = mailTextFactor(dyn.body.fontSize / typography.body.fontSize, PixelRatio.getFontScale());
|
||||||
|
// Reduce motion (the system setting, or the app's animations switch) holds
|
||||||
|
// inside mail too: CSS animations stop and animated images pause behind a
|
||||||
|
// Play button (webmail 24a13cc1f, bb7869798).
|
||||||
|
const reduceMotion = !useShouldAnimate();
|
||||||
const trustedSenderEmails = useContactsStore((s) => s.trustedSenderEmails);
|
const trustedSenderEmails = useContactsStore((s) => s.trustedSenderEmails);
|
||||||
const trustedSendersLoaded = useContactsStore((s) => s.trustedSendersLoaded);
|
const trustedSendersLoaded = useContactsStore((s) => s.trustedSendersLoaded);
|
||||||
const loadTrustedSendersBook = useContactsStore((s) => s.loadTrustedSendersBook);
|
const loadTrustedSendersBook = useContactsStore((s) => s.loadTrustedSendersBook);
|
||||||
@@ -562,8 +584,19 @@ export default function EmailBodyView({
|
|||||||
if (bodyOverride) return { html: bodyOverride.html ?? null, text: bodyOverride.text ?? null };
|
if (bodyOverride) return { html: bodyOverride.html ?? null, text: bodyOverride.text ?? null };
|
||||||
return pickEmailBody(email);
|
return pickEmailBody(email);
|
||||||
}, [email, bodyOverride]);
|
}, [email, bodyOverride]);
|
||||||
const rawHtml = React.useMemo(() => selectRenderableHtml(picked), [picked]);
|
const formattedHtml = React.useMemo(() => selectRenderableHtml(picked), [picked]);
|
||||||
const text = picked.text;
|
// HTML mail as plain text: "Plain Text Only" for every message, or the More
|
||||||
|
// sheet's per-message switch. The message's own text part when it has one,
|
||||||
|
// else a readable conversion that keeps link targets and image alt text
|
||||||
|
// (webmail 1e929b0a9). A plain-text view loads nothing remote, so the
|
||||||
|
// external-content banner goes too.
|
||||||
|
const plainView = plainViewOverride ?? plainTextMode;
|
||||||
|
const plainText = React.useMemo(
|
||||||
|
() => (plainView ? plainTextReading({ html: formattedHtml, text: picked.text }) : null),
|
||||||
|
[plainView, formattedHtml, picked.text],
|
||||||
|
);
|
||||||
|
const rawHtml = plainText !== null ? null : formattedHtml;
|
||||||
|
const text = plainText ?? picked.text;
|
||||||
// A From address the server's checks don't back may be forged: neither
|
// A From address the server's checks don't back may be forged: neither
|
||||||
// honour an earlier "Always trust" for it nor offer one, or a forgery of a
|
// honour an earlier "Always trust" for it nor offer one, or a forgery of a
|
||||||
// trusted sender would load remote content (and trusting it would let the
|
// trusted sender would load remote content (and trusting it would let the
|
||||||
@@ -660,6 +693,13 @@ export default function EmailBodyView({
|
|||||||
// Sanitised and wrapped once per message, body and settings, and reused
|
// Sanitised and wrapped once per message, body and settings, and reused
|
||||||
// when the same body mounts again (lib/email-body-document).
|
// when the same body mounts again (lib/email-body-document).
|
||||||
const emptyLabel = t('email_viewer.no_body_content', '(No body content available)');
|
const emptyLabel = t('email_viewer.no_body_content', '(No body content available)');
|
||||||
|
// A plain-text body in a dark app theme takes the theme's colours, not a
|
||||||
|
// fixed #0a0a0a. Only while the app itself is dark: a single message
|
||||||
|
// switched to dark in a light app has no dark palette to borrow.
|
||||||
|
const plainDarkColors = React.useMemo(
|
||||||
|
() => (resolvedTheme === 'dark' ? { background: c.background, text: c.text, link: c.textLink } : undefined),
|
||||||
|
[resolvedTheme, c.background, c.text, c.textLink],
|
||||||
|
);
|
||||||
const prepared = React.useMemo(() => bodyDocument({
|
const prepared = React.useMemo(() => bodyDocument({
|
||||||
key: `${jmapAccountId ?? ''}|${email.id}`,
|
key: `${jmapAccountId ?? ''}|${email.id}`,
|
||||||
rawHtml,
|
rawHtml,
|
||||||
@@ -671,12 +711,21 @@ export default function EmailBodyView({
|
|||||||
messageSpacing,
|
messageSpacing,
|
||||||
plainTextFont,
|
plainTextFont,
|
||||||
quoteLabels,
|
quoteLabels,
|
||||||
|
textFactor,
|
||||||
|
plainDarkColors,
|
||||||
}), [
|
}), [
|
||||||
jmapAccountId, email.id, rawHtml, text, email.preview, emptyLabel, shouldBlock, cidMap, renderAsDark,
|
jmapAccountId, email.id, rawHtml, text, email.preview, emptyLabel, shouldBlock, cidMap, renderAsDark,
|
||||||
messageSpacing, plainTextFont, quoteLabels,
|
messageSpacing, plainTextFont, quoteLabels, textFactor, plainDarkColors,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const source = React.useMemo(() => ({ html: prepared.html }), [prepared.html]);
|
const source = React.useMemo(
|
||||||
|
() => ({ html: reduceMotion && prepared.isHtml ? withReducedMotion(prepared.html) : prepared.html }),
|
||||||
|
[prepared.html, prepared.isHtml, reduceMotion],
|
||||||
|
);
|
||||||
|
const pauseLabels = React.useMemo(() => ({
|
||||||
|
play: t('email_viewer.play_animation', 'Play animation'),
|
||||||
|
pause: t('email_viewer.pause_animation', 'Pause animation'),
|
||||||
|
}), [t]);
|
||||||
const showBanner = shouldBlock && prepared.blockedExternal;
|
const showBanner = shouldBlock && prepared.blockedExternal;
|
||||||
// Still cut off after the API's larger refetch (or the refetch failed).
|
// Still cut off after the API's larger refetch (or the refetch failed).
|
||||||
const showTruncated = !bodyOverride && hasTruncatedDisplayedBody(email);
|
const showTruncated = !bodyOverride && hasTruncatedDisplayedBody(email);
|
||||||
@@ -686,11 +735,13 @@ export default function EmailBodyView({
|
|||||||
// input the CSS was built from, so the DOM pass and the stylesheet agree.
|
// input the CSS was built from, so the DOM pass and the stylesheet agree.
|
||||||
const injectedJs = React.useMemo(
|
const injectedJs = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
(prepared.isHtml ? buildQuoteCollapseScript(quoteLabels) : '')
|
(prepared.isHtml ? buildMailTextSizeScript(textFactor) : '')
|
||||||
|
+ (prepared.isHtml ? buildQuoteCollapseScript(quoteLabels) : '')
|
||||||
+ (prepared.applyInversion ? DARK_REINVERT_SCRIPT : '')
|
+ (prepared.applyInversion ? DARK_REINVERT_SCRIPT : '')
|
||||||
+ (prepared.isHtml ? IMG_FIXUP_SCRIPT : '')
|
+ (prepared.isHtml ? IMG_FIXUP_SCRIPT : '')
|
||||||
|
+ (prepared.isHtml && reduceMotion ? buildPauseAnimatedImagesScript(pauseLabels) : '')
|
||||||
+ HEIGHT_REPORTER + SWIPE_DETECTOR + PINCH_ZOOM,
|
+ HEIGHT_REPORTER + SWIPE_DETECTOR + PINCH_ZOOM,
|
||||||
[prepared.isHtml, prepared.applyInversion, quoteLabels],
|
[prepared.isHtml, prepared.applyInversion, quoteLabels, textFactor, reduceMotion, pauseLabels],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Performance harness: how much of opening a message is the WebView.
|
// Performance harness: how much of opening a message is the WebView.
|
||||||
@@ -765,6 +816,18 @@ export default function EmailBodyView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.wrapper, fill && styles.wrapperFill]}>
|
<View style={[styles.wrapper, fill && styles.wrapperFill]}>
|
||||||
|
{plainText !== null && (
|
||||||
|
<View style={styles.plainNote}>
|
||||||
|
<Text style={styles.bannerText}>
|
||||||
|
{t('email_viewer.shown_as_plain_text', 'This message is shown as plain text.')}
|
||||||
|
</Text>
|
||||||
|
{onShowFormatted && (
|
||||||
|
<Pressable onPress={onShowFormatted} hitSlop={8} accessibilityRole="button">
|
||||||
|
<Text style={styles.plainNoteLink}>{t('email_viewer.show_formatted', 'Show formatted')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
{showBanner && (
|
{showBanner && (
|
||||||
<View style={styles.banner}>
|
<View style={styles.banner}>
|
||||||
<Text style={styles.bannerText}>
|
<Text style={styles.bannerText}>
|
||||||
@@ -821,6 +884,9 @@ export default function EmailBodyView({
|
|||||||
// Native Android zoom would scale the viewport inside the fixed-size
|
// Native Android zoom would scale the viewport inside the fixed-size
|
||||||
// container; pinch zoom is implemented in the page instead.
|
// container; pinch zoom is implemented in the page instead.
|
||||||
setBuiltInZoomControls={false}
|
setBuiltInZoomControls={false}
|
||||||
|
// The system font size is part of textFactor; Android's WebView
|
||||||
|
// would scale the text by it a second time.
|
||||||
|
textZoom={100}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
setSupportMultipleWindows={false}
|
setSupportMultipleWindows={false}
|
||||||
@@ -895,6 +961,19 @@ function makeStyles(c: ThemePalette) {
|
|||||||
...typography.caption,
|
...typography.caption,
|
||||||
color: c.textSecondary,
|
color: c.textSecondary,
|
||||||
},
|
},
|
||||||
|
plainNote: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
alignItems: 'center',
|
||||||
|
columnGap: spacing.sm,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingTop: spacing.sm,
|
||||||
|
},
|
||||||
|
plainNoteLink: {
|
||||||
|
...typography.caption,
|
||||||
|
color: c.primary,
|
||||||
|
textDecorationLine: 'underline',
|
||||||
|
},
|
||||||
webContainer: {
|
webContainer: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
|
|||||||
@@ -13,19 +13,24 @@ import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailb
|
|||||||
import { useSheetDrag } from '../lib/use-sheet-drag';
|
import { useSheetDrag } from '../lib/use-sheet-drag';
|
||||||
import { useLocaleStore } from '../stores/locale-store';
|
import { useLocaleStore } from '../stores/locale-store';
|
||||||
import { localizeMailboxName } from '../lib/mailbox-label';
|
import { localizeMailboxName } from '../lib/mailbox-label';
|
||||||
|
import { mailboxIconKind, type MailboxIconKind } from '../lib/mailbox-icon';
|
||||||
import type { Mailbox } from '../api/types';
|
import type { Mailbox } from '../api/types';
|
||||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||||
import { useSheetInsets } from '../lib/use-layout';
|
import { useSheetInsets } from '../lib/use-layout';
|
||||||
|
|
||||||
function moveTargetIcon(role: string | null | undefined, name: string): AppIcon {
|
// By role only, like the sidebar (mailboxIconKind, #1235): a user folder
|
||||||
const lower = name.toLowerCase();
|
// named "web/sentry" is not the Sent folder.
|
||||||
if (role === 'inbox' || lower.includes('inbox')) return Inbox;
|
const MOVE_TARGET_ICONS: Partial<Record<MailboxIconKind, AppIcon>> = {
|
||||||
if (role === 'sent' || lower.includes('sent')) return Send;
|
inbox: Inbox,
|
||||||
if (role === 'drafts' || lower.includes('draft')) return FileIcon;
|
sent: Send,
|
||||||
if (role === 'trash' || lower.includes('trash') || lower.includes('deleted')) return Trash2;
|
drafts: FileIcon,
|
||||||
if (role === 'junk' || role === 'spam' || lower.includes('junk') || lower.includes('spam')) return Ban;
|
trash: Trash2,
|
||||||
if (role === 'archive' || lower.includes('archive')) return Archive;
|
junk: Ban,
|
||||||
return Folder;
|
archive: Archive,
|
||||||
|
};
|
||||||
|
|
||||||
|
function moveTargetIcon(role: string | null | undefined): AppIcon {
|
||||||
|
return MOVE_TARGET_ICONS[mailboxIconKind(role, false, false)] ?? Folder;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MoveSheetProps {
|
interface MoveSheetProps {
|
||||||
@@ -106,7 +111,7 @@ export function MoveSheet({
|
|||||||
</View>
|
</View>
|
||||||
<ScrollView>
|
<ScrollView>
|
||||||
{visibleNodes.map((node) => {
|
{visibleNodes.map((node) => {
|
||||||
const Icon = moveTargetIcon(node.role, node.name);
|
const Icon = moveTargetIcon(node.role);
|
||||||
const isCurrent = node.id === currentMailboxId;
|
const isCurrent = node.id === currentMailboxId;
|
||||||
// A shared account's header is a grouping row, not a folder, and
|
// A shared account's header is a grouping row, not a folder, and
|
||||||
// Drafts is never a move target (the webmail excludes it too).
|
// Drafts is never a move target (the webmail excludes it too).
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, Text, TextInput, Pressable, StyleSheet } from 'react-native';
|
||||||
|
import { Plus } from './icons';
|
||||||
|
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||||
|
import { useColors } from '../theme/colors';
|
||||||
|
import { useLocaleStore } from '../stores/locale-store';
|
||||||
|
import { keywordToken, useKeywordsStore, type KeywordDef } from '../stores/keywords-store';
|
||||||
|
import { checkNewTag, newTagDefinition } from '../lib/new-tag';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "New tag" at the end of a tag picker: a name field; the tag gets the next
|
||||||
|
* colour of the palette, is saved with the other tags and goes onto the
|
||||||
|
* message(s) straight away (webmail components/email/tag-picker.tsx).
|
||||||
|
*/
|
||||||
|
export function NewTagRow({ keywords, onCreated }: {
|
||||||
|
/** The tags the picker lists: a new id must not repeat one of them. */
|
||||||
|
keywords: KeywordDef[];
|
||||||
|
/** Apply the new tag's keyword token. */
|
||||||
|
onCreated: (token: string) => void;
|
||||||
|
}) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
const addKeyword = useKeywordsStore((s) => s.add);
|
||||||
|
const [creating, setCreating] = React.useState(false);
|
||||||
|
const [name, setName] = React.useState('');
|
||||||
|
const { error } = checkNewTag(name, keywords);
|
||||||
|
const errorText = error === 'tag_exists'
|
||||||
|
? t('email_viewer.tag_exists', 'A tag with this name already exists')
|
||||||
|
: error === 'tag_name_invalid' ? t('email_viewer.tag_name_invalid', 'Choose a shorter name') : null;
|
||||||
|
|
||||||
|
const create = () => {
|
||||||
|
const def = newTagDefinition(name, keywords);
|
||||||
|
if (!def) return;
|
||||||
|
addKeyword(def);
|
||||||
|
setCreating(false);
|
||||||
|
setName('');
|
||||||
|
onCreated(keywordToken(def.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!creating) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setCreating(true)}
|
||||||
|
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
testID="tag-picker-new"
|
||||||
|
>
|
||||||
|
<Plus size={16} color={c.textMuted} />
|
||||||
|
<Text style={styles.label}>{t('email_viewer.new_tag', 'New tag')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<View style={styles.form}>
|
||||||
|
<TextInput
|
||||||
|
autoFocus
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
onSubmitEditing={create}
|
||||||
|
returnKeyType="done"
|
||||||
|
placeholder={t('email_viewer.new_tag_placeholder', 'Tag name')}
|
||||||
|
placeholderTextColor={c.textMuted}
|
||||||
|
accessibilityLabel={t('email_viewer.new_tag_placeholder', 'Tag name')}
|
||||||
|
style={[styles.input, errorText ? styles.inputError : null]}
|
||||||
|
/>
|
||||||
|
{errorText && <Text style={styles.error} accessibilityRole="alert">{errorText}</Text>}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.sm,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingVertical: spacing.sm + 2,
|
||||||
|
minHeight: 44,
|
||||||
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderTopColor: c.border,
|
||||||
|
},
|
||||||
|
rowPressed: { backgroundColor: c.surfaceHover },
|
||||||
|
label: { ...typography.body, color: c.textSecondary, flex: 1 },
|
||||||
|
form: { paddingHorizontal: spacing.lg, paddingVertical: spacing.sm, gap: spacing.xs },
|
||||||
|
input: {
|
||||||
|
...typography.body,
|
||||||
|
color: c.text,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: c.border,
|
||||||
|
borderRadius: radius.sm,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
minHeight: 40,
|
||||||
|
},
|
||||||
|
inputError: { borderColor: c.error },
|
||||||
|
error: { ...typography.caption, color: c.error },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import React from 'react';
|
|||||||
import { View, Text, Image, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
|
import { View, Text, Image, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import {
|
import {
|
||||||
|
avatarContent,
|
||||||
|
initialsColor,
|
||||||
generateEmailAvatarColor,
|
generateEmailAvatarColor,
|
||||||
getContactPhotoForEmail,
|
getContactPhotoForEmail,
|
||||||
getEmailInitials,
|
getEmailInitials,
|
||||||
@@ -14,6 +16,8 @@ import type { ContactPhotoRef } from '../lib/contact-utils';
|
|||||||
import { useContactPhotoSource } from '../lib/use-contact-photo';
|
import { useContactPhotoSource } from '../lib/use-contact-photo';
|
||||||
import { useSettingsStore } from '../stores/settings-store';
|
import { useSettingsStore } from '../stores/settings-store';
|
||||||
import { useContactsStore } from '../stores/contacts-store';
|
import { useContactsStore } from '../stores/contacts-store';
|
||||||
|
import { useColors } from '../theme/colors';
|
||||||
|
import { ShieldAlert } from './icons';
|
||||||
|
|
||||||
interface SenderAvatarProps {
|
interface SenderAvatarProps {
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
@@ -32,23 +36,32 @@ interface SenderAvatarProps {
|
|||||||
* the contact has no photo, so no other card's photo is borrowed.
|
* the contact has no photo, so no other card's photo is borrowed.
|
||||||
*/
|
*/
|
||||||
photo?: ContactPhotoRef | null;
|
photo?: ContactPhotoRef | null;
|
||||||
|
/**
|
||||||
|
* The message's sender check failed or is unverified (see
|
||||||
|
* useSenderWarning): a warning shield with this label instead of any photo,
|
||||||
|
* favicon or initials, so a forged From doesn't get the real sender's logo
|
||||||
|
* or photo (webmail #1439).
|
||||||
|
*/
|
||||||
|
senderWarning?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirrors the webmail `Avatar` priority for the subset available on native:
|
// Mirrors the webmail `Avatar` priority for the subset available on native:
|
||||||
// address-book photo → company favicon (if sender domain is non-personal and
|
// sender warning → address-book photo → company favicon (if sender domain is
|
||||||
// `senderFavicons` is on) → initials over HSL-hashed color.
|
// non-personal and `senderFavicons` is on) → initials over HSL-hashed color.
|
||||||
export default function SenderAvatar({ name, email, size = 40, style, disableImages, photo }: SenderAvatarProps) {
|
export default function SenderAvatar({ name, email, size = 40, style, disableImages, photo, senderWarning }: SenderAvatarProps) {
|
||||||
const senderFavicons = useSettingsStore((s) => s.senderFavicons) && !disableImages;
|
const c = useColors();
|
||||||
|
const warned = !!senderWarning;
|
||||||
|
const senderFavicons = useSettingsStore((s) => s.senderFavicons) && !disableImages && !warned;
|
||||||
// Compared shallowly, so only avatars whose photo changed re-render when the
|
// Compared shallowly, so only avatars whose photo changed re-render when the
|
||||||
// contacts change; the index behind it is built once per contacts array.
|
// contacts change; the index behind it is built once per contacts array.
|
||||||
const indexedPhoto = useContactsStore(
|
const indexedPhoto = useContactsStore(
|
||||||
useShallow((s) => (photo === undefined ? getContactPhotoForEmail(s.contacts, email) : undefined)),
|
useShallow((s) => (photo === undefined && !warned ? getContactPhotoForEmail(s.contacts, email) : undefined)),
|
||||||
);
|
);
|
||||||
const contactPhoto = photo === undefined ? indexedPhoto : photo;
|
const contactPhoto = photo === undefined ? indexedPhoto : photo;
|
||||||
// A blob photo (Stalwart 1.0) has no source while it waits for its
|
// A blob photo (Stalwart 1.0) has no source while it waits for its
|
||||||
// download or after it failed; the favicon or initials show meanwhile.
|
// download or after it failed; the favicon or initials show meanwhile.
|
||||||
const { source: photoSource, onLoad: onPhotoLoad, onError: onPhotoError } =
|
const { source: photoSource, onLoad: onPhotoLoad, onError: onPhotoError } =
|
||||||
useContactPhotoSource(disableImages ? null : contactPhoto);
|
useContactPhotoSource(disableImages || warned ? null : contactPhoto);
|
||||||
const [imgError, setImgError] = React.useState(false);
|
const [imgError, setImgError] = React.useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -61,8 +74,11 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
|
|||||||
|
|
||||||
const faviconDomain = getFaviconDomain(email ?? undefined);
|
const faviconDomain = getFaviconDomain(email ?? undefined);
|
||||||
const domainFailed = faviconDomain ? hasFaviconFailed(faviconDomain) : false;
|
const domainFailed = faviconDomain ? hasFaviconFailed(faviconDomain) : false;
|
||||||
const showPhoto = !disableImages && !!photoSource;
|
const content = avatarContent({
|
||||||
const showFavicon = !showPhoto && senderFavicons && !!faviconDomain && !imgError && !domainFailed;
|
senderWarning,
|
||||||
|
hasPhoto: !disableImages && !!photoSource,
|
||||||
|
showFavicon: senderFavicons && !!faviconDomain && !imgError && !domainFailed,
|
||||||
|
});
|
||||||
|
|
||||||
const handleError = React.useCallback(() => {
|
const handleError = React.useCallback(() => {
|
||||||
setImgError(true);
|
setImgError(true);
|
||||||
@@ -75,14 +91,29 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
|
|||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
borderRadius: size / 2,
|
borderRadius: size / 2,
|
||||||
backgroundColor: showPhoto ? 'transparent' : showFavicon ? '#ffffff' : bgColor,
|
backgroundColor: content === 'warning'
|
||||||
|
? c.errorBg
|
||||||
|
: content === 'photo' ? 'transparent' : content === 'favicon' ? '#ffffff' : bgColor,
|
||||||
},
|
},
|
||||||
style,
|
style,
|
||||||
];
|
];
|
||||||
|
|
||||||
const fontSize = Math.round(size * 0.4);
|
const fontSize = Math.round(size * 0.4);
|
||||||
|
|
||||||
if (showPhoto && photoSource) {
|
if (content === 'warning') {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={containerStyle}
|
||||||
|
accessible
|
||||||
|
accessibilityLabel={`${primaryName || email || ''} (${senderWarning})`}
|
||||||
|
testID="sender-warning-avatar"
|
||||||
|
>
|
||||||
|
<ShieldAlert size={Math.round(size * 0.5)} color={c.error} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content === 'photo' && photoSource) {
|
||||||
return (
|
return (
|
||||||
<View style={containerStyle}>
|
<View style={containerStyle}>
|
||||||
<Image
|
<Image
|
||||||
@@ -96,7 +127,7 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showFavicon && faviconDomain) {
|
if (content === 'favicon' && faviconDomain) {
|
||||||
return (
|
return (
|
||||||
<View style={containerStyle}>
|
<View style={containerStyle}>
|
||||||
<Image
|
<Image
|
||||||
@@ -111,7 +142,7 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={containerStyle}>
|
<View style={containerStyle}>
|
||||||
<Text style={[styles.initials, { fontSize }]}>{initials}</Text>
|
<Text style={[styles.initials, { fontSize, color: initialsColor(bgColor) }]}>{initials}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||||
import { useColors } from '../theme/colors';
|
import { useColors } from '../theme/colors';
|
||||||
import { useAnimDuration } from '../theme/dynamic';
|
import { useAnimDuration } from '../theme/dynamic';
|
||||||
import { useEmailStore, spannedAccounts } from '../stores/email-store';
|
import { useEmailStore, spannedAccounts, trashAndJunkIds } from '../stores/email-store';
|
||||||
import { useAuthStore } from '../stores/auth-store';
|
import { useAuthStore } from '../stores/auth-store';
|
||||||
import { useAccountStore } from '../stores/account-store';
|
import { useAccountStore } from '../stores/account-store';
|
||||||
import { useSettingsStore } from '../stores/settings-store';
|
import { useSettingsStore } from '../stores/settings-store';
|
||||||
@@ -128,7 +128,7 @@ function RowCounts({ unread, total, showTotal, onPressUnread }: {
|
|||||||
disabled={!onPressUnread}
|
disabled={!onPressUnread}
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
accessibilityRole={onPressUnread ? 'button' : undefined}
|
accessibilityRole={onPressUnread ? 'button' : undefined}
|
||||||
accessibilityLabel={onPressUnread ? t('sidebar.show_unread', 'Show unread') : undefined}
|
accessibilityLabel={onPressUnread ? t('sidebar.unread_filter', 'Show unread only ({count})', { count: unread }) : undefined}
|
||||||
>
|
>
|
||||||
<Text style={styles.countUnread}>{unread}</Text>
|
<Text style={styles.countUnread}>{unread}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -167,11 +167,19 @@ function SidebarRow({
|
|||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const t = useLocaleStore((s) => s.t);
|
const t = useLocaleStore((s) => s.t);
|
||||||
const leftPad = ROW_PX_BASE + depth * INDENT_STEP;
|
const leftPad = ROW_PX_BASE + depth * INDENT_STEP;
|
||||||
|
// Which folder is open, and the unread count with the folder's name
|
||||||
|
// ("Inbox, 3 unread"), for screen readers (webmail 7c282f1f4).
|
||||||
|
const accessibleName = unread > 0 && !isAccountHeader
|
||||||
|
? t('shell.name_unread', '{name}, {count} unread', { name: label, count: unread })
|
||||||
|
: label;
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
onLongPress={onLongPress}
|
onLongPress={onLongPress}
|
||||||
delayLongPress={350}
|
delayLongPress={350}
|
||||||
|
accessibilityRole={isAccountHeader ? 'header' : 'button'}
|
||||||
|
accessibilityLabel={accessibleName}
|
||||||
|
accessibilityState={isAccountHeader ? undefined : { selected: isSelected }}
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [
|
||||||
styles.row,
|
styles.row,
|
||||||
isSelected && styles.rowSelected,
|
isSelected && styles.rowSelected,
|
||||||
@@ -454,8 +462,10 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!visible || !tagsExpanded || keywordDefs.length === 0 || !activeAccountId || !jmapClient.isConnected) return;
|
if (!visible || !tagsExpanded || keywordDefs.length === 0 || !activeAccountId || !jmapClient.isConnected) return;
|
||||||
const accounts = tagAccountsKey.split('|').map((id) => id || undefined);
|
const accounts = tagAccountsKey.split('|').map((id) => id || undefined);
|
||||||
void ensureTagCounts(activeAccountId, keywordDefs.map((k) => k.id), accounts);
|
// Trash and Junk stay out of the counts as they do of the tag view (#1156).
|
||||||
}, [visible, tagsExpanded, keywordDefs, tagAccountsKey, activeAccountId, tagCountsGeneration, ensureTagCounts]);
|
const excluded = Object.fromEntries(accounts.map((id) => [id ?? '', trashAndJunkIds(mailboxes, id)]));
|
||||||
|
void ensureTagCounts(activeAccountId, keywordDefs.map((k) => k.id), accounts, excluded);
|
||||||
|
}, [visible, tagsExpanded, keywordDefs, tagAccountsKey, mailboxes, activeAccountId, tagCountsGeneration, ensureTagCounts]);
|
||||||
|
|
||||||
// ── Folder actions (long-press) ───────────────────────────────────────
|
// ── Folder actions (long-press) ───────────────────────────────────────
|
||||||
const refFor = (mb: Mailbox) => ({
|
const refFor = (mb: Mailbox) => ({
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { getEmailTagIds } from '../lib/thread-utils';
|
|||||||
import type { Email } from '../api/types';
|
import type { Email } from '../api/types';
|
||||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||||
import { useSheetInsets } from '../lib/use-layout';
|
import { useSheetInsets } from '../lib/use-layout';
|
||||||
|
import { NewTagRow } from './NewTagRow';
|
||||||
|
|
||||||
interface TagSheetProps {
|
interface TagSheetProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -132,6 +133,7 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
<NewTagRow keywords={keywords} onCreated={(token) => onToggle(token, true)} />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
// Selecting mail needed a long press; native #91 asks for a tap on the
|
||||||
|
// left side, as the webmail's avatar does.
|
||||||
|
|
||||||
|
describe('list row avatar (#91)', () => {
|
||||||
|
it('toggles the row into the selection on a tap', () => {
|
||||||
|
const src = fs.readFileSync(path.resolve(__dirname, '../../screens/EmailListScreen.tsx'), 'utf8');
|
||||||
|
const avatar = src.slice(src.indexOf('{density.showAvatar && ('), src.indexOf('{/* Content */}'));
|
||||||
|
expect(avatar).toMatch(/<Pressable\s+onPress=\{handleLongPress\}/);
|
||||||
|
expect(avatar).toMatch(/accessibilityRole="checkbox"/);
|
||||||
|
expect(avatar).toMatch(/accessibilityState=\{\{ checked: selected \}\}/);
|
||||||
|
expect(avatar).toMatch(/<SenderAvatar/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
// "Plain Text Only" reads "Compose and read in plain text only", but only the
|
||||||
|
// composer honoured it: the reader always showed the HTML. The webmail reads
|
||||||
|
// HTML mail as plain text by default or per message (1e929b0a9). The
|
||||||
|
// conversion itself is tested in lib/__tests__/html-to-readable-text.test.ts;
|
||||||
|
// this checks the reader is wired to it.
|
||||||
|
|
||||||
|
const src = (p: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', p), 'utf8');
|
||||||
|
|
||||||
|
describe('plain-text reading', () => {
|
||||||
|
it('renders the plain-text reading when the setting or the per-message switch is on', () => {
|
||||||
|
const body = src('components/EmailBodyView.tsx');
|
||||||
|
expect(body).toMatch(/useSettingsStore\(\(s\) => s\.plainTextMode\)/);
|
||||||
|
expect(body).toMatch(/const plainView = plainViewOverride \?\? plainTextMode;/);
|
||||||
|
expect(body).toMatch(/plainTextReading\(\{ html: formattedHtml, text: picked\.text \}\)/);
|
||||||
|
// The document is built from the text, not the HTML, while it is on.
|
||||||
|
expect(body).toMatch(/const rawHtml = plainText !== null \? null : formattedHtml;/);
|
||||||
|
expect(body).toMatch(/email_viewer\.shown_as_plain_text/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers "Show as plain text" per message in the More sheet and passes it to every card', () => {
|
||||||
|
const screen = src('screens/EmailThreadScreen.tsx');
|
||||||
|
expect(screen).toMatch(/email_viewer\.show_as_plain_text/);
|
||||||
|
expect(screen).toMatch(/plainView=\{plainOverrides\[m\.id\] \?\? null\}/);
|
||||||
|
expect(src('components/email/MessageContent.tsx')).toMatch(/plainView=\{plainView\}/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
// List chips showed a typed, coloured icon per file; the reader's chips a
|
||||||
|
// grey paperclip for everything (webmail a78d03a18 uses the list's icons in
|
||||||
|
// the reader too).
|
||||||
|
|
||||||
|
const src = (p: string) => fs.readFileSync(path.resolve(__dirname, '..', p), 'utf8');
|
||||||
|
|
||||||
|
describe('reader attachment chip icons', () => {
|
||||||
|
it('draws the same typed icon and colour as the list chips', () => {
|
||||||
|
const reader = src('email/AttachmentChips.tsx');
|
||||||
|
expect(reader).not.toMatch(/<Paperclip /);
|
||||||
|
expect(reader).toMatch(/const kind = attachmentKind\(item\.type, item\.name\);/);
|
||||||
|
expect(reader).toMatch(/<Icon size=\{14\} color=\{attachmentIconColor\(kind, c\)\} \/>/);
|
||||||
|
const list = src('email/ListAttachmentChips.tsx');
|
||||||
|
expect(list).toMatch(/ATTACHMENT_ICONS\[kind\]/);
|
||||||
|
expect(list).toMatch(/attachmentIconColor\(kind, c\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
vi.mock('../../api/jmap-client', () => ({
|
||||||
|
jmapClient: { accountId: 'acc-1', request: vi.fn(), getMaxObjectsInGet: vi.fn(() => 50) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { avatarContent } from '../../lib/avatar-utils';
|
||||||
|
import { LIST_AUTH_RESULTS_PROPERTY, senderWarningOf } from '../../lib/email-headers';
|
||||||
|
import { EMAIL_FULL_PROPERTIES, EMAIL_LIST_PROPERTIES } from '../../api/email';
|
||||||
|
import type { Email } from '../../api/types';
|
||||||
|
|
||||||
|
// A forged From kept the real sender's favicon or contact photo: the reader
|
||||||
|
// worked out that the sender check failed, but the avatar beside it still
|
||||||
|
// showed the claimed sender's logo, and list rows had no auth data at all
|
||||||
|
// (webmail 605a3983b, #1439). Now the avatar shows a warning shield.
|
||||||
|
|
||||||
|
const FORGED = 'mx.example.org; spf=fail smtp.mailfrom=evil.example; dkim=none; dmarc=fail header.from=bank.example';
|
||||||
|
const GOOD = 'mx.example.org; spf=pass smtp.mailfrom=bank.example; dkim=pass header.d=bank.example; dmarc=pass header.from=bank.example';
|
||||||
|
|
||||||
|
function row(auth: string[] | undefined): Email {
|
||||||
|
return {
|
||||||
|
id: 'm1', threadId: 't1', mailboxIds: {}, keywords: {}, size: 1, receivedAt: '2026-10-31T10:00:00Z',
|
||||||
|
hasAttachment: false, from: [{ name: 'Bank', email: 'ceo@bank.example' }],
|
||||||
|
...(auth ? { [LIST_AUTH_RESULTS_PROPERTY]: auth } : {}),
|
||||||
|
} as Email;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('sender warning in avatars (bulwarkmail/webmail#1439)', () => {
|
||||||
|
it('asks for the Authentication-Results headers in the list fetch, not in the full one', () => {
|
||||||
|
expect(EMAIL_LIST_PROPERTIES).toContain('header:Authentication-Results:all');
|
||||||
|
// The full fetch has `headers`, which carry the same header.
|
||||||
|
expect(EMAIL_FULL_PROPERTIES).not.toContain('header:Authentication-Results:all');
|
||||||
|
expect(EMAIL_FULL_PROPERTIES).toContain('headers');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('judges a list row from the listed header and a full message from its headers', () => {
|
||||||
|
expect(senderWarningOf(row([FORGED]))?.status).toBe('failed');
|
||||||
|
expect(senderWarningOf(row([GOOD]))).toBeNull();
|
||||||
|
expect(senderWarningOf(row(undefined))).toBeNull();
|
||||||
|
expect(senderWarningOf(row([]))).toBeNull();
|
||||||
|
const full = { ...row(undefined), headers: [{ name: 'Authentication-Results', value: ` ${FORGED}` }] };
|
||||||
|
expect(senderWarningOf(full)?.status).toBe('failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the shield before a contact photo or favicon', () => {
|
||||||
|
expect(avatarContent({ senderWarning: 'Sender check failed', hasPhoto: true, showFavicon: true })).toBe('warning');
|
||||||
|
expect(avatarContent({ senderWarning: undefined, hasPhoto: true, showFavicon: true })).toBe('photo');
|
||||||
|
expect(avatarContent({ senderWarning: null, hasPhoto: false, showFavicon: true })).toBe('favicon');
|
||||||
|
expect(avatarContent({ hasPhoto: false, showFavicon: false })).toBe('initials');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the warning to the avatar in the reader, collapsed conversation cards and list rows', () => {
|
||||||
|
const src = (p: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', p), 'utf8');
|
||||||
|
expect(src('components/email/MessageHeader.tsx')).toMatch(/senderWarning=\{verificationText\?\.label\}/);
|
||||||
|
expect(src('components/email/ThreadMessageCard.tsx')).toMatch(/senderWarning=\{senderWarning\}/);
|
||||||
|
expect(src('screens/EmailListScreen.tsx')).toMatch(/senderWarning=\{senderWarning\}/);
|
||||||
|
expect(src('screens/UnifiedInboxScreen.tsx')).toMatch(/senderWarning=\{verification \?/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
// Screen readers couldn't tell which folder was open, and the unread count
|
||||||
|
// was a button read as "Show unread" with no number (webmail 7c282f1f4).
|
||||||
|
|
||||||
|
describe('folder list names', () => {
|
||||||
|
const src = fs.readFileSync(path.resolve(__dirname, '../SidebarDrawer.tsx'), 'utf8');
|
||||||
|
|
||||||
|
it('marks the open folder as selected and says its unread count with its name', () => {
|
||||||
|
expect(src).toMatch(/accessibilityState=\{isAccountHeader \? undefined : \{ selected: isSelected \}\}/);
|
||||||
|
expect(src).toMatch(/t\('shell\.name_unread', '\{name\}, \{count\} unread', \{ name: label, count: unread \}\)/);
|
||||||
|
expect(src).toMatch(/accessibilityLabel=\{accessibleName\}/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the unread filter button with its count', () => {
|
||||||
|
expect(src).not.toMatch(/sidebar\.show_unread/);
|
||||||
|
expect(src).toMatch(/t\('sidebar\.unread_filter', 'Show unread only \(\{count\}\)', \{ count: unread \}\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet, Pressable, ActivityIndicator, Alert } from 'react-native';
|
import { View, Text, StyleSheet, Pressable, ActivityIndicator, Alert } from 'react-native';
|
||||||
import { Paperclip, Download, Eye, ExternalLink, Save, Share2, FolderArchive, FolderOpen } from '../icons';
|
import { Download, Eye, ExternalLink, Save, Share2, FolderArchive, FolderOpen } from '../icons';
|
||||||
import type { Attachment, Email } from '../../api/types';
|
import type { Attachment, Email } from '../../api/types';
|
||||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
import { useColors } from '../../theme/colors';
|
import { useColors } from '../../theme/colors';
|
||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
getAttachmentDisplayName, visibleAttachments, formatSize,
|
getAttachmentDisplayName, visibleAttachments, formatSize,
|
||||||
} from '../../lib/attachment-display';
|
} from '../../lib/attachment-display';
|
||||||
import { emailExportFilename } from '../../lib/download-filename';
|
import { emailExportFilename } from '../../lib/download-filename';
|
||||||
|
import { attachmentKind } from '../../lib/list-attachments';
|
||||||
|
import { ATTACHMENT_ICONS, attachmentIconColor } from './attachment-icons';
|
||||||
import { ActionSheet, type ActionSheetItem } from './ActionSheet';
|
import { ActionSheet, type ActionSheetItem } from './ActionSheet';
|
||||||
import { AttachmentPreviewModal } from './AttachmentPreviewModal';
|
import { AttachmentPreviewModal } from './AttachmentPreviewModal';
|
||||||
import type { ExtractedAttachment } from './use-body-override';
|
import type { ExtractedAttachment } from './use-body-override';
|
||||||
@@ -232,6 +234,9 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
|
|||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
{visible.map((item) => {
|
{visible.map((item) => {
|
||||||
const busy = busyKey === item.key;
|
const busy = busyKey === item.key;
|
||||||
|
// Typed and coloured like the list's chips (webmail a78d03a18).
|
||||||
|
const kind = attachmentKind(item.type, item.name);
|
||||||
|
const Icon = ATTACHMENT_ICONS[kind];
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={item.key}
|
key={item.key}
|
||||||
@@ -241,7 +246,7 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
|
|||||||
disabled={!!busyKey}
|
disabled={!!busyKey}
|
||||||
accessibilityLabel={item.name}
|
accessibilityLabel={item.name}
|
||||||
>
|
>
|
||||||
{busy ? <ActivityIndicator size="small" color={c.textMuted} /> : <Paperclip size={14} color={c.textMuted} />}
|
{busy ? <ActivityIndicator size="small" color={c.textMuted} /> : <Icon size={14} color={attachmentIconColor(kind, c)} />}
|
||||||
<Text style={styles.name} numberOfLines={1}>{item.name}</Text>
|
<Text style={styles.name} numberOfLines={1}>{item.name}</Text>
|
||||||
<Text style={styles.size}>{formatSize(item.size)}</Text>
|
<Text style={styles.size}>{formatSize(item.size)}</Text>
|
||||||
<Download size={14} color={c.textMuted} />
|
<Download size={14} color={c.textMuted} />
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
|
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
import { File as FileIcon, FileArchive, FileImage, FileSpreadsheet, FileText } from '../icons';
|
|
||||||
import type { Attachment, Email, Mailbox } from '../../api/types';
|
import type { Attachment, Email, Mailbox } from '../../api/types';
|
||||||
import { getEmailAttachments } from '../../api/email';
|
import { getEmailAttachments } from '../../api/email';
|
||||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
@@ -13,8 +12,9 @@ import { accountIdOfRow } from '../../stores/email-store';
|
|||||||
import { accountScopedId } from '../../lib/thread-utils';
|
import { accountScopedId } from '../../lib/thread-utils';
|
||||||
import {
|
import {
|
||||||
attachmentKind, realAttachments, requestListAttachments, shortAttachmentName,
|
attachmentKind, realAttachments, requestListAttachments, shortAttachmentName,
|
||||||
type AttachmentKind, type LoadListAttachments,
|
type LoadListAttachments,
|
||||||
} from '../../lib/list-attachments';
|
} from '../../lib/list-attachments';
|
||||||
|
import { ATTACHMENT_ICONS, attachmentIconColor } from './attachment-icons';
|
||||||
import { getAttachmentDisplayName } from '../../lib/attachment-display';
|
import { getAttachmentDisplayName } from '../../lib/attachment-display';
|
||||||
import { attachmentSaveName, downloadAttachment } from '../../lib/email-export';
|
import { attachmentSaveName, downloadAttachment } from '../../lib/email-export';
|
||||||
import { AttachmentPreviewModal } from './AttachmentPreviewModal';
|
import { AttachmentPreviewModal } from './AttachmentPreviewModal';
|
||||||
@@ -23,31 +23,11 @@ import { useFilePreview } from './use-file-preview';
|
|||||||
/** How many chips a row shows before collapsing the rest into a count. */
|
/** How many chips a row shows before collapsing the rest into a count. */
|
||||||
const MAX_CHIPS = 2;
|
const MAX_CHIPS = 2;
|
||||||
|
|
||||||
const ICONS: Record<AttachmentKind, typeof FileIcon> = {
|
|
||||||
image: FileImage,
|
|
||||||
pdf: FileText,
|
|
||||||
sheet: FileSpreadsheet,
|
|
||||||
archive: FileArchive,
|
|
||||||
document: FileText,
|
|
||||||
other: FileIcon,
|
|
||||||
};
|
|
||||||
|
|
||||||
function iconColor(kind: AttachmentKind, c: ThemePalette): string {
|
|
||||||
switch (kind) {
|
|
||||||
case 'image': return c.tags.purple.dot;
|
|
||||||
case 'pdf': return c.tags.red.dot;
|
|
||||||
case 'sheet': return c.tags.green.dot;
|
|
||||||
case 'archive': return c.tags.amber.dot;
|
|
||||||
case 'document': return c.tags.blue.dot;
|
|
||||||
default: return c.textMuted;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The row's attachment parts: the ones the email already carries (a message
|
* The row's attachment parts: the ones the email already carries (a message
|
||||||
* opened before), else loaded lazily when it has a paperclip.
|
* opened before), else loaded lazily when it has a paperclip.
|
||||||
*/
|
*/
|
||||||
function useListAttachments(email: Email, load?: LoadListAttachments): Attachment[] | undefined {
|
export function useListAttachments(email: Email, load?: LoadListAttachments): Attachment[] | undefined {
|
||||||
const [loaded, setLoaded] = React.useState<{ key: string; attachments: Attachment[] } | null>(null);
|
const [loaded, setLoaded] = React.useState<{ key: string; attachments: Attachment[] } | null>(null);
|
||||||
const needsLoad = !!load && !!email.hasAttachment && !email.attachments;
|
const needsLoad = !!load && !!email.hasAttachment && !email.attachments;
|
||||||
// Rows of a list spanning accounts can share an id (#1082).
|
// Rows of a list spanning accounts can share an id (#1082).
|
||||||
@@ -67,7 +47,10 @@ function useListAttachments(email: Email, load?: LoadListAttachments): Attachmen
|
|||||||
|
|
||||||
interface ChipsProps {
|
interface ChipsProps {
|
||||||
email: Email;
|
email: Email;
|
||||||
load?: LoadListAttachments;
|
/** The row's parts (useListAttachments); undefined while they load. */
|
||||||
|
attachments: Attachment[] | undefined;
|
||||||
|
/** The parts are being loaded: hold the chips' line so the row doesn't grow. */
|
||||||
|
pending?: boolean;
|
||||||
onOpen: (email: Email, attachment: Attachment) => void;
|
onOpen: (email: Email, attachment: Attachment) => void;
|
||||||
/** Selection mode: taps belong to the row. */
|
/** Selection mode: taps belong to the row. */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -79,13 +62,23 @@ interface ChipsProps {
|
|||||||
* that file directly.
|
* that file directly.
|
||||||
*/
|
*/
|
||||||
export const ListAttachmentChips = React.memo(function ListAttachmentChips({
|
export const ListAttachmentChips = React.memo(function ListAttachmentChips({
|
||||||
email, load, onOpen, disabled,
|
email, attachments, pending, onOpen, disabled,
|
||||||
}: ChipsProps) {
|
}: ChipsProps) {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const dyn = useTypography();
|
const dyn = useTypography();
|
||||||
const attachments = useListAttachments(email, load);
|
|
||||||
const real = React.useMemo(() => realAttachments(attachments), [attachments]);
|
const real = React.useMemo(() => realAttachments(attachments), [attachments]);
|
||||||
|
// The line the chips will take, held while they load (webmail 7b25d3a95):
|
||||||
|
// the row grew under the finger when they arrived.
|
||||||
|
if (pending && attachments === undefined) {
|
||||||
|
return (
|
||||||
|
<View style={styles.row} accessibilityElementsHidden importantForAccessibility="no-hide-descendants" testID="list-chips-placeholder">
|
||||||
|
<View style={[styles.chip, styles.placeholderChip]}>
|
||||||
|
<Text style={[styles.name, dyn.caption]}> </Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (real.length === 0) return null;
|
if (real.length === 0) return null;
|
||||||
|
|
||||||
const shown = real.slice(0, MAX_CHIPS);
|
const shown = real.slice(0, MAX_CHIPS);
|
||||||
@@ -94,7 +87,7 @@ export const ListAttachmentChips = React.memo(function ListAttachmentChips({
|
|||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
{shown.map((a, i) => {
|
{shown.map((a, i) => {
|
||||||
const kind = attachmentKind(a.type, a.name);
|
const kind = attachmentKind(a.type, a.name);
|
||||||
const Icon = ICONS[kind];
|
const Icon = ATTACHMENT_ICONS[kind];
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={`${a.blobId}-${i}`}
|
key={`${a.blobId}-${i}`}
|
||||||
@@ -106,7 +99,7 @@ export const ListAttachmentChips = React.memo(function ListAttachmentChips({
|
|||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={a.name}
|
accessibilityLabel={a.name}
|
||||||
>
|
>
|
||||||
<Icon size={12} color={iconColor(kind, c)} />
|
<Icon size={12} color={attachmentIconColor(kind, c)} />
|
||||||
<Text style={[styles.name, dyn.caption]} numberOfLines={1}>{shortAttachmentName(a.name ?? '')}</Text>
|
<Text style={[styles.name, dyn.caption]} numberOfLines={1}>{shortAttachmentName(a.name ?? '')}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
@@ -234,6 +227,8 @@ function makeStyles(c: ThemePalette) {
|
|||||||
backgroundColor: c.surface,
|
backgroundColor: c.surface,
|
||||||
},
|
},
|
||||||
chipPressed: { backgroundColor: c.surfaceHover },
|
chipPressed: { backgroundColor: c.surfaceHover },
|
||||||
|
// Empty space of a chip's height; a failed load leaves no grey stub behind.
|
||||||
|
placeholderChip: { width: 96, opacity: 0 },
|
||||||
name: { ...typography.caption, color: c.textSecondary, flexShrink: 1 },
|
name: { ...typography.caption, color: c.textSecondary, flexShrink: 1 },
|
||||||
more: {
|
more: {
|
||||||
paddingHorizontal: 6,
|
paddingHorizontal: 6,
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export interface MessageContentProps {
|
|||||||
/** On screen rather than pre-rendered by the pager (gates auto-sent read receipts). */
|
/** On screen rather than pre-rendered by the pager (gates auto-sent read receipts). */
|
||||||
active: boolean;
|
active: boolean;
|
||||||
themeOverride?: 'light' | 'dark' | null;
|
themeOverride?: 'light' | 'dark' | null;
|
||||||
|
/** Per-message "Show as plain text"; null follows the "Plain Text Only" setting. */
|
||||||
|
plainView?: boolean | null;
|
||||||
|
/** Turn the per-message plain-text view off ("Show formatted"). */
|
||||||
|
onShowFormatted?: (email: Email) => void;
|
||||||
onSwipe?: (direction: 'prev' | 'next') => void;
|
onSwipe?: (direction: 'prev' | 'next') => void;
|
||||||
onZoomChange?: (zoom: { pinching: boolean; zoomed: boolean }) => void;
|
onZoomChange?: (zoom: { pinching: boolean; zoomed: boolean }) => void;
|
||||||
onToggleStar?: (email: Email) => void;
|
onToggleStar?: (email: Email) => void;
|
||||||
@@ -52,8 +56,8 @@ export interface MessageContentProps {
|
|||||||
* cards.
|
* cards.
|
||||||
*/
|
*/
|
||||||
export function MessageContent({
|
export function MessageContent({
|
||||||
email, jmapAccountId, identities, currentMailboxRole, active, themeOverride, onSwipe, onZoomChange,
|
email, jmapAccountId, identities, currentMailboxRole, active, themeOverride, plainView, onShowFormatted,
|
||||||
onToggleStar, onAddressPress, onEmailPatched, compact, deferBody, onBodySettled, fill,
|
onSwipe, onZoomChange, onToggleStar, onAddressPress, onEmailPatched, compact, deferBody, onBodySettled, fill,
|
||||||
}: MessageContentProps) {
|
}: MessageContentProps) {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
@@ -124,6 +128,8 @@ export function MessageContent({
|
|||||||
onSwipe={onSwipe}
|
onSwipe={onSwipe}
|
||||||
onZoomChange={onZoomChange}
|
onZoomChange={onZoomChange}
|
||||||
themeOverride={themeOverride}
|
themeOverride={themeOverride}
|
||||||
|
plainView={plainView}
|
||||||
|
onShowFormatted={onShowFormatted ? () => onShowFormatted(email) : undefined}
|
||||||
bodyOverride={unwrap.override}
|
bodyOverride={unwrap.override}
|
||||||
onSettled={onBodySettled}
|
onSettled={onBodySettled}
|
||||||
fill={fill}
|
fill={fill}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { useLocaleStore } from '../../stores/locale-store';
|
|||||||
import { useAccountKeywordDefs } from '../../stores/keywords-store';
|
import { useAccountKeywordDefs } from '../../stores/keywords-store';
|
||||||
import { emailDisplayDate, formatHeaderDate, formatHeaderTime, formatFullDateTime } from '../../lib/email-date';
|
import { emailDisplayDate, formatHeaderDate, formatHeaderTime, formatFullDateTime } from '../../lib/email-date';
|
||||||
import {
|
import {
|
||||||
deriveHeaderInfo, deliveryDeltaMs, formatDelta, isAuthenticationSpoofed, findReceivingIdentity,
|
deriveHeaderInfo, deliveryDeltaMs, formatDelta, isAuthenticationSpoofed, findReceivingIdentity, viaIdentityNameShown,
|
||||||
getSenderVerification, describeSenderVerification,
|
getSenderVerification, describeSenderVerification,
|
||||||
type AuthenticationResults, type EmailHeaderInfo,
|
type AuthenticationResults, type EmailHeaderInfo,
|
||||||
} from '../../lib/email-headers';
|
} from '../../lib/email-headers';
|
||||||
@@ -138,7 +138,7 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
|
|||||||
if (spoofed || identities.length === 0) return null;
|
if (spoofed || identities.length === 0) return null;
|
||||||
const fromEmail = from?.email?.toLowerCase();
|
const fromEmail = from?.email?.toLowerCase();
|
||||||
const sentAs = fromEmail ? identities.find((i) => i.email?.toLowerCase() === fromEmail) : undefined;
|
const sentAs = fromEmail ? identities.find((i) => i.email?.toLowerCase() === fromEmail) : undefined;
|
||||||
if (sentAs) return { identity: sentAs, direction: 'from' as const };
|
if (sentAs) return viaIdentityNameShown(sentAs, from) ? { identity: sentAs, direction: 'from' as const } : null;
|
||||||
const received = findReceivingIdentity(identities, email);
|
const received = findReceivingIdentity(identities, email);
|
||||||
if (received && identities.length > 1 && received.id !== identities[0].id) {
|
if (received && identities.length > 1 && received.id !== identities[0].id) {
|
||||||
return { identity: received, direction: 'to' as const };
|
return { identity: received, direction: 'to' as const };
|
||||||
@@ -172,7 +172,12 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
|
|||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={from?.name || from?.email || t('email_viewer.unknown_sender', 'Unknown')}
|
accessibilityLabel={from?.name || from?.email || t('email_viewer.unknown_sender', 'Unknown')}
|
||||||
>
|
>
|
||||||
<SenderAvatar name={from?.name} email={from?.email} size={compact ? componentSizes.avatarSm : componentSizes.avatarMd} />
|
<SenderAvatar
|
||||||
|
name={from?.name}
|
||||||
|
email={from?.email}
|
||||||
|
size={compact ? componentSizes.avatarSm : componentSizes.avatarMd}
|
||||||
|
senderWarning={verificationText?.label}
|
||||||
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<View style={styles.info}>
|
<View style={styles.info}>
|
||||||
<Pressable onPress={from ? () => onAddressPress(from) : undefined}>
|
<Pressable onPress={from ? () => onAddressPress(from) : undefined}>
|
||||||
@@ -253,7 +258,7 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
|
|||||||
<View style={[styles.badge, { backgroundColor: c.primaryBg }]}>
|
<View style={[styles.badge, { backgroundColor: c.primaryBg }]}>
|
||||||
<Text style={[styles.badgeText, { color: c.primary }]}>
|
<Text style={[styles.badgeText, { color: c.primary }]}>
|
||||||
{viaIdentity.direction === 'from'
|
{viaIdentity.direction === 'from'
|
||||||
? t('identities.badge.identity_short', 'via {name}', { name: viaIdentity.identity.email })
|
? t('identities.badge.identity_short', 'via {name}', { name: viaIdentity.identity.name })
|
||||||
: t('email_viewer.received_via', 'to {name}', { name: viaIdentity.identity.email })}
|
: t('email_viewer.received_via', 'to {name}', { name: viaIdentity.identity.email })}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import SenderAvatar from '../SenderAvatar';
|
|||||||
import { MessageContent, type MessageContentProps } from './MessageContent';
|
import { MessageContent, type MessageContentProps } from './MessageContent';
|
||||||
import { emailDisplayDate, formatHeaderDate, formatHeaderTime } from '../../lib/email-date';
|
import { emailDisplayDate, formatHeaderDate, formatHeaderTime } from '../../lib/email-date';
|
||||||
import { singleLine } from '../../lib/single-line';
|
import { singleLine } from '../../lib/single-line';
|
||||||
|
import { cleanPreview } from '../../lib/preview-text';
|
||||||
|
import { useSenderWarning } from './use-sender-warning';
|
||||||
|
|
||||||
interface Props extends MessageContentProps {
|
interface Props extends MessageContentProps {
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
@@ -43,11 +45,12 @@ export function ThreadMessageCard({
|
|||||||
const unread = !email.keywords?.$seen;
|
const unread = !email.keywords?.$seen;
|
||||||
const starred = !!email.keywords?.$flagged;
|
const starred = !!email.keywords?.$flagged;
|
||||||
const date = emailDisplayDate(email);
|
const date = emailDisplayDate(email);
|
||||||
|
const senderWarning = useSenderWarning(expanded ? undefined : email);
|
||||||
|
|
||||||
if (!expanded) {
|
if (!expanded) {
|
||||||
return (
|
return (
|
||||||
<Pressable onPress={onToggleExpanded} style={({ pressed }) => [styles.collapsed, pressed && styles.pressed]}>
|
<Pressable onPress={onToggleExpanded} style={({ pressed }) => [styles.collapsed, pressed && styles.pressed]}>
|
||||||
<SenderAvatar name={from?.name} email={from?.email} size={componentSizes.avatarSm} />
|
<SenderAvatar name={from?.name} email={from?.email} size={componentSizes.avatarSm} senderWarning={senderWarning} />
|
||||||
<View style={styles.collapsedInfo}>
|
<View style={styles.collapsedInfo}>
|
||||||
<View style={styles.collapsedTop}>
|
<View style={styles.collapsedTop}>
|
||||||
<Text style={[styles.collapsedName, unread && styles.unread]} numberOfLines={1}>
|
<Text style={[styles.collapsedName, unread && styles.unread]} numberOfLines={1}>
|
||||||
@@ -56,7 +59,7 @@ export function ThreadMessageCard({
|
|||||||
{email.hasAttachment && <Paperclip size={12} color={c.textMuted} />}
|
{email.hasAttachment && <Paperclip size={12} color={c.textMuted} />}
|
||||||
<Text style={styles.collapsedDate}>{formatHeaderDate(date, locale)} {formatHeaderTime(date, timeFormat, locale)}</Text>
|
<Text style={styles.collapsedDate}>{formatHeaderDate(date, locale)} {formatHeaderTime(date, timeFormat, locale)}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.collapsedPreview} numberOfLines={1}>{singleLine(email.preview)}</Text>
|
<Text style={styles.collapsedPreview} numberOfLines={1}>{singleLine(cleanPreview(email.preview))}</Text>
|
||||||
</View>
|
</View>
|
||||||
{starred && <Star size={14} color={c.starred} fill={c.starred} />}
|
{starred && <Star size={14} color={c.starred} fill={c.starred} />}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { File as FileIcon, FileArchive, FileImage, FileSpreadsheet, FileText } from '../icons';
|
||||||
|
import type { ThemePalette } from '../../theme/tokens';
|
||||||
|
import type { AttachmentKind } from '../../lib/list-attachments';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The icon of an attachment chip by file type, the same in the list and the
|
||||||
|
* reader (webmail components/email/attachment-chips.tsx, a78d03a18).
|
||||||
|
*/
|
||||||
|
export const ATTACHMENT_ICONS: Record<AttachmentKind, typeof FileIcon> = {
|
||||||
|
image: FileImage,
|
||||||
|
pdf: FileText,
|
||||||
|
sheet: FileSpreadsheet,
|
||||||
|
archive: FileArchive,
|
||||||
|
document: FileText,
|
||||||
|
other: FileIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The icon's colour by file type. */
|
||||||
|
export function attachmentIconColor(kind: AttachmentKind, c: ThemePalette): string {
|
||||||
|
switch (kind) {
|
||||||
|
case 'image': return c.tags.purple.dot;
|
||||||
|
case 'pdf': return c.tags.red.dot;
|
||||||
|
case 'sheet': return c.tags.green.dot;
|
||||||
|
case 'archive': return c.tags.amber.dot;
|
||||||
|
case 'document': return c.tags.blue.dot;
|
||||||
|
default: return c.textMuted;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { Email } from '../../api/types';
|
||||||
|
import { describeSenderVerification, senderWarningOf } from '../../lib/email-headers';
|
||||||
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sender-check label of a message whose From the receiving server's
|
||||||
|
* checks don't back, the same verdict as the notice in the reader. Avatars
|
||||||
|
* show it instead of the claimed sender's favicon or photo (webmail
|
||||||
|
* use-sender-warning.ts, #1439). Undefined when the check passed, there are
|
||||||
|
* no results to judge by, or no message is given (Sent and Drafts rows show
|
||||||
|
* the recipient, whose avatar the check says nothing about).
|
||||||
|
*/
|
||||||
|
export function useSenderWarning(email: Pick<Email, 'headers' | 'from'> | undefined): string | undefined {
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
const verification = React.useMemo(() => (email ? senderWarningOf(email) : null), [email]);
|
||||||
|
return verification ? describeSenderVerification(verification, t).label : undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { generateEmailAvatarColor, initialsColor } from '../avatar-utils';
|
||||||
|
import { parseColor, relativeLuminance } from '../event-colors';
|
||||||
|
|
||||||
|
// Initials were always white, which on the yellow, green and cyan hues of
|
||||||
|
// `hsl(h, 70%, 50%)` came out near 1.5:1 (webmail 2e13accad).
|
||||||
|
|
||||||
|
function contrast(a: { r: number; g: number; b: number; a: number }, b: { r: number; g: number; b: number; a: number }) {
|
||||||
|
const [hi, lo] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x);
|
||||||
|
return (hi + 0.05) / (lo + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hsl(h: number): { r: number; g: number; b: number; a: number } {
|
||||||
|
// Same formula as the avatar's: 70 % saturation, 50 % lightness.
|
||||||
|
const s = 0.7; const l = 0.5;
|
||||||
|
const k = (n: number) => (n + h / 30) % 12;
|
||||||
|
const f = (n: number) => l - s * Math.min(l, 1 - l) * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1));
|
||||||
|
return { r: Math.round(f(0) * 255), g: Math.round(f(8) * 255), b: Math.round(f(4) * 255), a: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('avatar initials colour', () => {
|
||||||
|
it('picks black on light hues and white on dark ones', () => {
|
||||||
|
expect(initialsColor('hsl(60, 70%, 50%)')).toBe('#000000');
|
||||||
|
expect(initialsColor('hsl(180, 70%, 50%)')).toBe('#000000');
|
||||||
|
expect(initialsColor('hsl(240, 70%, 50%)')).toBe('#ffffff');
|
||||||
|
expect(initialsColor('#1d4ed8')).toBe('#ffffff');
|
||||||
|
expect(initialsColor('not a colour')).toBe('#ffffff');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reaches 4.5:1 on every generated hue', () => {
|
||||||
|
for (let h = 0; h < 360; h++) {
|
||||||
|
const ink = parseColor(initialsColor(`hsl(${h}, 70%, 50%)`))!;
|
||||||
|
expect(contrast(hsl(h), ink), `hue ${h}`).toBeGreaterThanOrEqual(4.5);
|
||||||
|
}
|
||||||
|
expect(generateEmailAvatarColor('Ann')).toMatch(/^hsl\(\d+, 70%, 50%\)$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is what the sender avatar draws its initials in', () => {
|
||||||
|
const src = fs.readFileSync(path.resolve(__dirname, '../../components/SenderAvatar.tsx'), 'utf8');
|
||||||
|
expect(src).toMatch(/color: initialsColor\(bgColor\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { stripBidiControls } from '../bidi';
|
||||||
|
import { getAttachmentDisplayName } from '../attachment-display';
|
||||||
|
|
||||||
|
// A sender-chosen attachment name kept its direction-override characters,
|
||||||
|
// so `report<U+202E>fdp.exe` showed as `reportexe.pdf`: an executable
|
||||||
|
// dressed up as a PDF (webmail lib/bidi.ts).
|
||||||
|
|
||||||
|
const ch = (code: number) => String.fromCharCode(code);
|
||||||
|
const RLO = ch(0x202e);
|
||||||
|
|
||||||
|
describe('direction-override characters in attachment names', () => {
|
||||||
|
it('strips the embeddings, overrides, isolates and LRM/RLM marks', () => {
|
||||||
|
const controls = [0x200e, 0x200f, 0x202a, 0x202b, 0x202c, 0x202d, 0x202e, 0x2066, 0x2067, 0x2068, 0x2069].map(ch).join('');
|
||||||
|
expect(stripBidiControls(`a${controls}b`)).toBe('ab');
|
||||||
|
// Hebrew and Arabic letters stay.
|
||||||
|
expect(stripBidiControls('שלום.pdf')).toBe('שלום.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the real extension in the chip name', () => {
|
||||||
|
expect(getAttachmentDisplayName(`report${RLO}fdp.exe`, 'application/octet-stream')).toBe('reportfdp.exe');
|
||||||
|
// A name of nothing but controls falls back to the type label.
|
||||||
|
expect(getAttachmentDisplayName(RLO, 'application/pdf')).toBe('Document.pdf');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -246,6 +246,12 @@ describe('cachePreviewFile (in-app preview)', () => {
|
|||||||
expect(file.name).toBe('a_b_.txt');
|
expect(file.name).toBe('a_b_.txt');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('drops direction-override characters, so the real extension is the one the file gets shown with', async () => {
|
||||||
|
const rlo = String.fromCharCode(0x202e);
|
||||||
|
const file = await cachePreviewFile('blob-b', `report${rlo}fdp.exe`, 'application/octet-stream');
|
||||||
|
expect(file.name).toBe('reportfdp.exe');
|
||||||
|
});
|
||||||
|
|
||||||
it('removes its folder and rethrows when the download fails', async () => {
|
it('removes its folder and rethrows when the download fails', async () => {
|
||||||
vi.mocked(File.downloadFileAsync).mockRejectedValueOnce(new Error('Download failed: 404'));
|
vi.mocked(File.downloadFileAsync).mockRejectedValueOnce(new Error('Download failed: 404'));
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,16 @@ describe('getSenderVerification', () => {
|
|||||||
expect(getSenderVerification(undefined, 'a@bank.example')).toBeNull();
|
expect(getSenderVerification(undefined, 'a@bank.example')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('takes the signing domain from header.i when the receiver writes no header.d (bulwarkmail/webmail#1420)', () => {
|
||||||
|
const from = 'noreply@example.org';
|
||||||
|
expect(getSenderVerification(parseAuthenticationResults('mx.example.net; dkim=pass header.i=@example.org'), from)).toBeNull();
|
||||||
|
expect(getSenderVerification(parseAuthenticationResults('mx; dkim=pass header.i=bounce@mail.example.org'), from)).toBeNull();
|
||||||
|
expect(getSenderVerification(parseAuthenticationResults('mx; dkim=pass header.i=@other.example'), from))
|
||||||
|
.toEqual({ status: 'unverified', domain: 'example.org' });
|
||||||
|
// header.d still wins when both are there.
|
||||||
|
expect(parseAuthenticationResults('mx; dkim=pass header.d=a.example header.i=@b.example').dkim?.domain).toBe('a.example');
|
||||||
|
});
|
||||||
|
|
||||||
it('does not count a HELO pass', () => {
|
it('does not count a HELO pass', () => {
|
||||||
const auth = parseAuthenticationResults('mx; spf=pass smtp.helo=bank.example; spf=none smtp.mailfrom=bank.example');
|
const auth = parseAuthenticationResults('mx; spf=pass smtp.helo=bank.example; spf=none smtp.mailfrom=bank.example');
|
||||||
expect(getSenderVerification(auth, 'a@bank.example')?.status).toBe('unverified');
|
expect(getSenderVerification(auth, 'a@bank.example')?.status).toBe('unverified');
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { htmlToReadableText, plainTextReading } from '../html-to-readable-text';
|
||||||
|
|
||||||
|
// "Plain Text Only" promised plain-text reading, but the reader showed the
|
||||||
|
// HTML anyway. These are the webmail's cases for its converter
|
||||||
|
// (lib/__tests__/html-to-readable-text.test.ts) plus the HTML a DOM parser
|
||||||
|
// fixes up on its own.
|
||||||
|
|
||||||
|
describe('htmlToReadableText', () => {
|
||||||
|
it('keeps paragraphs and line breaks', () => {
|
||||||
|
expect(htmlToReadableText('<p>Hello <b>Ann</b>,</p><p>first line<br>second line</p>'))
|
||||||
|
.toBe('Hello Ann,\n\nfirst line\nsecond line');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps link targets and image alt text', () => {
|
||||||
|
expect(htmlToReadableText('<p>Read <a href="https://ex.com/a">the report</a> <img src="x.png" alt="Logo"></p>'))
|
||||||
|
.toBe('Read the report (https://ex.com/a) [Logo]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not repeat a target that is the link text, and drops script and style', () => {
|
||||||
|
expect(htmlToReadableText('<style>p{color:red}</style><a href="https://ex.com/">ex.com</a><script>x()</script>'))
|
||||||
|
.toBe('ex.com');
|
||||||
|
expect(htmlToReadableText('<a href="mailto:ann@ex.com?subject=Hi">Write to Ann</a>'))
|
||||||
|
.toBe('Write to Ann (ann@ex.com)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns layout tables into lines and lists into bullets', () => {
|
||||||
|
const html = '<table><tr><td>Name</td><td>Ann</td></tr><tr><td>City</td><td>Berlin</td></tr></table><ul><li>one</li><li>two</li></ul>';
|
||||||
|
expect(htmlToReadableText(html)).toBe('Name Ann\nCity Berlin\n\n• one\n• two');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips hidden preheaders and images without alt text', () => {
|
||||||
|
expect(htmlToReadableText('<div style="display:none">preheader</div><img src="t.gif"><p>Body</p>')).toBe('Body');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a whole document: head, entities, comments and unclosed tags', () => {
|
||||||
|
const html = '<!DOCTYPE html><html><head><title>T</title><style>a{}</style></head><body>'
|
||||||
|
+ '<!-- tracking --><p>Fish & chips €5<p>Next <a href="https://ex.com/?a=1&b=2">deal</a>'
|
||||||
|
+ '<ul><li>one<li>two</ul><script>if (a < b) document.write("<p>x</p>")</script></body></html>';
|
||||||
|
expect(htmlToReadableText(html)).toBe('Fish & chips €5\n\nNext deal (https://ex.com/?a=1&b=2)\n\n• one\n• two');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps preformatted text and marks quotes', () => {
|
||||||
|
expect(htmlToReadableText('<pre>a b\n c</pre>')).toBe('a b\n c');
|
||||||
|
expect(htmlToReadableText('<p>On Monday Bob wrote:</p><blockquote>hi</blockquote>')).toBe('On Monday Bob wrote:\n\n> hi');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('plainTextReading', () => {
|
||||||
|
it("prefers the message's own text part, else converts the HTML, and leaves plain-text mail alone", () => {
|
||||||
|
expect(plainTextReading({ html: '<p>Hi <b>there</b></p>', text: 'Hi there (text part)' })).toBe('Hi there (text part)');
|
||||||
|
expect(plainTextReading({ html: '<p>Hi <b>there</b></p>', text: null })).toBe('Hi there');
|
||||||
|
expect(plainTextReading({ html: '<p>Hi</p>', text: ' ' })).toBe('Hi');
|
||||||
|
expect(plainTextReading({ html: null, text: 'just text' })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { showsPaperclip } from '../list-attachments';
|
||||||
|
import type { Attachment } from '../../api/types';
|
||||||
|
|
||||||
|
// A message with nothing but inline images (a signature logo) kept its
|
||||||
|
// paperclip in the list, and the row grew when its chips arrived (webmail
|
||||||
|
// attachment-chips.tsx, 7b25d3a95).
|
||||||
|
|
||||||
|
const inline = { blobId: 'b1', type: 'image/png', name: 'logo.png', disposition: 'inline', cid: 'logo' } as Attachment;
|
||||||
|
const file = { blobId: 'b2', type: 'application/pdf', name: 'invoice.pdf' } as Attachment;
|
||||||
|
|
||||||
|
describe('list row paperclip and chips', () => {
|
||||||
|
it("follows the server's flag until the parts are in, then only real attachments", () => {
|
||||||
|
expect(showsPaperclip(true, undefined)).toBe(true);
|
||||||
|
expect(showsPaperclip(true, [inline])).toBe(false);
|
||||||
|
expect(showsPaperclip(true, [inline, file])).toBe(true);
|
||||||
|
expect(showsPaperclip(false, undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is wired into the row, which holds the chips line while they load', () => {
|
||||||
|
const list = fs.readFileSync(path.resolve(__dirname, '../../screens/EmailListScreen.tsx'), 'utf8');
|
||||||
|
expect(list).toMatch(/showsPaperclip\(item\.hasAttachment, attachments\)/);
|
||||||
|
expect(list).toMatch(/pending=\{!!loadAttachments && item\.hasAttachment && !item\.attachments\}/);
|
||||||
|
const chips = fs.readFileSync(path.resolve(__dirname, '../../components/email/ListAttachmentChips.tsx'), 'utf8');
|
||||||
|
expect(chips).toMatch(/if \(pending && attachments === undefined\) \{/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { isReportPart } from '../attachment-display';
|
||||||
|
import { realAttachments } from '../list-attachments';
|
||||||
|
import type { Attachment } from '../../api/types';
|
||||||
|
|
||||||
|
// A bounce's delivery status and a read receipt's disposition showed as
|
||||||
|
// attachment chips in the list (the reader already hid them), and the RFC
|
||||||
|
// 6533 `global-` forms counted as real files everywhere (webmail 8a09ba573).
|
||||||
|
|
||||||
|
const part = (type: string, name: string): Attachment => ({ blobId: `b-${name}`, type, name, size: 1 } as Attachment);
|
||||||
|
|
||||||
|
describe('report parts in list chips', () => {
|
||||||
|
it('counts the global- forms as report parts', () => {
|
||||||
|
expect(isReportPart('message/global-delivery-status')).toBe(true);
|
||||||
|
expect(isReportPart('message/global-disposition-notification')).toBe(true);
|
||||||
|
expect(isReportPart('Message/Delivery-Status; charset=utf-8')).toBe(true);
|
||||||
|
expect(isReportPart('message/rfc822')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves report parts out of the list chips', () => {
|
||||||
|
const parts = [
|
||||||
|
part('message/delivery-status', 'details.txt'),
|
||||||
|
part('message/global-disposition-notification', 'receipt'),
|
||||||
|
part('application/pdf', 'invoice.pdf'),
|
||||||
|
];
|
||||||
|
expect(realAttachments(parts).map((a) => a.name)).toEqual(['invoice.pdf']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { listViewKey } from '../list-view-key';
|
||||||
|
|
||||||
|
// The list kept its scroll offset when the folder, tag or search changed, so
|
||||||
|
// the new list opened somewhere in the middle (webmail #1138).
|
||||||
|
|
||||||
|
describe('list scroll position on a view switch (bulwarkmail/webmail#1138)', () => {
|
||||||
|
it('changes with the folder, the search and the tag, and only with them', () => {
|
||||||
|
const base = listViewKey('inbox', '', {});
|
||||||
|
expect(listViewKey('inbox', '', {})).toBe(base);
|
||||||
|
expect(listViewKey('inbox', ' ', {})).toBe(base);
|
||||||
|
expect(listViewKey('archive', '', {})).not.toBe(base);
|
||||||
|
expect(listViewKey('inbox', 'invoice', {})).not.toBe(base);
|
||||||
|
expect(listViewKey('inbox', '', { keyword: '$label:red' })).not.toBe(base);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scrolls the list to the top when it changes', () => {
|
||||||
|
const list = fs.readFileSync(path.resolve(__dirname, '../../screens/EmailListScreen.tsx'), 'utf8');
|
||||||
|
expect(list).toMatch(/ref=\{listRef\}/);
|
||||||
|
expect(list).toMatch(/listRef\.current\?\.scrollToOffset\(\{ offset: 0, animated: false \}\);\s*\}, \[viewKey\]\);/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { buildPauseAnimatedImagesScript, IS_ANIMATED_SOURCE, MAIL_REDUCED_MOTION_CSS, withReducedMotion } from '../mail-motion';
|
||||||
|
|
||||||
|
// The app follows the system's reduce motion, but mail kept its CSS
|
||||||
|
// animations and moving GIFs (webmail 24a13cc1f, bb7869798).
|
||||||
|
|
||||||
|
const isAnimated = new Function(`${IS_ANIMATED_SOURCE}; return isAnimatedImageData;`)() as (b: Uint8Array) => boolean;
|
||||||
|
|
||||||
|
function gif(frames: number): Uint8Array {
|
||||||
|
const head = [...'GIF89a'].map((ch) => ch.charCodeAt(0));
|
||||||
|
const screen = [1, 0, 1, 0, 0, 0, 0]; // 1x1, no global colour table
|
||||||
|
const frame = [0x2c, 0, 0, 0, 0, 1, 0, 1, 0, 0, 2, 1, 0, 0]; // descriptor, LZW size, one sub-block, end
|
||||||
|
const extension = [0x21, 0xf9, 4, 0, 0, 0, 0, 0]; // graphic control extension
|
||||||
|
const body: number[] = [];
|
||||||
|
for (let i = 0; i < frames; i++) body.push(...extension, ...frame);
|
||||||
|
return new Uint8Array([...head, ...screen, ...body, 0x3b]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function png(chunks: string[]): Uint8Array {
|
||||||
|
const out = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||||
|
for (const type of chunks) out.push(0, 0, 0, 0, ...[...type].map((ch) => ch.charCodeAt(0)), 0, 0, 0, 0);
|
||||||
|
return new Uint8Array(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64 = (bytes: Uint8Array) => Buffer.from(bytes).toString('base64');
|
||||||
|
|
||||||
|
describe('reduce motion in mail', () => {
|
||||||
|
it('tells animated GIF, APNG and WebP from still images', () => {
|
||||||
|
expect(isAnimated(gif(2))).toBe(true);
|
||||||
|
expect(isAnimated(gif(1))).toBe(false);
|
||||||
|
expect(isAnimated(png(['IHDR', 'acTL', 'IDAT']))).toBe(true);
|
||||||
|
expect(isAnimated(png(['IHDR', 'IDAT']))).toBe(false);
|
||||||
|
const webp = (flags: number) => new Uint8Array([...'RIFF\0\0\0\0WEBPVP8X'].map((ch) => ch.charCodeAt(0)).concat([0, 0, 0, 0, flags, 0, 0, 0]));
|
||||||
|
expect(isAnimated(webp(0x02))).toBe(true);
|
||||||
|
expect(isAnimated(webp(0x00))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops CSS animations in the body', () => {
|
||||||
|
const html = withReducedMotion('<html><body><p>hi</p></body></html>');
|
||||||
|
expect(html).toContain(`<style data-bulwark-reduced-motion>${MAIL_REDUCED_MOTION_CSS}</style></body>`);
|
||||||
|
expect(MAIL_REDUCED_MOTION_CSS).toContain('animation-duration:0.01ms!important');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swaps an animated image for a still frame with a Play button, and plays it on tap', () => {
|
||||||
|
const made: FakeEl[] = [];
|
||||||
|
let clickHandler: ((e: unknown) => void) | null = null;
|
||||||
|
class FakeEl {
|
||||||
|
attrs = new Map<string, string>();
|
||||||
|
children: FakeEl[] = [];
|
||||||
|
parentNode: FakeEl | null = null;
|
||||||
|
textContent = '';
|
||||||
|
type = '';
|
||||||
|
width = 0;
|
||||||
|
height = 0;
|
||||||
|
naturalWidth = 10;
|
||||||
|
complete = true;
|
||||||
|
style = { props: new Map<string, string>(), width: '', setProperty(k: string, v: string) { this.props.set(k, v); }, removeProperty(k: string) { this.props.delete(k); } };
|
||||||
|
constructor(public tagName: string) { made.push(this); }
|
||||||
|
get parentElement() { return this.parentNode; }
|
||||||
|
setAttribute(k: string, v: string) { this.attrs.set(k, v); }
|
||||||
|
getAttribute(k: string) { return this.attrs.get(k) ?? null; }
|
||||||
|
hasAttribute(k: string) { return this.attrs.has(k); }
|
||||||
|
appendChild(c: FakeEl) { c.parentNode?.children.splice(c.parentNode.children.indexOf(c), 1); c.parentNode = this; this.children.push(c); }
|
||||||
|
insertBefore(c: FakeEl, ref: FakeEl) { c.parentNode = this; this.children.splice(this.children.indexOf(ref), 0, c); }
|
||||||
|
getBoundingClientRect() { return { width: 10 }; }
|
||||||
|
getContext() { return { drawImage: () => undefined }; }
|
||||||
|
addEventListener() { /* loaded already */ }
|
||||||
|
querySelector(sel: string) { return this.children.find((c) => (sel.startsWith('canvas') ? c.tagName === 'CANVAS' : c.tagName === 'IMG')) ?? null; }
|
||||||
|
closest(sel: string) { return sel.startsWith('button') && this.tagName === 'BUTTON' ? this : null; }
|
||||||
|
}
|
||||||
|
const body = new FakeEl('BODY');
|
||||||
|
const moving = new FakeEl('IMG');
|
||||||
|
moving.setAttribute('src', `data:image/gif;base64,${base64(gif(3))}`);
|
||||||
|
const still = new FakeEl('IMG');
|
||||||
|
still.setAttribute('src', `data:image/gif;base64,${base64(gif(1))}`);
|
||||||
|
body.appendChild(moving);
|
||||||
|
body.appendChild(still);
|
||||||
|
const document = {
|
||||||
|
body,
|
||||||
|
head: new FakeEl('HEAD'),
|
||||||
|
createElement: (tag: string) => new FakeEl(tag.toUpperCase()),
|
||||||
|
querySelectorAll: () => [moving, still],
|
||||||
|
addEventListener: (_: string, fn: (e: unknown) => void) => { clickHandler = fn; },
|
||||||
|
};
|
||||||
|
const atob = (s: string) => Buffer.from(s, 'base64').toString('binary');
|
||||||
|
new Function('document', 'atob', 'URL', buildPauseAnimatedImagesScript({ play: 'Play animation', pause: 'Pause animation' }))(document, atob, URL);
|
||||||
|
|
||||||
|
const wrap = body.children[0];
|
||||||
|
expect(wrap.tagName).toBe('SPAN');
|
||||||
|
const [button, canvas, img] = wrap.children;
|
||||||
|
expect(button.textContent).toBe('Play animation');
|
||||||
|
expect(canvas.tagName).toBe('CANVAS');
|
||||||
|
expect(img).toBe(moving);
|
||||||
|
expect(moving.style.props.get('display')).toBe('none');
|
||||||
|
// The single-frame GIF is left alone.
|
||||||
|
expect(body.children[1]).toBe(still);
|
||||||
|
|
||||||
|
const event = { target: button, preventDefault: () => undefined, stopImmediatePropagation: () => undefined };
|
||||||
|
clickHandler!(event);
|
||||||
|
expect(button.textContent).toBe('Pause animation');
|
||||||
|
expect(moving.style.props.has('display')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is applied by the body view while motion is reduced', () => {
|
||||||
|
const view = fs.readFileSync(path.resolve(__dirname, '../../components/EmailBodyView.tsx'), 'utf8');
|
||||||
|
expect(view).toMatch(/const reduceMotion = !useShouldAnimate\(\);/);
|
||||||
|
expect(view).toMatch(/reduceMotion && prepared\.isHtml \? withReducedMotion\(prepared\.html\)/);
|
||||||
|
expect(view).toMatch(/prepared\.isHtml && reduceMotion \? buildPauseAnimatedImagesScript\(pauseLabels\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { buildMailTextSizeScript, mailBaseFontPx, mailTextFactor } from '../mail-reading';
|
||||||
|
import { buildBodyDocument } from '../email-body-document';
|
||||||
|
|
||||||
|
// The mail body was fixed at 14 px whatever the app's text size or the
|
||||||
|
// system font size said (webmail c07ba1a37 scales it).
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
key: 'k', emptyLabel: '-', blockRemoteImages: false, cidMap: {}, isDark: false,
|
||||||
|
messageSpacing: 'auto' as const, plainTextFont: 'sans' as const, quoteLabels: { show: 's', hide: 'h' },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('mail text size', () => {
|
||||||
|
it('multiplies the app text size by the system font scale, within bounds', () => {
|
||||||
|
expect(mailTextFactor(1, 1)).toBe(1);
|
||||||
|
expect(mailTextFactor(1.14, 1.3)).toBe(1.48);
|
||||||
|
expect(mailTextFactor(1, 0)).toBe(1);
|
||||||
|
expect(mailTextFactor(2, 3)).toBe(3);
|
||||||
|
expect(mailBaseFontPx(1.5)).toBe(21);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sizes a plain-text body by the factor', () => {
|
||||||
|
expect(buildBodyDocument({ ...base, rawHtml: null, text: 'hello' }).html).toMatch(/font-size: 14px;/);
|
||||||
|
expect(buildBodyDocument({ ...base, rawHtml: null, text: 'hello', textFactor: 1.5 }).html).toMatch(/font-size: 21px;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scales every element of an HTML body once, line heights in px included', () => {
|
||||||
|
expect(buildMailTextSizeScript(1)).toBe('');
|
||||||
|
const writes: Array<[string, string, string]> = [];
|
||||||
|
const attrs = new Set<string>();
|
||||||
|
const el = (tag: string, fontSize: string, lineHeight: string, inSvg = false) => ({
|
||||||
|
tagName: tag,
|
||||||
|
computed: { fontSize, lineHeight },
|
||||||
|
closest: (sel: string) => (sel === 'svg' && inSvg ? {} : null),
|
||||||
|
style: { setProperty: (prop: string, value: string) => writes.push([tag, prop, value]) },
|
||||||
|
});
|
||||||
|
const kids = [el('P', '16px', '24px'), el('SPAN', '12px', 'normal'), el('IMG', '14px', 'normal'), el('TEXT', '10px', 'normal', true)];
|
||||||
|
const body = {
|
||||||
|
...el('BODY', '14px', '22.4px'),
|
||||||
|
hasAttribute: (a: string) => attrs.has(a),
|
||||||
|
setAttribute: (a: string) => attrs.add(a),
|
||||||
|
querySelectorAll: () => kids,
|
||||||
|
};
|
||||||
|
const window = { getComputedStyle: (e: { computed: unknown }) => e.computed };
|
||||||
|
const run = () => new Function('document', 'window', buildMailTextSizeScript(1.5))({ body }, window);
|
||||||
|
run();
|
||||||
|
expect(writes).toEqual([
|
||||||
|
['BODY', 'font-size', '21px'], ['BODY', 'line-height', '33.6px'],
|
||||||
|
['P', 'font-size', '24px'], ['P', 'line-height', '36px'],
|
||||||
|
['SPAN', 'font-size', '18px'],
|
||||||
|
]);
|
||||||
|
// The body view injects its scripts again after load: no second pass.
|
||||||
|
run();
|
||||||
|
expect(writes).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is wired into the body view, with Android text zoom held at 100', () => {
|
||||||
|
const view = fs.readFileSync(path.resolve(__dirname, '../../components/EmailBodyView.tsx'), 'utf8');
|
||||||
|
expect(view).toMatch(/mailTextFactor\(dyn\.body\.fontSize \/ typography\.body\.fontSize, PixelRatio\.getFontScale\(\)\)/);
|
||||||
|
expect(view).toMatch(/buildMailTextSizeScript\(textFactor\)/);
|
||||||
|
expect(view).toMatch(/textZoom=\{100\}/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { useSettingsStore } from '../../stores/settings-store';
|
||||||
|
import { confirmMailRemoval, type AskFn } from '../mail-removal-confirm';
|
||||||
|
|
||||||
|
// "Confirm before deleting" and "Confirm before archiving" existed as
|
||||||
|
// settings but nothing asked: delete and archive went ahead from the open
|
||||||
|
// message, the selection bar and swipes (webmail lib/mail-removal-confirm.ts).
|
||||||
|
|
||||||
|
const t = (key: string, fallback?: string, params?: Record<string, string | number>) =>
|
||||||
|
`${key}${params?.count !== undefined ? `:${params.count}` : ''}${fallback ? '' : ''}`;
|
||||||
|
|
||||||
|
describe('confirmMailRemoval', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await AsyncStorage.clear();
|
||||||
|
useSettingsStore.getState().resetToDefaults();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('goes ahead without asking while the settings are off', async () => {
|
||||||
|
const ask = vi.fn<AskFn>(async () => false);
|
||||||
|
expect(await confirmMailRemoval('delete', 2, t, ask)).toBe(true);
|
||||||
|
expect(await confirmMailRemoval('archive', 2, t, ask)).toBe(true);
|
||||||
|
expect(ask).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks before a delete when "Confirm before deleting" is on, and stops on no', async () => {
|
||||||
|
useSettingsStore.setState({ confirmDelete: true });
|
||||||
|
const no = vi.fn<AskFn>(async () => false);
|
||||||
|
expect(await confirmMailRemoval('delete', 3, t, no)).toBe(false);
|
||||||
|
expect(no).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
title: 'email_list.confirm_remove.delete_title:3', destructive: true,
|
||||||
|
}));
|
||||||
|
expect(await confirmMailRemoval('delete', 3, t, async () => true)).toBe(true);
|
||||||
|
// Archiving has its own switch.
|
||||||
|
expect(await confirmMailRemoval('archive', 3, t, no)).toBe(true);
|
||||||
|
expect(no).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks before an archive when "Confirm before archiving" is on', async () => {
|
||||||
|
useSettingsStore.setState({ confirmArchive: true });
|
||||||
|
const ask = vi.fn<AskFn>(async () => false);
|
||||||
|
expect(await confirmMailRemoval('archive', 1, t, ask)).toBe(false);
|
||||||
|
expect(ask).toHaveBeenCalledWith(expect.objectContaining({ title: 'email_list.confirm_remove.archive_title:1', destructive: false }));
|
||||||
|
expect(await confirmMailRemoval('archive', 0, t, ask)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is asked from the open message, the selection bar and swipes, but not on top of the permanent delete question', () => {
|
||||||
|
const src = (p: string) => fs.readFileSync(path.resolve(__dirname, '../..', p), 'utf8');
|
||||||
|
const thread = src('screens/EmailThreadScreen.tsx');
|
||||||
|
expect(thread).toMatch(/confirmMailRemoval\('delete', 1, t\)/);
|
||||||
|
expect(thread).toMatch(/confirmMailRemoval\('archive', 1, t\)/);
|
||||||
|
const list = src('screens/EmailListScreen.tsx');
|
||||||
|
// deleteIds (swipe) and the selection bar's delete, each in the
|
||||||
|
// non-permanent branch only.
|
||||||
|
expect(list.match(/\} else if \(!\(await confirmMailRemoval\('delete', ids\.length, t\)\)\) \{/g)).toHaveLength(2);
|
||||||
|
expect(list).toMatch(/confirmMailRemoval\('archive', ids\.length, t\)/);
|
||||||
|
expect(list).toMatch(/confirmMailRemoval\('archive', selectedMessageIds\.length, t\)/);
|
||||||
|
const unified = src('screens/UnifiedInboxScreen.tsx');
|
||||||
|
expect(unified).toMatch(/confirmMailRemoval\('archive', targets\.length, t\)/);
|
||||||
|
expect(unified).toMatch(/confirmMailRemoval\('delete', targets\.length, t\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,4 +25,11 @@ describe('mailboxIconKind', () => {
|
|||||||
expect(source).toContain('mailboxIconKind(');
|
expect(source).toContain('mailboxIconKind(');
|
||||||
expect(source).not.toMatch(/lower\.includes\('(?:sent|inbox|draft|trash|junk|archive|star)'\)/);
|
expect(source).not.toMatch(/lower\.includes\('(?:sent|inbox|draft|trash|junk|archive|star)'\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The move sheet still guessed from the name (#1235 fixed only the sidebar).
|
||||||
|
it('is what the move sheet uses too, with no guess from the folder name', () => {
|
||||||
|
const source = readFileSync(path.join(process.cwd(), 'src/components/MoveSheet.tsx'), 'utf8');
|
||||||
|
expect(source).toContain('mailboxIconKind(');
|
||||||
|
expect(source).not.toMatch(/\.includes\('(?:sent|inbox|draft|trash|deleted|junk|spam|archive)'\)/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { moveSheetCurrentId } from '../move-targets';
|
||||||
|
import type { Mailbox } from '../../api/types';
|
||||||
|
|
||||||
|
// A search across folders keeps the open folder selected, and the move
|
||||||
|
// sheet greyed it out, so a hit from another folder couldn't be moved into
|
||||||
|
// it (webmail dafdda534, #1452).
|
||||||
|
|
||||||
|
const box = (id: string, extra: Partial<Mailbox> = {}): Mailbox => ({ id, name: id, totalEmails: 0, unreadEmails: 0, ...extra } as Mailbox);
|
||||||
|
const mailboxes = [box('inbox'), box('work'), box('g:raw', { originalId: 'raw', accountId: 'g' })];
|
||||||
|
|
||||||
|
describe('move sheet current folder (bulwarkmail/webmail#1452)', () => {
|
||||||
|
it('keeps the open folder out only when every row is already in it', () => {
|
||||||
|
expect(moveSheetCurrentId(mailboxes, 'inbox', [{ mailboxIds: { inbox: true } }])).toBe('inbox');
|
||||||
|
expect(moveSheetCurrentId(mailboxes, 'inbox', [{ mailboxIds: { work: true }, jmapAccountId: 'me' }])).toBeNull();
|
||||||
|
expect(moveSheetCurrentId(mailboxes, 'inbox', [{ mailboxIds: { inbox: true } }, { mailboxIds: { work: true } }])).toBeNull();
|
||||||
|
expect(moveSheetCurrentId(mailboxes, null, [{ mailboxIds: { work: true } }])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches a shared folder by its server id and account", () => {
|
||||||
|
expect(moveSheetCurrentId(mailboxes, 'g:raw', [{ mailboxIds: { raw: true }, jmapAccountId: 'g' }])).toBe('g:raw');
|
||||||
|
expect(moveSheetCurrentId(mailboxes, 'g:raw', [{ mailboxIds: { raw: true }, jmapAccountId: 'other' }])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is what both move sheets of the list use', () => {
|
||||||
|
const list = fs.readFileSync(path.resolve(__dirname, '../../screens/EmailListScreen.tsx'), 'utf8');
|
||||||
|
expect(list).toMatch(/currentMailboxId=\{pendingMoveCurrentId\}/);
|
||||||
|
expect(list).toMatch(/toId !== pendingMoveCurrentId/);
|
||||||
|
expect(list).toMatch(/currentMailboxId=\{moveSheetCurrentId\(mailboxes, currentMailboxId, selectedEmails\)\}/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { checkNewTag, composeKeywordId, MAX_KEYWORD_ID_LENGTH, newTagDefinition } from '../new-tag';
|
||||||
|
import { DEFAULT_KEYWORDS } from '../../stores/keywords-store';
|
||||||
|
|
||||||
|
// The tag pickers listed existing tags only; a new one meant a trip to
|
||||||
|
// Settings (webmail tag-picker.tsx "New tag", 19676a14f).
|
||||||
|
|
||||||
|
describe('new tag from the tag picker', () => {
|
||||||
|
it('makes the id the way the webmail does', () => {
|
||||||
|
expect(composeKeywordId(null, ' Client Acme / 2026! ')).toBe('client-acme-2026');
|
||||||
|
expect(composeKeywordId('work', 'Clients')).toBe('work/clients');
|
||||||
|
expect(composeKeywordId(null, '!!!')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an existing id and a name with no id or too long a one', () => {
|
||||||
|
expect(checkNewTag('Red', DEFAULT_KEYWORDS).error).toBe('tag_exists');
|
||||||
|
expect(checkNewTag('???', DEFAULT_KEYWORDS).error).toBe('tag_name_invalid');
|
||||||
|
expect(checkNewTag('x'.repeat(MAX_KEYWORD_ID_LENGTH + 1), DEFAULT_KEYWORDS).error).toBe('tag_name_invalid');
|
||||||
|
expect(checkNewTag('', DEFAULT_KEYWORDS).error).toBeNull();
|
||||||
|
expect(newTagDefinition('Red', DEFAULT_KEYWORDS)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the new tag the next colour of the palette row', () => {
|
||||||
|
// Seven default tags: index 7 of red, orange, amber, yellow, lime, green, teal, cyan…
|
||||||
|
expect(newTagDefinition(' Invoices ', DEFAULT_KEYWORDS)).toEqual({ id: 'invoices', label: 'Invoices', color: 'cyan' });
|
||||||
|
expect(newTagDefinition('First', [])?.color).toBe('red');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is offered in the list tag sheet and the message tag sheet, and applies the tag', () => {
|
||||||
|
const src = (p: string) => fs.readFileSync(path.resolve(__dirname, '../..', p), 'utf8');
|
||||||
|
expect(src('components/TagSheet.tsx')).toMatch(/<NewTagRow keywords=\{keywords\} onCreated=\{\(token\) => onToggle\(token, true\)\} \/>/);
|
||||||
|
expect(src('screens/EmailThreadScreen.tsx')).toMatch(/<NewTagRow keywords=\{keywords\} onCreated=\{onToggle\} \/>/);
|
||||||
|
const row = src('components/NewTagRow.tsx');
|
||||||
|
expect(row).toMatch(/addKeyword\(def\);/);
|
||||||
|
expect(row).toMatch(/onCreated\(keywordToken\(def\.id\)\);/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { buildBodyDocument } from '../email-body-document';
|
||||||
|
import { BUILTIN_THEMES } from '../../theme/builtin-themes';
|
||||||
|
|
||||||
|
// A plain-text mail read in dark mode sat on a fixed #0a0a0a whatever the
|
||||||
|
// app's theme was (UX audit 2026-10, "Plain-text mail dark background").
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
key: 'k', emptyLabel: '-', blockRemoteImages: false, cidMap: {}, rawHtml: null, text: 'hello',
|
||||||
|
messageSpacing: 'auto' as const, plainTextFont: 'sans' as const, quoteLabels: { show: 's', hide: 'h' },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('plain-text mail colours', () => {
|
||||||
|
it("takes the dark theme's ground, text and link colours", () => {
|
||||||
|
const nord = { background: '#2e3440', text: '#eceff4', link: '#88c0d0' };
|
||||||
|
const html = buildBodyDocument({ ...base, isDark: true, plainDarkColors: nord }).html;
|
||||||
|
expect(html).toContain('background: #2e3440;');
|
||||||
|
expect(html).toContain('color: #eceff4;');
|
||||||
|
expect(html).toContain('a { color: #88c0d0;');
|
||||||
|
expect(html).not.toContain('#0a0a0a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps white in light mode and the old dark colours without a theme', () => {
|
||||||
|
const colors = { background: '#2e3440', text: '#eceff4', link: '#88c0d0' };
|
||||||
|
expect(buildBodyDocument({ ...base, isDark: false, plainDarkColors: colors }).html).toContain('background: #ffffff;');
|
||||||
|
expect(buildBodyDocument({ ...base, isDark: true }).html).toContain('background: #0a0a0a;');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cannot close the style sheet with a theme colour', () => {
|
||||||
|
const html = buildBodyDocument({
|
||||||
|
...base, isDark: true, plainDarkColors: { background: 'red;}</style><script>x()</script>', text: '#fff', link: '#00f' },
|
||||||
|
}).html;
|
||||||
|
expect(html).not.toContain('</style><script>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is fed from the app theme by the body view', () => {
|
||||||
|
expect(BUILTIN_THEMES.length).toBeGreaterThan(0);
|
||||||
|
const view = fs.readFileSync(path.resolve(__dirname, '../../components/EmailBodyView.tsx'), 'utf8');
|
||||||
|
expect(view).toMatch(/background: c\.background, text: c\.text, link: c\.textLink/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { cleanPreview, stripEmphasisMarkers, stripLeadingCss } from '../preview-text';
|
||||||
|
|
||||||
|
// Copied from the webmail (lib/__tests__/preview-leading-css.test.ts), plus
|
||||||
|
// the check that the list rows use it (#1136).
|
||||||
|
|
||||||
|
// Some servers build a message preview from its HTML without skipping the
|
||||||
|
// <style> element, so a marketing mail's preview starts with its style sheet.
|
||||||
|
describe('stripLeadingCss', () => {
|
||||||
|
it('drops a leading media query and keeps the text after it', () => {
|
||||||
|
const preview = '@media screen and (min-width:600px){.hide{display:none!important;overflow:hidden!important}.show{display:block!important}} Your parcel is on its way';
|
||||||
|
expect(stripLeadingCss(preview)).toBe('Your parcel is on its way');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops several leading rules in a row', () => {
|
||||||
|
expect(stripLeadingCss('.a{color:red} #b p{margin:0} td, th {padding:0}Hello')).toBe('Hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a leading @import that ends without a block', () => {
|
||||||
|
expect(stripLeadingCss("@import url('x.css'); Weekly digest")).toBe('Weekly digest');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empties a preview that ends inside the style sheet', () => {
|
||||||
|
expect(stripLeadingCss('@media screen and (min-width:600px){.hide{display:none!important;max-height:0!import')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves ordinary text alone, braces and all', () => {
|
||||||
|
for (const text of [
|
||||||
|
'Hi Luca, the meeting is at {time} tomorrow',
|
||||||
|
'#1234 has been shipped',
|
||||||
|
'@Luca can you check the figures?',
|
||||||
|
'Order {A-17} confirmed',
|
||||||
|
'Price {EUR}: 10 per box',
|
||||||
|
'Meeting at {room',
|
||||||
|
'@media team, the brief is attached',
|
||||||
|
'@Page can you check?',
|
||||||
|
'@import the CSV, then send it back',
|
||||||
|
'Reminder: your appointment {date: 2026-10-01} is confirmed',
|
||||||
|
]) expect(stripLeadingCss(text)).toBe(text);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cleanPreview', () => {
|
||||||
|
it('strips padding and a style sheet together, and treats nothing left as no preview', () => {
|
||||||
|
expect(cleanPreview('\u034f \u200b.x{color:red} Real text')).toBe('Real text');
|
||||||
|
expect(cleanPreview('.x{color:red} ...')).toBe('');
|
||||||
|
expect(cleanPreview(null)).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The preview comes from the text/plain part, which marks bold with asterisks
|
||||||
|
// where the HTML part uses <strong> (#1260).
|
||||||
|
describe('stripEmphasisMarkers', () => {
|
||||||
|
it('drops emphasis markers around words', () => {
|
||||||
|
expect(cleanPreview('*@Claude* mentioned you: please review')).toBe('@Claude mentioned you: please review');
|
||||||
|
expect(stripEmphasisMarkers('This is **really** important.')).toBe('This is really important.');
|
||||||
|
expect(stripEmphasisMarkers('See _the docs_ and __this__, (*now*)')).toBe('See the docs and this, (now)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves asterisks and underscores inside words or between spaces alone', () => {
|
||||||
|
for (const text of [
|
||||||
|
'2 * 3 * 4 = 24',
|
||||||
|
'a*b*c and snake_case_name',
|
||||||
|
'rating: * * *',
|
||||||
|
'my_var_ is set',
|
||||||
|
'footnote* and *',
|
||||||
|
'***',
|
||||||
|
'__init__.py changed',
|
||||||
|
]) expect(stripEmphasisMarkers(text)).toBe(text);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stripLeadingCss pseudo-classes', () => {
|
||||||
|
it('still drops a rule whose selector has a pseudo-class', () => {
|
||||||
|
expect(stripLeadingCss('a:hover, a:focus{color:red} Hello')).toBe('Hello');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('list rows', () => {
|
||||||
|
it('show the cleaned preview', () => {
|
||||||
|
const read = (p: string) => fs.readFileSync(path.resolve(__dirname, '../..', p), 'utf8');
|
||||||
|
expect(read('screens/EmailListScreen.tsx')).toMatch(/singleLine\(cleanPreview\(item\.preview\)\)/);
|
||||||
|
expect(read('screens/UnifiedInboxScreen.tsx')).toMatch(/singleLine\(cleanPreview\(item\.preview\)\)/);
|
||||||
|
expect(read('components/email/ThreadMessageCard.tsx')).toMatch(/singleLine\(cleanPreview\(email\.preview\)\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { buildQuoteCollapseScript, QUOTE_COLLAPSED_ATTR, QUOTE_TOGGLE_ATTR } from '../quote-collapse';
|
||||||
|
|
||||||
|
// The reader's quote-collapse script looked for an attributed <blockquote>
|
||||||
|
// by walking every text node from the start of the body once per top-level
|
||||||
|
// blockquote: quadratic, so a mail full of blockquotes froze the reader. The
|
||||||
|
// webmail does one walk (lib/quote-collapse.ts). These tests run the injected
|
||||||
|
// script against a small fake DOM that counts the tree-walker steps.
|
||||||
|
|
||||||
|
const SHOW_ELEMENT = 1;
|
||||||
|
const SHOW_TEXT = 4;
|
||||||
|
|
||||||
|
let steps = 0;
|
||||||
|
|
||||||
|
class FNode {
|
||||||
|
parentNode: FEl | null = null;
|
||||||
|
get parentElement(): FEl | null { return this.parentNode; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class FText extends FNode {
|
||||||
|
nodeType = 3;
|
||||||
|
constructor(public nodeValue: string) { super(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
class FEl extends FNode {
|
||||||
|
nodeType = 1;
|
||||||
|
tagName: string;
|
||||||
|
childNodes: FNode[] = [];
|
||||||
|
attrs = new Map<string, string>();
|
||||||
|
style = { display: '', cssText: '' };
|
||||||
|
title = '';
|
||||||
|
textContent = '';
|
||||||
|
constructor(tag: string) { super(); this.tagName = tag.toUpperCase(); }
|
||||||
|
append(...kids: Array<FNode | string>): this {
|
||||||
|
for (const kid of kids) {
|
||||||
|
const node = typeof kid === 'string' ? new FText(kid) : kid;
|
||||||
|
node.parentNode = this;
|
||||||
|
this.childNodes.push(node);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
insertBefore(node: FNode, ref: FNode): void {
|
||||||
|
node.parentNode = this;
|
||||||
|
this.childNodes.splice(this.childNodes.indexOf(ref), 0, node);
|
||||||
|
}
|
||||||
|
get nextElementSibling(): FEl | null {
|
||||||
|
const sibs = this.parentNode?.childNodes ?? [];
|
||||||
|
for (let i = sibs.indexOf(this) + 1; i < sibs.length; i++) if (sibs[i] instanceof FEl) return sibs[i] as FEl;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
setAttribute(k: string, v: string) { this.attrs.set(k, v); }
|
||||||
|
getAttribute(k: string) { return this.attrs.get(k) ?? null; }
|
||||||
|
hasAttribute(k: string) { return this.attrs.has(k); }
|
||||||
|
removeAttribute(k: string) { this.attrs.delete(k); }
|
||||||
|
addEventListener() { /* not clicked here */ }
|
||||||
|
matches() { return false; }
|
||||||
|
// The fixtures hold no element the container or separator selectors match.
|
||||||
|
querySelector() { return null; }
|
||||||
|
querySelectorAll(tag: string): FEl[] {
|
||||||
|
return preorder(this).slice(1).filter((n): n is FEl => n instanceof FEl && n.tagName === tag.toUpperCase());
|
||||||
|
}
|
||||||
|
closest(tag: string): FEl | null {
|
||||||
|
for (let el: FEl | null = this; el; el = el.parentNode) if (el.tagName === tag.toUpperCase()) return el;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
compareDocumentPosition(other: FNode): number {
|
||||||
|
for (let p = other.parentNode; p; p = p.parentNode) if (p === this) return 16 | 4; // contained by, following
|
||||||
|
const order = preorder(rootOf(this));
|
||||||
|
return order.indexOf(other) < order.indexOf(this) ? 2 : 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rootOf(node: FNode): FNode {
|
||||||
|
let n = node;
|
||||||
|
while (n.parentNode) n = n.parentNode;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function preorder(root: FNode): FNode[] {
|
||||||
|
const out: FNode[] = [];
|
||||||
|
const visit = (n: FNode) => {
|
||||||
|
out.push(n);
|
||||||
|
if (n instanceof FEl) n.childNodes.forEach(visit);
|
||||||
|
};
|
||||||
|
visit(root);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function el(tag: string, ...kids: Array<FNode | string>): FEl {
|
||||||
|
return new FEl(tag).append(...kids);
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(body: FEl): void {
|
||||||
|
const document = {
|
||||||
|
body,
|
||||||
|
createElement: (tag: string) => new FEl(tag),
|
||||||
|
createTreeWalker: (root: FEl, what: number) => {
|
||||||
|
const nodes = preorder(root).slice(1).filter((n) =>
|
||||||
|
(n instanceof FText && what & SHOW_TEXT) || (n instanceof FEl && what & SHOW_ELEMENT));
|
||||||
|
let i = 0;
|
||||||
|
return { nextNode: () => { steps++; return nodes[i++] ?? null; } };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const Node = {
|
||||||
|
TEXT_NODE: 3, DOCUMENT_POSITION_PRECEDING: 2, DOCUMENT_POSITION_FOLLOWING: 4, DOCUMENT_POSITION_CONTAINED_BY: 16,
|
||||||
|
};
|
||||||
|
const NodeFilter = { SHOW_ELEMENT, SHOW_TEXT };
|
||||||
|
const script = buildQuoteCollapseScript({ show: 'Show', hide: 'Hide' });
|
||||||
|
new Function('document', 'Node', 'NodeFilter', 'window', script)(document, Node, NodeFilter, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('quote collapse in HTML mail', () => {
|
||||||
|
it('finds the attributed blockquote in one walk, not one walk per blockquote', () => {
|
||||||
|
const n = 300;
|
||||||
|
const body = el('body');
|
||||||
|
for (let i = 0; i < n; i++) body.append(el('p', `line ${i}`), el('blockquote', `quote ${i}`));
|
||||||
|
steps = 0;
|
||||||
|
run(body);
|
||||||
|
// About 4n nodes in the body; walking from the start for each of the n
|
||||||
|
// blockquotes took about n² steps.
|
||||||
|
expect(steps).toBeLessThan(20 * n);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses the outermost blockquote after an attribution line', () => {
|
||||||
|
const inner = el('blockquote', 'older');
|
||||||
|
const outer = el('blockquote', el('p', 'old'), inner);
|
||||||
|
const first = el('blockquote', 'not attributed');
|
||||||
|
const body = el('body', el('p', 'a'), first, el('p', 'Thanks!'), el('p', 'On Monday, Bob wrote:'), outer);
|
||||||
|
run(body);
|
||||||
|
expect(outer.style.display).toBe('none');
|
||||||
|
expect(outer.hasAttribute(QUOTE_COLLAPSED_ATTR)).toBe(true);
|
||||||
|
expect(first.style.display).toBe('');
|
||||||
|
expect(inner.hasAttribute(QUOTE_COLLAPSED_ATTR)).toBe(false);
|
||||||
|
const button = body.childNodes[body.childNodes.indexOf(outer) - 1] as FEl;
|
||||||
|
expect(button.hasAttribute(QUOTE_TOGGLE_ATTR)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a blockquote alone when no attribution line precedes it', () => {
|
||||||
|
const quote = el('blockquote', 'quoted');
|
||||||
|
run(el('body', el('p', 'Hello there'), quote));
|
||||||
|
expect(quote.style.display).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { searchScopeFolders } from '../search-scope-folders';
|
||||||
|
import type { Mailbox } from '../../api/types';
|
||||||
|
|
||||||
|
// The search panel's Folder picker was a flat list cut at twelve folders, so
|
||||||
|
// deeper or later folders couldn't be searched on their own (webmail
|
||||||
|
// lib/search-scope-folders.ts lists them all as a tree).
|
||||||
|
|
||||||
|
const box = (id: string, parentId: string | null = null, role: string | null = null) =>
|
||||||
|
({ id, name: id, parentId, role, sortOrder: 0, totalEmails: 0, unreadEmails: 0, isShared: false } as unknown as Mailbox);
|
||||||
|
|
||||||
|
describe('search folder picker', () => {
|
||||||
|
it('lists every folder, each subfolder after its parent with its depth, without the open one', () => {
|
||||||
|
const boxes = [box('inbox', null, 'inbox'), ...Array.from({ length: 15 }, (_, i) => box(`f${String(i).padStart(2, '0')}`)), box('child', 'f03')];
|
||||||
|
const list = searchScopeFolders(boxes, 'inbox');
|
||||||
|
expect(list).toHaveLength(16);
|
||||||
|
const ids = list.map((m) => m.id);
|
||||||
|
expect(ids.indexOf('child')).toBe(ids.indexOf('f03') + 1);
|
||||||
|
expect(list.find((m) => m.id === 'child')?.depth).toBe(1);
|
||||||
|
expect(ids).not.toContain('inbox');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is what the panel shows, uncut', () => {
|
||||||
|
const list = fs.readFileSync(path.resolve(__dirname, '../../screens/EmailListScreen.tsx'), 'utf8');
|
||||||
|
expect(list).not.toMatch(/\.slice\(0, 12\)/);
|
||||||
|
expect(list).toMatch(/searchScopeFolders\(scopedMailboxes, currentMailboxId\)\.map/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { sortChoiceChanges, sortChoiceFor } from '../sort-choices';
|
||||||
|
|
||||||
|
// The list header only flipped newest/oldest; the order presets were three
|
||||||
|
// screens away in Settings > Layout (webmail e3f1ccce7 has a Sort menu).
|
||||||
|
|
||||||
|
describe('list header sort menu', () => {
|
||||||
|
it('names the order the open folder uses', () => {
|
||||||
|
expect(sortChoiceFor([], false)).toBe('newest');
|
||||||
|
expect(sortChoiceFor([], true)).toBe('oldest');
|
||||||
|
expect(sortChoiceFor([{ criterion: 'unread', direction: 'desc' }], false)).toBe('unread');
|
||||||
|
expect(sortChoiceFor([{ criterion: 'from', direction: 'asc' }, { criterion: 'receivedAt', direction: 'desc' }], false)).toBe('sender');
|
||||||
|
expect(sortChoiceFor([{ criterion: 'unread', direction: 'desc' }, { criterion: 'starred', direction: 'desc' }], false)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes the order setting, and widens an Inbox-only order when picked elsewhere', () => {
|
||||||
|
expect(sortChoiceChanges('largest', 'inbox', 'inbox')).toEqual({
|
||||||
|
messageListOrder: [{ criterion: 'size', direction: 'desc' }], mailSortAscending: false,
|
||||||
|
});
|
||||||
|
expect(sortChoiceChanges('starred', 'inbox', 'archive')?.messageListOrderScope).toBe('all');
|
||||||
|
expect(sortChoiceChanges('oldest', 'inbox', 'archive')).toEqual({ messageListOrder: [], mailSortAscending: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens from the header button', () => {
|
||||||
|
const list = fs.readFileSync(path.resolve(__dirname, '../../screens/EmailListScreen.tsx'), 'utf8');
|
||||||
|
expect(list).toMatch(/onPress=\{\(\) => setSortMenuOpen\(true\)\}/);
|
||||||
|
expect(list).toMatch(/sortChoiceChanges\(choice\.key, messageListOrderScope, currentRole\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
vi.mock('../../api/jmap-client', () => ({
|
||||||
|
jmapClient: { accountId: 'acc-1', request: vi.fn(), hasDelayedSend: vi.fn(() => true) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { jmapClient } from '../../api/jmap-client';
|
||||||
|
import { scheduledEmailIdsInThread } from '../../api/thread-scheduled';
|
||||||
|
import { isReplyableMember, quickReplyTarget } from '../thread-utils';
|
||||||
|
|
||||||
|
// Every card of a conversation offered Reply, and the quick reply answered
|
||||||
|
// the newest member even when that was the user's own draft or a reply
|
||||||
|
// waiting for its scheduled send (webmail 8b29254a6, #1404).
|
||||||
|
|
||||||
|
const msg = (id: string, keywords: Record<string, boolean> = {}) => ({ id, keywords });
|
||||||
|
const none = new Set<string>();
|
||||||
|
|
||||||
|
describe('replying in a conversation (bulwarkmail/webmail#1404)', () => {
|
||||||
|
it('does not offer Reply on drafts and scheduled messages', () => {
|
||||||
|
expect(isReplyableMember(msg('a'), none)).toBe(true);
|
||||||
|
expect(isReplyableMember(msg('d', { $draft: true }), none)).toBe(false);
|
||||||
|
expect(isReplyableMember(msg('s'), new Set(['s']))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('points the quick reply at the newest message that is neither', () => {
|
||||||
|
const conversation = [msg('1'), msg('2'), msg('3'), msg('draft', { $draft: true })];
|
||||||
|
expect(quickReplyTarget(conversation, none)?.id).toBe('3');
|
||||||
|
expect(quickReplyTarget(conversation, new Set(['3']))?.id).toBe('2');
|
||||||
|
expect(quickReplyTarget([msg('d', { $draft: true })], none)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is wired into the conversation cards and the quick reply', () => {
|
||||||
|
const screen = fs.readFileSync(path.resolve(__dirname, '../../screens/EmailThreadScreen.tsx'), 'utf8');
|
||||||
|
expect(screen).toMatch(/replyable=\{!!full && isReplyableMember\(m, scheduledIds\)\}/);
|
||||||
|
expect(screen).toMatch(/const newest = quickReplyTarget\(/);
|
||||||
|
expect(screen).toMatch(/scheduledEmailIdsInThread\(threadId, jmapAccountId\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scheduledEmailIdsInThread', () => {
|
||||||
|
const request = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||||
|
beforeEach(() => request.mockReset());
|
||||||
|
|
||||||
|
it('keeps pending submissions whose send time is ahead', async () => {
|
||||||
|
const future = new Date(Date.now() + 3_600_000).toISOString();
|
||||||
|
const past = new Date(Date.now() - 60_000).toISOString();
|
||||||
|
request.mockResolvedValue({
|
||||||
|
methodResponses: [
|
||||||
|
['EmailSubmission/query', { ids: ['s1', 's2', 's3'] }, '0'],
|
||||||
|
['EmailSubmission/get', { list: [
|
||||||
|
{ emailId: 'e1', sendAt: future, undoStatus: 'pending' },
|
||||||
|
{ emailId: 'e2', sendAt: past, undoStatus: 'pending' },
|
||||||
|
{ emailId: 'e3', sendAt: future, undoStatus: 'canceled' },
|
||||||
|
] }, '1'],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect([...await scheduledEmailIdsInThread('t1')]).toEqual(['e1']);
|
||||||
|
const [[calls]] = request.mock.calls;
|
||||||
|
expect(calls[0][1].filter).toEqual({ threadIds: ['t1'], undoStatus: 'pending' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers with nothing when the server refuses or the account cannot schedule', async () => {
|
||||||
|
request.mockResolvedValue({ methodResponses: [['error', { type: 'unsupportedFilter' }, '0'], ['error', {}, '1']] });
|
||||||
|
expect((await scheduledEmailIdsInThread('t1')).size).toBe(0);
|
||||||
|
request.mockImplementationOnce(async () => { throw new Error('offline'); });
|
||||||
|
expect((await scheduledEmailIdsInThread('t1')).size).toBe(0);
|
||||||
|
(jmapClient.hasDelayedSend as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
||||||
|
request.mockClear();
|
||||||
|
expect((await scheduledEmailIdsInThread('t1')).size).toBe(0);
|
||||||
|
expect(request).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { viaIdentityNameShown } from '../email-headers';
|
||||||
|
|
||||||
|
// Mail sent as one of the user's identities read "Ann <ann@x>" with a
|
||||||
|
// "via ann@x" badge: the sender's address a second time (webmail
|
||||||
|
// email-identity-badge.tsx shows the identity's name, and only when it adds
|
||||||
|
// something).
|
||||||
|
|
||||||
|
describe('"via" badge', () => {
|
||||||
|
it("shows the identity's name only when it differs from what the sender line says", () => {
|
||||||
|
const from = { name: 'Ann Smith', email: 'ann@example.org' };
|
||||||
|
expect(viaIdentityNameShown({ name: 'Support', email: 'ann@example.org' }, from)).toBe(true);
|
||||||
|
expect(viaIdentityNameShown({ name: 'Ann Smith', email: 'ann@example.org' }, from)).toBe(false);
|
||||||
|
expect(viaIdentityNameShown({ name: 'ann@example.org', email: 'ann@example.org' }, from)).toBe(false);
|
||||||
|
expect(viaIdentityNameShown({ name: '', email: 'ann@example.org' }, from)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the identity, not its address, in the reader's badge", () => {
|
||||||
|
const header = fs.readFileSync(path.resolve(__dirname, '../../components/email/MessageHeader.tsx'), 'utf8');
|
||||||
|
expect(header).toMatch(/'via \{name\}', \{ name: viaIdentity\.identity\.name \}/);
|
||||||
|
expect(header).toMatch(/viaIdentityNameShown\(sentAs, from\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import type { Attachment, Email } from '../api/types';
|
|||||||
import { findCalendarAttachment } from './calendar-invitation';
|
import { findCalendarAttachment } from './calendar-invitation';
|
||||||
import { pickEmailBody, selectRenderableHtml } from './email-body';
|
import { pickEmailBody, selectRenderableHtml } from './email-body';
|
||||||
import { extractCidRefs } from './email-html';
|
import { extractCidRefs } from './email-html';
|
||||||
|
import { stripBidiControls } from './bidi';
|
||||||
|
|
||||||
const MIME_TYPE_LABELS: Record<string, string> = {
|
const MIME_TYPE_LABELS: Record<string, string> = {
|
||||||
'application/pdf': 'Document.pdf',
|
'application/pdf': 'Document.pdf',
|
||||||
@@ -31,7 +32,10 @@ const MIME_TYPE_LABELS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function getAttachmentDisplayName(name: string | null | undefined, mimeType?: string): string {
|
export function getAttachmentDisplayName(name: string | null | undefined, mimeType?: string): string {
|
||||||
if (name) return name;
|
// A direction override in a sender-chosen name can fake the file type
|
||||||
|
// (`report<U+202E>fdp.exe` shows as `reportexe.pdf`).
|
||||||
|
const shown = name ? stripBidiControls(name) : '';
|
||||||
|
if (shown) return shown;
|
||||||
if (mimeType) {
|
if (mimeType) {
|
||||||
const base = mimeType.split(';')[0].trim().toLowerCase();
|
const base = mimeType.split(';')[0].trim().toLowerCase();
|
||||||
const label = MIME_TYPE_LABELS[base];
|
const label = MIME_TYPE_LABELS[base];
|
||||||
@@ -52,10 +56,22 @@ export function isCalendarMimeType(type?: string | null): boolean {
|
|||||||
return CAL_MIME.has(type.split(';')[0].trim().toLowerCase());
|
return CAL_MIME.has(type.split(';')[0].trim().toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Machine-readable report parts (MDN receipts, DSN bounces) - never real attachments. */
|
/**
|
||||||
|
* Machine-readable report parts: the delivery status of a bounce (RFC 3464),
|
||||||
|
* the disposition of a read receipt (RFC 8098) and their RFC 6533 UTF-8
|
||||||
|
* forms. A multipart/report needs them, but they are not files anyone
|
||||||
|
* attached, so no view lists them and a forward leaves them out (webmail
|
||||||
|
* lib/attachment-visibility.ts).
|
||||||
|
*/
|
||||||
|
const REPORT_PART_TYPES: ReadonlySet<string> = new Set([
|
||||||
|
'message/delivery-status',
|
||||||
|
'message/disposition-notification',
|
||||||
|
'message/global-delivery-status',
|
||||||
|
'message/global-disposition-notification',
|
||||||
|
]);
|
||||||
|
|
||||||
export function isReportPart(type?: string | null): boolean {
|
export function isReportPart(type?: string | null): boolean {
|
||||||
const base = (type || '').split(';')[0].trim().toLowerCase();
|
return REPORT_PART_TYPES.has((type || '').split(';')[0].trim().toLowerCase());
|
||||||
return base === 'message/disposition-notification' || base === 'message/delivery-status';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isRfc822Attachment(att: Pick<Attachment, 'name' | 'type'>): boolean {
|
export function isRfc822Attachment(att: Pick<Attachment, 'name' | 'type'>): boolean {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
import type { ContactCard } from '../api/types';
|
import type { ContactCard } from '../api/types';
|
||||||
import { getContactPhotoRef, type ContactPhotoRef } from './contact-utils';
|
import { getContactPhotoRef, type ContactPhotoRef } from './contact-utils';
|
||||||
|
import { parseColor, relativeLuminance } from './event-colors';
|
||||||
|
|
||||||
// --- Account avatar -------------------------------------------------------
|
// --- Account avatar -------------------------------------------------------
|
||||||
|
|
||||||
@@ -48,6 +49,44 @@ export function generateEmailAvatarColor(name: string, email?: string): string {
|
|||||||
return `hsl(${hue}, 70%, 50%)`;
|
return `hsl(${hue}, 70%, 50%)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `hsl(h, s%, l%)` as RGB; null for anything else. */
|
||||||
|
function hslToRgb(color: string): { r: number; g: number; b: number; a: number } | null {
|
||||||
|
const m = /^hsla?\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%/i.exec(color.trim());
|
||||||
|
if (!m) return null;
|
||||||
|
const h = (Number(m[1]) % 360) / 360;
|
||||||
|
const s = Number(m[2]) / 100;
|
||||||
|
const l = Number(m[3]) / 100;
|
||||||
|
const hue2rgb = (p: number, q: number, t: number) => {
|
||||||
|
if (t < 0) t += 1;
|
||||||
|
if (t > 1) t -= 1;
|
||||||
|
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||||
|
if (t < 1 / 2) return q;
|
||||||
|
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||||
|
const p = 2 * l - q;
|
||||||
|
return {
|
||||||
|
r: Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
|
||||||
|
g: Math.round(hue2rgb(p, q, h) * 255),
|
||||||
|
b: Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
|
||||||
|
a: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* White or black initials, whichever contrasts more with the background.
|
||||||
|
* One of the two always reaches 4.58:1, so the initials pass 4.5:1 on every
|
||||||
|
* generated colour; white alone failed on yellows, greens and cyans
|
||||||
|
* (webmail components/ui/avatar.tsx initialsColor, 2e13accad).
|
||||||
|
*/
|
||||||
|
export function initialsColor(background: string): string {
|
||||||
|
const rgb = hslToRgb(background) ?? parseColor(background);
|
||||||
|
if (!rgb) return '#ffffff';
|
||||||
|
const l = relativeLuminance(rgb);
|
||||||
|
return 1.05 / (l + 0.05) >= (l + 0.05) / 0.05 ? '#ffffff' : '#000000';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initials as the webmail's `Avatar` (components/ui/avatar.tsx) works them
|
* Initials as the webmail's `Avatar` (components/ui/avatar.tsx) works them
|
||||||
* out: the first letters of the first and last name part, ignoring
|
* out: the first letters of the first and last name part, ignoring
|
||||||
@@ -196,3 +235,24 @@ export function getContactPhotoForEmail(
|
|||||||
const key = email?.trim().toLowerCase();
|
const key = email?.trim().toLowerCase();
|
||||||
return key ? getContactPhotoIndex(contacts).get(key) : undefined;
|
return key ? getContactPhotoIndex(contacts).get(key) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- What the avatar shows --------------------------------------------------
|
||||||
|
|
||||||
|
export type AvatarContent = 'warning' | 'photo' | 'favicon' | 'initials';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a sender avatar shows, in the webmail `Avatar`'s order. A failed or
|
||||||
|
* unverified sender check comes first: a warning shield instead of any photo,
|
||||||
|
* favicon or initials, so a forged From doesn't get the real sender's logo or
|
||||||
|
* contact photo (webmail 605a3983b, #1439).
|
||||||
|
*/
|
||||||
|
export function avatarContent(options: {
|
||||||
|
senderWarning?: string | null;
|
||||||
|
hasPhoto: boolean;
|
||||||
|
showFavicon: boolean;
|
||||||
|
}): AvatarContent {
|
||||||
|
if (options.senderWarning) return 'warning';
|
||||||
|
if (options.hasPhoto) return 'photo';
|
||||||
|
if (options.showFavicon) return 'favicon';
|
||||||
|
return 'initials';
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Unicode directional formatting characters: the explicit embeddings,
|
||||||
|
* overrides and isolates (U+202A-202E, U+2066-2069) and the LRM/RLM marks.
|
||||||
|
* In a sender-controlled name they reorder what the reader sees, so
|
||||||
|
* `report<U+202E>fdp.exe` shows as `reportexe.pdf` (webmail lib/bidi.ts).
|
||||||
|
*/
|
||||||
|
const BIDI_CONTROLS = /[\u200E\u200F\u202A-\u202E\u2066-\u2069]/g;
|
||||||
|
|
||||||
|
/** `text` without directional formatting characters, for display. */
|
||||||
|
export function stripBidiControls(text: string): string {
|
||||||
|
return text.replace(BIDI_CONTROLS, '');
|
||||||
|
}
|
||||||
@@ -6,9 +6,10 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
prepareEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml,
|
prepareEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml,
|
||||||
type MessageSpacing, type PlainTextFont,
|
type MessageSpacing, type PlainTextFont, type PlainTextColors,
|
||||||
} from './email-html';
|
} from './email-html';
|
||||||
import { collapsePlainTextQuotes, type QuoteCollapseLabels } from './quote-collapse';
|
import { collapsePlainTextQuotes, type QuoteCollapseLabels } from './quote-collapse';
|
||||||
|
import { mailBaseFontPx } from './mail-reading';
|
||||||
|
|
||||||
export interface BodyDocumentInput {
|
export interface BodyDocumentInput {
|
||||||
/** The message (account and id); the sources below are compared as well. */
|
/** The message (account and id); the sources below are compared as well. */
|
||||||
@@ -25,6 +26,13 @@ export interface BodyDocumentInput {
|
|||||||
messageSpacing: MessageSpacing;
|
messageSpacing: MessageSpacing;
|
||||||
plainTextFont: PlainTextFont;
|
plainTextFont: PlainTextFont;
|
||||||
quoteLabels: QuoteCollapseLabels;
|
quoteLabels: QuoteCollapseLabels;
|
||||||
|
/**
|
||||||
|
* Factor for the text size (mailTextFactor): the base size of a plain-text
|
||||||
|
* body. An HTML body is scaled by an injected pass instead. Default 1.
|
||||||
|
*/
|
||||||
|
textFactor?: number;
|
||||||
|
/** The app theme's colours for a plain-text body in dark mode. */
|
||||||
|
plainDarkColors?: PlainTextColors;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BodyDocument {
|
export interface BodyDocument {
|
||||||
@@ -55,7 +63,12 @@ export function buildBodyDocument(input: BodyDocumentInput): BodyDocument {
|
|||||||
}
|
}
|
||||||
const safe = collapsePlainTextQuotes(plainTextToSafeHtml(text), input.quoteLabels);
|
const safe = collapsePlainTextQuotes(plainTextToSafeHtml(text), input.quoteLabels);
|
||||||
return {
|
return {
|
||||||
html: wrapPlainTextEmail(safe, { isDark, font: input.plainTextFont }),
|
html: wrapPlainTextEmail(safe, {
|
||||||
|
isDark,
|
||||||
|
font: input.plainTextFont,
|
||||||
|
fontPx: mailBaseFontPx(input.textFactor ?? 1),
|
||||||
|
darkColors: input.plainDarkColors,
|
||||||
|
}),
|
||||||
applyInversion: false,
|
applyInversion: false,
|
||||||
hasNativeDark: false,
|
hasNativeDark: false,
|
||||||
blockedExternal: false,
|
blockedExternal: false,
|
||||||
@@ -83,7 +96,8 @@ function sameSources(a: BodyDocumentInput, b: BodyDocumentInput): boolean {
|
|||||||
export function bodyDocument(input: BodyDocumentInput): BodyDocument {
|
export function bodyDocument(input: BodyDocumentInput): BodyDocument {
|
||||||
const key = JSON.stringify([
|
const key = JSON.stringify([
|
||||||
input.key, input.blockRemoteImages, input.isDark, input.messageSpacing, input.plainTextFont,
|
input.key, input.blockRemoteImages, input.isDark, input.messageSpacing, input.plainTextFont,
|
||||||
input.quoteLabels.show, input.quoteLabels.hide, input.emptyLabel,
|
input.quoteLabels.show, input.quoteLabels.hide, input.emptyLabel, input.textFactor ?? 1,
|
||||||
|
input.plainDarkColors ?? null,
|
||||||
]);
|
]);
|
||||||
const hit = documents.get(key);
|
const hit = documents.get(key);
|
||||||
if (hit && sameSources(hit.input, input)) {
|
if (hit && sameSources(hit.input, input)) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from './download-filename';
|
} from './download-filename';
|
||||||
import { getClientCertAlias, secureFetch } from './client-cert';
|
import { getClientCertAlias, secureFetch } from './client-cert';
|
||||||
import { sniffImageMime } from './email-html';
|
import { sniffImageMime } from './email-html';
|
||||||
|
import { stripBidiControls } from './bidi';
|
||||||
import { handoffMimeType } from './file-preview';
|
import { handoffMimeType } from './file-preview';
|
||||||
|
|
||||||
const RFC822 = 'message/rfc822';
|
const RFC822 = 'message/rfc822';
|
||||||
@@ -55,7 +56,7 @@ export function attachmentSaveName(email: Email, name: string | undefined, type:
|
|||||||
|
|
||||||
function safeAttachmentName(name: string | undefined, type: string | undefined): string {
|
function safeAttachmentName(name: string | undefined, type: string | undefined): string {
|
||||||
const fallbackExt = type?.split('/')[1]?.replace(/[^a-z0-9]/gi, '').slice(0, 8) || 'bin';
|
const fallbackExt = type?.split('/')[1]?.replace(/[^a-z0-9]/gi, '').slice(0, 8) || 'bin';
|
||||||
const cleaned = (name ?? '').replace(/[\\/:*?"<>|]/g, '_').trim();
|
const cleaned = stripBidiControls(name ?? '').replace(/[\\/:*?"<>|]/g, '_').trim();
|
||||||
if (cleaned) return cleaned.slice(0, 120);
|
if (cleaned) return cleaned.slice(0, 120);
|
||||||
return `attachment.${fallbackExt}`;
|
return `attachment.${fallbackExt}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,21 @@ const SPF_SEVERITY: Record<SpfResult, number> = {
|
|||||||
pass: 0,
|
pass: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether "via <identity>" on a message sent as one of the user's identities
|
||||||
|
* tells the reader anything: only when the identity's name differs from its
|
||||||
|
* address, the From address and the name already shown as the sender, so it
|
||||||
|
* no longer reads "ann@x via ann@x" (webmail email-identity-badge.tsx).
|
||||||
|
*/
|
||||||
|
export function viaIdentityNameShown(
|
||||||
|
identity: Pick<Identity, 'name' | 'email'>,
|
||||||
|
from: { name?: string | null; email?: string | null } | undefined,
|
||||||
|
): boolean {
|
||||||
|
const name = identity.name?.trim();
|
||||||
|
if (!name) return false;
|
||||||
|
return name !== identity.email && name !== from?.email && name !== from?.name;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the authentication results indicate the visible From identity can't
|
* Whether the authentication results indicate the visible From identity can't
|
||||||
* be trusted (i.e. the message is likely spoofed). Used to suppress UI that
|
* be trusted (i.e. the message is likely spoofed). Used to suppress UI that
|
||||||
@@ -244,6 +259,28 @@ export function senderVerificationOf(email: Pick<Email, 'headers' | 'from'>): Se
|
|||||||
return getSenderVerification(parseAuthenticationResults(values), email.from?.[0]?.email);
|
return getSenderVerification(parseAuthenticationResults(values), email.from?.[0]?.email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The list fetch asks for the Authentication-Results headers by this
|
||||||
|
* property, so list rows and collapsed conversation cards can judge the
|
||||||
|
* sender too (webmail 605a3983b, #1439). Stalwart answers header properties
|
||||||
|
* from the stored headers, not the message blob.
|
||||||
|
*/
|
||||||
|
export const LIST_AUTH_RESULTS_PROPERTY = 'header:Authentication-Results:all';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sender check for a message as the list or the reader holds it: from
|
||||||
|
* the full headers when they are there, else from the list property. Null
|
||||||
|
* when the checks back the From or there is nothing to judge by.
|
||||||
|
*/
|
||||||
|
export function senderWarningOf(email: Pick<Email, 'headers' | 'from'>): SenderVerification | null {
|
||||||
|
if (email.headers) return senderVerificationOf(email);
|
||||||
|
const raw = (email as { [LIST_AUTH_RESULTS_PROPERTY]?: unknown })[LIST_AUTH_RESULTS_PROPERTY];
|
||||||
|
if (!Array.isArray(raw)) return null;
|
||||||
|
const values = raw.filter((value): value is string => typeof value === 'string');
|
||||||
|
if (values.length === 0) return null;
|
||||||
|
return getSenderVerification(parseAuthenticationResults(values), email.from?.[0]?.email);
|
||||||
|
}
|
||||||
|
|
||||||
interface ResInfo {
|
interface ResInfo {
|
||||||
method: string;
|
method: string;
|
||||||
result: string;
|
result: string;
|
||||||
@@ -323,6 +360,12 @@ function parseResinfos(header: string): ResInfo[] {
|
|||||||
.filter((info): info is ResInfo => info !== null);
|
.filter((info): info is ResInfo => info !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The domain of a DKIM identity (`header.i`): `@domain` or `user@domain`. */
|
||||||
|
function identityDomain(identity: string | undefined): string | undefined {
|
||||||
|
const at = identity?.lastIndexOf('@') ?? -1;
|
||||||
|
return identity && at >= 0 && at < identity.length - 1 ? identity.slice(at + 1) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const DMARC_SEVERITY: Record<string, number> = {
|
const DMARC_SEVERITY: Record<string, number> = {
|
||||||
fail: 3,
|
fail: 3,
|
||||||
permerror: 2,
|
permerror: 2,
|
||||||
@@ -394,7 +437,9 @@ export function parseAuthenticationResults(headers: string | readonly string[]):
|
|||||||
.filter((info) => info.method === 'dkim')
|
.filter((info) => info.method === 'dkim')
|
||||||
.map((info) => ({
|
.map((info) => ({
|
||||||
result: info.result as DkimResult,
|
result: info.result as DkimResult,
|
||||||
domain: info.props['header.d'],
|
// Some receivers name the signature only by its identity (`header.i`,
|
||||||
|
// `@domain` or `user@domain`, RFC 8601 2.7.1); its domain is the signer's.
|
||||||
|
domain: info.props['header.d'] ?? identityDomain(info.props['header.i']),
|
||||||
selector: info.props['header.s'],
|
selector: info.props['header.s'],
|
||||||
}));
|
}));
|
||||||
if (dkimResults.length > 0) {
|
if (dkimResults.length > 0) {
|
||||||
|
|||||||
+22
-6
@@ -953,16 +953,32 @@ export type PlainTextFont = 'sans' | 'mono';
|
|||||||
// WebView. Mirrors the webmail's plain-text render path: app font by default
|
// WebView. Mirrors the webmail's plain-text render path: app font by default
|
||||||
// (monospace on request, #830), `white-space: pre-wrap`, native dark theme
|
// (monospace on request, #830), `white-space: pre-wrap`, native dark theme
|
||||||
// (no filter inversion), and a dark-mode-appropriate link colour.
|
// (no filter inversion), and a dark-mode-appropriate link colour.
|
||||||
|
/** The app theme's colours for a plain-text body read in dark mode. */
|
||||||
|
export interface PlainTextColors {
|
||||||
|
background: string;
|
||||||
|
text: string;
|
||||||
|
link: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A theme colour for a style sheet: nothing that could end the declaration or the element. */
|
||||||
|
function cssColor(value: string, fallback: string): string {
|
||||||
|
const cleaned = value.replace(/[<>{};\\]/g, '').trim();
|
||||||
|
return cleaned || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
export function wrapPlainTextEmail(
|
export function wrapPlainTextEmail(
|
||||||
innerHtml: string,
|
innerHtml: string,
|
||||||
options: { isDark?: boolean; font?: PlainTextFont } = {},
|
options: { isDark?: boolean; font?: PlainTextFont; fontPx?: number; darkColors?: PlainTextColors } = {},
|
||||||
): string {
|
): string {
|
||||||
const { isDark = true, font = 'sans' } = options;
|
const { isDark = true, font = 'sans', fontPx = 14, darkColors } = options;
|
||||||
const cleaned = stripDangerousTags(innerHtml);
|
const cleaned = stripDangerousTags(innerHtml);
|
||||||
|
|
||||||
const bg = isDark ? '#0a0a0a' : '#ffffff';
|
// In dark mode the body takes the app theme's ground, text and link colours
|
||||||
const fg = isDark ? '#fafafa' : '#1a1a1a';
|
// when they are given (a built-in dark theme isn't #0a0a0a); light mode
|
||||||
const link = isDark ? '#60a5fa' : '#1a73e8';
|
// stays on white like HTML mail.
|
||||||
|
const bg = isDark ? cssColor(darkColors?.background ?? '', '#0a0a0a') : '#ffffff';
|
||||||
|
const fg = isDark ? cssColor(darkColors?.text ?? '', '#fafafa') : '#1a1a1a';
|
||||||
|
const link = isDark ? cssColor(darkColors?.link ?? '', '#60a5fa') : '#1a73e8';
|
||||||
const fontFamily = font === 'mono'
|
const fontFamily = font === 'mono'
|
||||||
? 'ui-monospace, Menlo, Consolas, "SF Mono", monospace'
|
? 'ui-monospace, Menlo, Consolas, "SF Mono", monospace'
|
||||||
: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||||
@@ -976,7 +992,7 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
font-family: ${fontFamily};
|
font-family: ${fontFamily};
|
||||||
font-size: 14px;
|
font-size: ${fontPx}px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: ${fg};
|
color: ${fg};
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Mailbox } from '../api/types';
|
||||||
|
|
||||||
|
/** Roles a folder link may name instead of an id (webmail lib/deep-links.ts). */
|
||||||
|
const ALIASED_ROLES = new Set(['inbox', 'sent', 'drafts', 'trash', 'archive', 'junk']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The folder a `mail/folder/<ref>` link names: an exact id first, then a role
|
||||||
|
* alias (the own account's folder before a shared one's). Null when nothing in
|
||||||
|
* the loaded folders matches, a link to a folder that was deleted or lives in
|
||||||
|
* another account (webmail resolveFolderRef).
|
||||||
|
*/
|
||||||
|
export function resolveFolderRef(ref: string, mailboxes: readonly Mailbox[]): string | null {
|
||||||
|
if (mailboxes.some((m) => m.id === ref)) return ref;
|
||||||
|
if (ALIASED_ROLES.has(ref)) {
|
||||||
|
const byRole = mailboxes.find((m) => m.role === ref && !m.isShared)
|
||||||
|
?? mailboxes.find((m) => m.role === ref);
|
||||||
|
return byRole?.id ?? null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
/**
|
||||||
|
* HTML mail as plain text, for "Plain Text Only" reading and the reader's
|
||||||
|
* per-message "Show as plain text" when a message has no text/plain part of
|
||||||
|
* its own. Port of the webmail's lib/html-to-readable-text.ts.
|
||||||
|
*
|
||||||
|
* Layout tables become lines, headings and paragraphs keep a blank line
|
||||||
|
* around them, list items get a bullet, images are replaced by their alt text
|
||||||
|
* and a link keeps its target in brackets after its text, so nothing the
|
||||||
|
* formatted mail offered is lost.
|
||||||
|
*
|
||||||
|
* The webmail walks a DOMParser tree; React Native has no DOM, so this walks
|
||||||
|
* the tags in order with a stack of open elements and closes the elements an
|
||||||
|
* HTML parser would close on its own (a paragraph before a block, a list item
|
||||||
|
* before the next one, table cells and rows).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { decodeEntities } from './verification-code';
|
||||||
|
|
||||||
|
const BLOCK = new Set([
|
||||||
|
'ADDRESS', 'ARTICLE', 'ASIDE', 'BLOCKQUOTE', 'CENTER', 'DD', 'DETAILS', 'DIV', 'DL', 'DT', 'FIELDSET', 'FIGCAPTION',
|
||||||
|
'FIGURE', 'FOOTER', 'FORM', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER', 'HR', 'LI', 'MAIN', 'NAV', 'OL', 'P', 'PRE',
|
||||||
|
'SECTION', 'SUMMARY', 'TABLE', 'TBODY', 'TFOOT', 'THEAD', 'TR', 'UL',
|
||||||
|
]);
|
||||||
|
/** Blocks that get a blank line before and after, not just a line break. */
|
||||||
|
const PARAGRAPH = new Set(['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BLOCKQUOTE', 'PRE', 'UL', 'OL', 'TABLE', 'HR']);
|
||||||
|
const SKIP = new Set(['HEAD', 'STYLE', 'SCRIPT', 'TEMPLATE', 'NOSCRIPT', 'TITLE', 'META', 'LINK', 'SVG', 'OBJECT', 'IFRAME']);
|
||||||
|
/** Elements whose content is raw text up to their end tag. */
|
||||||
|
const RAW_TEXT = new Set(['SCRIPT', 'STYLE', 'TITLE', 'TEXTAREA', 'XMP', 'NOSCRIPT', 'IFRAME', 'NOEMBED', 'NOFRAMES']);
|
||||||
|
const VOID = new Set(['AREA', 'BASE', 'BR', 'COL', 'EMBED', 'HR', 'IMG', 'INPUT', 'LINK', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']);
|
||||||
|
|
||||||
|
/** Link targets worth writing out: web, mail and phone links. */
|
||||||
|
function shownHref(href: string | null): string | null {
|
||||||
|
if (!href) return null;
|
||||||
|
const trimmed = href.trim();
|
||||||
|
if (/^mailto:/i.test(trimmed)) return trimmed.slice(7).split('?')[0] || null;
|
||||||
|
if (/^(https?:|tel:)/i.test(trimmed)) return trimmed;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(text: string): string {
|
||||||
|
return text.replace(/[ \t\f\v\r\n\xa0]+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same address once with and once without the scheme or a trailing slash. */
|
||||||
|
function sameTarget(text: string, href: string): boolean {
|
||||||
|
const strip = (s: string) => s.trim().toLowerCase().replace(/^(https?:\/\/|mailto:|tel:)/, '').replace(/\/$/, '');
|
||||||
|
return strip(text) === strip(href);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOKEN = /<!--[\s\S]*?(?:-->|$)|<![^>]*>?|<\?[^>]*>?|<(\/?)([a-zA-Z][a-zA-Z0-9:-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g;
|
||||||
|
const ATTR = /([^\s=/>"']+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
|
||||||
|
|
||||||
|
function parseAttrs(raw: string): Map<string, string> {
|
||||||
|
const attrs = new Map<string, string>();
|
||||||
|
ATTR.lastIndex = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = ATTR.exec(raw)) !== null) {
|
||||||
|
const name = m[1].toLowerCase();
|
||||||
|
if (!attrs.has(name)) attrs.set(name, decodeEntities(m[2] ?? m[3] ?? m[4] ?? ''));
|
||||||
|
}
|
||||||
|
return attrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Frame {
|
||||||
|
tag: string;
|
||||||
|
/** Inside a skipped or hidden element: nothing is written. */
|
||||||
|
skip: boolean;
|
||||||
|
pre: boolean;
|
||||||
|
listDepth: number;
|
||||||
|
/** out.length when the element opened, for a link's text. */
|
||||||
|
start: number;
|
||||||
|
href: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open elements an element closes when it starts, as an HTML parser does. */
|
||||||
|
function closesOnOpen(open: string, tag: string): boolean {
|
||||||
|
if (open === 'P') return BLOCK.has(tag);
|
||||||
|
if (open === 'LI') return tag === 'LI';
|
||||||
|
if (open === 'DT' || open === 'DD') return tag === 'DT' || tag === 'DD';
|
||||||
|
if (open === 'TD' || open === 'TH') return tag === 'TD' || tag === 'TH' || tag === 'TR';
|
||||||
|
if (open === 'TR') return tag === 'TR';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Elements that stop the search for an element to close on open. */
|
||||||
|
const SCOPE = new Set(['UL', 'OL', 'TABLE', 'DL', 'BLOCKQUOTE', 'DIV', 'TD', 'TH']);
|
||||||
|
|
||||||
|
export function htmlToReadableText(html: string): string {
|
||||||
|
if (!html) return '';
|
||||||
|
const out: string[] = [];
|
||||||
|
// Pending line breaks, so runs of empty blocks collapse to one blank line.
|
||||||
|
let pendingBreaks = 0;
|
||||||
|
let atLineStart = true;
|
||||||
|
|
||||||
|
const breakLine = (count: number) => {
|
||||||
|
if (out.length === 0) return;
|
||||||
|
pendingBreaks = Math.max(pendingBreaks, count);
|
||||||
|
};
|
||||||
|
const write = (text: string) => {
|
||||||
|
if (!text) return;
|
||||||
|
if (pendingBreaks > 0) {
|
||||||
|
out.push('\n'.repeat(pendingBreaks));
|
||||||
|
pendingBreaks = 0;
|
||||||
|
atLineStart = true;
|
||||||
|
}
|
||||||
|
if (atLineStart) text = text.replace(/^ +/, '');
|
||||||
|
if (!text) return;
|
||||||
|
out.push(text);
|
||||||
|
atLineStart = text.endsWith('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
const stack: Frame[] = [];
|
||||||
|
const top = (): Frame | undefined => stack[stack.length - 1];
|
||||||
|
|
||||||
|
const close = (frame: Frame) => {
|
||||||
|
if (frame.skip) return;
|
||||||
|
const { tag } = frame;
|
||||||
|
if (tag === 'A') {
|
||||||
|
const text = out.slice(frame.start).join('').trim();
|
||||||
|
if (frame.href && !sameTarget(text, frame.href)) write(text ? ` (${frame.href})` : frame.href);
|
||||||
|
}
|
||||||
|
if (BLOCK.has(tag)) {
|
||||||
|
const gap = PARAGRAPH.has(tag) && !(tag === 'UL' || tag === 'OL') ? 2 : 1;
|
||||||
|
breakLine(tag === 'LI' ? 1 : gap);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const open = (tag: string, attrs: Map<string, string>) => {
|
||||||
|
// Close what this element implicitly ends (a <p> before a block, the
|
||||||
|
// previous <li>, table cells and rows).
|
||||||
|
for (let i = stack.length - 1; i >= 0; i--) {
|
||||||
|
const frameTag = stack[i].tag;
|
||||||
|
if (closesOnOpen(frameTag, tag)) {
|
||||||
|
while (stack.length > i) close(stack.pop()!);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (SCOPE.has(frameTag)) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = top();
|
||||||
|
const inherited = parent?.skip ?? false;
|
||||||
|
const hidden = attrs.has('hidden') || /display\s*:\s*none/i.test(attrs.get('style') ?? '');
|
||||||
|
const skip = inherited || SKIP.has(tag) || hidden;
|
||||||
|
const pre = parent?.pre ?? false;
|
||||||
|
const listDepth = parent?.listDepth ?? 0;
|
||||||
|
|
||||||
|
if (VOID.has(tag)) {
|
||||||
|
if (skip) return;
|
||||||
|
if (tag === 'BR') {
|
||||||
|
out.push('\n');
|
||||||
|
pendingBreaks = 0;
|
||||||
|
atLineStart = true;
|
||||||
|
} else if (tag === 'IMG' || (tag === 'INPUT' && attrs.get('type') === 'image')) {
|
||||||
|
const alt = normalize(attrs.get('alt') ?? '').trim();
|
||||||
|
if (alt) write(`[${alt}]`);
|
||||||
|
} else if (tag === 'HR') {
|
||||||
|
breakLine(2);
|
||||||
|
write('---');
|
||||||
|
breakLine(2);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!skip) {
|
||||||
|
const isBlock = BLOCK.has(tag);
|
||||||
|
const gap = PARAGRAPH.has(tag) && !(tag === 'UL' || tag === 'OL') ? 2 : 1;
|
||||||
|
if (isBlock) breakLine(listDepth > 0 && (tag === 'UL' || tag === 'OL') ? 1 : gap);
|
||||||
|
if (tag === 'LI') write(`${' '.repeat(Math.max(0, listDepth - 1))}• `);
|
||||||
|
if (tag === 'TD' || tag === 'TH') {
|
||||||
|
if (!atLineStart && pendingBreaks === 0) write(' ');
|
||||||
|
}
|
||||||
|
if (tag === 'BLOCKQUOTE') write('> ');
|
||||||
|
}
|
||||||
|
stack.push({
|
||||||
|
tag,
|
||||||
|
skip,
|
||||||
|
pre: pre || tag === 'PRE',
|
||||||
|
listDepth: tag === 'UL' || tag === 'OL' ? listDepth + 1 : listDepth,
|
||||||
|
start: out.length,
|
||||||
|
href: tag === 'A' ? shownHref(attrs.get('href') ?? null) : null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const end = (tag: string) => {
|
||||||
|
let i = stack.length - 1;
|
||||||
|
while (i >= 0 && stack[i].tag !== tag) i--;
|
||||||
|
if (i < 0) return; // a stray end tag
|
||||||
|
while (stack.length > i) close(stack.pop()!);
|
||||||
|
};
|
||||||
|
|
||||||
|
const text = (raw: string) => {
|
||||||
|
const frame = top();
|
||||||
|
if (frame?.skip || !raw) return;
|
||||||
|
const value = decodeEntities(raw);
|
||||||
|
write(frame?.pre ? value : normalize(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
TOKEN.lastIndex = 0;
|
||||||
|
let lower: string | undefined;
|
||||||
|
let pos = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = TOKEN.exec(html)) !== null) {
|
||||||
|
if (m.index > pos) text(html.slice(pos, m.index));
|
||||||
|
pos = TOKEN.lastIndex;
|
||||||
|
if (m[2] === undefined) continue; // comment, doctype, processing instruction
|
||||||
|
const tag = m[2].toUpperCase();
|
||||||
|
if (m[1]) {
|
||||||
|
end(tag);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
open(tag, parseAttrs(m[3]));
|
||||||
|
const selfClosing = /\/\s*$/.test(m[3]);
|
||||||
|
if (selfClosing && !VOID.has(tag)) end(tag);
|
||||||
|
if (RAW_TEXT.has(tag) && !selfClosing) {
|
||||||
|
// Raw text runs to the end tag: a "<" inside a script is not a tag.
|
||||||
|
lower ??= html.toLowerCase();
|
||||||
|
const endAt = lower.indexOf(`</${tag.toLowerCase()}`, pos);
|
||||||
|
const stop = endAt < 0 ? html.length : endAt;
|
||||||
|
if (!SKIP.has(tag)) text(html.slice(pos, stop));
|
||||||
|
pos = stop;
|
||||||
|
TOKEN.lastIndex = stop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pos < html.length) text(html.slice(pos));
|
||||||
|
while (stack.length > 0) close(stack.pop()!);
|
||||||
|
|
||||||
|
return out
|
||||||
|
.join('')
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.replace(/[ \t]+$/g, ''))
|
||||||
|
.join('\n')
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The text the reader shows for a message read as plain text: its own
|
||||||
|
* text/plain part when it has one, else its HTML turned into text (webmail
|
||||||
|
* email-viewer.tsx, 1e929b0a9). Null when there is no HTML to replace: a
|
||||||
|
* plain-text message reads as it is.
|
||||||
|
*/
|
||||||
|
export function plainTextReading(body: { html: string | null; text: string | null }): string | null {
|
||||||
|
if (!body.html) return null;
|
||||||
|
if (body.text?.trim()) return body.text;
|
||||||
|
return htmlToReadableText(body.html);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Attachment, Email } from '../api/types';
|
import type { Attachment, Email } from '../api/types';
|
||||||
|
import { isReportPart } from './attachment-display';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attachment chips for message-list rows (webmail 0e1c34cd, #1089).
|
* Attachment chips for message-list rows (webmail 0e1c34cd, #1089).
|
||||||
@@ -14,11 +15,23 @@ import type { Attachment, Email } from '../api/types';
|
|||||||
/**
|
/**
|
||||||
* The parts the sender actually attached. Anything marked inline or
|
* The parts the sender actually attached. Anything marked inline or
|
||||||
* referenced by a Content-ID (signature logos, spacers, tracking pixels) is
|
* referenced by a Content-ID (signature logos, spacers, tracking pixels) is
|
||||||
* dropped, as are parts without a filename, which can't be labelled or saved.
|
* dropped, as are parts without a filename, which can't be labelled or saved,
|
||||||
|
* and the report parts of a bounce or a read receipt.
|
||||||
*/
|
*/
|
||||||
export function realAttachments(attachments?: readonly Attachment[] | null): Attachment[] {
|
export function realAttachments(attachments?: readonly Attachment[] | null): Attachment[] {
|
||||||
if (!attachments?.length) return [];
|
if (!attachments?.length) return [];
|
||||||
return attachments.filter((a) => a.disposition !== 'inline' && !a.cid && !!a.name);
|
return attachments.filter((a) => a.disposition !== 'inline' && !a.cid && !!a.name && !isReportPart(a.type));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a list row shows the paperclip: the server's `hasAttachment` until
|
||||||
|
* the row's parts are loaded, then only when a real attachment is among them,
|
||||||
|
* so a message with nothing but inline images loses it (webmail
|
||||||
|
* components/email/attachment-chips.tsx).
|
||||||
|
*/
|
||||||
|
export function showsPaperclip(hasAttachment: boolean, attachments: readonly Attachment[] | undefined): boolean {
|
||||||
|
if (attachments === undefined) return hasAttachment;
|
||||||
|
return realAttachments(attachments).length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* What the message list shows, as one string: the folder, the search and the
|
||||||
|
* filters (a tag view is a keyword filter). The list scrolls back to the top
|
||||||
|
* when it changes, so another folder, tag or search doesn't open at the
|
||||||
|
* offset the last one was left at (webmail #1138).
|
||||||
|
*/
|
||||||
|
export function listViewKey(
|
||||||
|
currentMailboxId: string | null,
|
||||||
|
searchQuery: string,
|
||||||
|
filters: object,
|
||||||
|
): string {
|
||||||
|
return JSON.stringify([currentMailboxId, searchQuery.trim(), filters]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* Reduce motion inside a message body (webmail lib/mail-reading.ts
|
||||||
|
* MAIL_REDUCED_MOTION_CSS, 24a13cc1f, and lib/animated-images.ts,
|
||||||
|
* bb7869798): CSS animations and transitions stop, and each animated GIF,
|
||||||
|
* WebP or APNG shows a still frame with a Play button.
|
||||||
|
*
|
||||||
|
* The page allows no script of its own, so the pause runs as a script the
|
||||||
|
* body view injects; the frame check is written out as a string because the
|
||||||
|
* app's JS engine (Hermes) can't hand a function's source to the page.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Stops CSS animations and transitions in a mail. */
|
||||||
|
export const MAIL_REDUCED_MOTION_CSS = '*,*::before,*::after{animation-duration:0.01ms!important;animation-iteration-count:1!important;animation-delay:0s!important;transition-duration:0.01ms!important;transition-delay:0s!important;scroll-behavior:auto!important}';
|
||||||
|
|
||||||
|
/** Puts the reduce-motion style sheet into a built body document. */
|
||||||
|
export function withReducedMotion(html: string): string {
|
||||||
|
const style = `<style data-bulwark-reduced-motion>${MAIL_REDUCED_MOTION_CSS}</style>`;
|
||||||
|
const at = html.lastIndexOf('</body>');
|
||||||
|
return at < 0 ? html + style : html.slice(0, at) + style + html.slice(at);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `function isAnimatedImageData(bytes)`: whether image bytes hold more than
|
||||||
|
* one frame (GIF image descriptors, an APNG acTL chunk, the WebP animation
|
||||||
|
* flag). Shared by the page script and the tests.
|
||||||
|
*/
|
||||||
|
export const IS_ANIMATED_SOURCE = `function isAnimatedImageData(bytes) {
|
||||||
|
if (!bytes || bytes.length < 12) return false;
|
||||||
|
function ascii(start, length) {
|
||||||
|
var s = '';
|
||||||
|
for (var k = start; k < start + length && k < bytes.length; k++) s += String.fromCharCode(bytes[k]);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
var i;
|
||||||
|
if (ascii(0, 4) === 'GIF8') {
|
||||||
|
i = 13;
|
||||||
|
var flags = bytes[10];
|
||||||
|
if (flags & 0x80) i += 3 * (1 << ((flags & 0x07) + 1));
|
||||||
|
var frames = 0;
|
||||||
|
var skipSubBlocks = function () {
|
||||||
|
while (i < bytes.length && bytes[i] !== 0) i += bytes[i] + 1;
|
||||||
|
i += 1;
|
||||||
|
};
|
||||||
|
while (i < bytes.length) {
|
||||||
|
var block = bytes[i];
|
||||||
|
if (block === 0x2c) {
|
||||||
|
frames += 1;
|
||||||
|
if (frames > 1) return true;
|
||||||
|
var local = bytes[i + 9];
|
||||||
|
i += 10;
|
||||||
|
if (local & 0x80) i += 3 * (1 << ((local & 0x07) + 1));
|
||||||
|
i += 1;
|
||||||
|
skipSubBlocks();
|
||||||
|
} else if (block === 0x21) {
|
||||||
|
i += 2;
|
||||||
|
skipSubBlocks();
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (bytes[0] === 0x89 && ascii(1, 3) === 'PNG') {
|
||||||
|
i = 8;
|
||||||
|
while (i + 8 <= bytes.length) {
|
||||||
|
var length = ((bytes[i] << 24) | (bytes[i + 1] << 16) | (bytes[i + 2] << 8) | bytes[i + 3]) >>> 0;
|
||||||
|
var type = ascii(i + 4, 4);
|
||||||
|
if (type === 'acTL') return true;
|
||||||
|
if (type === 'IDAT' || type === 'IEND') return false;
|
||||||
|
i += 12 + length;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (ascii(0, 4) === 'RIFF' && ascii(8, 4) === 'WEBP') {
|
||||||
|
if (ascii(12, 4) === 'VP8X' && bytes.length > 20) return (bytes[20] & 0x02) !== 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
export interface PauseLabels {
|
||||||
|
play: string;
|
||||||
|
pause: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOGGLE_ATTR = 'data-bulwark-animation';
|
||||||
|
const STILL_ATTR = 'data-bulwark-still';
|
||||||
|
|
||||||
|
const PAUSE_STYLE = `[${TOGGLE_ATTR}-wrap]{position:relative;display:inline-block;max-width:100%;line-height:0}`
|
||||||
|
+ `canvas[${STILL_ATTR}]{max-width:100%;height:auto}`
|
||||||
|
+ `button[${TOGGLE_ATTR}]{position:absolute;top:6px;inset-inline-start:6px;z-index:1;min-width:32px;min-height:32px;padding:4px 10px;border:2px solid #fff;border-radius:999px;background:rgba(0,0,0,.75);color:#fff;font:600 13px/1.2 system-ui,sans-serif;animation:none!important;transition:none!important}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script that freezes the animated images of the body: each one is swapped
|
||||||
|
* for a canvas holding its first frame, with a Play button on top; Play shows
|
||||||
|
* the moving image again and the button becomes Pause. Inline images (data:
|
||||||
|
* URIs) are checked by their bytes; a remote image only by its name (.gif,
|
||||||
|
* .apng), since the page can't fetch it. Runs once per document.
|
||||||
|
*/
|
||||||
|
export function buildPauseAnimatedImagesScript(labels: PauseLabels): string {
|
||||||
|
const cfg = JSON.stringify({ play: labels.play, pause: labels.pause, style: PAUSE_STYLE });
|
||||||
|
return `
|
||||||
|
(function () {
|
||||||
|
if (document.__rnPauseImages || !document.body) return;
|
||||||
|
document.__rnPauseImages = true;
|
||||||
|
var C = ${cfg};
|
||||||
|
var T = '${TOGGLE_ATTR}';
|
||||||
|
var S = '${STILL_ATTR}';
|
||||||
|
${IS_ANIMATED_SOURCE}
|
||||||
|
function dataBytes(src) {
|
||||||
|
var m = /^data:[^,]*;base64,([\\s\\S]*)$/i.exec(src);
|
||||||
|
if (!m) return null;
|
||||||
|
try {
|
||||||
|
var bin = atob(m[1].replace(/\\s+/g, ''));
|
||||||
|
var out = new Uint8Array(bin.length);
|
||||||
|
for (var i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
} catch (e) { return null; }
|
||||||
|
}
|
||||||
|
function mayMove(src) {
|
||||||
|
if (/^data:/i.test(src)) return isAnimatedImageData(dataBytes(src));
|
||||||
|
if (/^https?:/i.test(src)) {
|
||||||
|
try { return /\\.(gif|apng)$/i.test(new URL(src).pathname); } catch (e) { return false; }
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function draw(img, canvas) {
|
||||||
|
var ctx = canvas.getContext && canvas.getContext('2d');
|
||||||
|
if (!ctx) return false;
|
||||||
|
canvas.width = img.naturalWidth;
|
||||||
|
canvas.height = img.naturalHeight;
|
||||||
|
try { ctx.drawImage(img, 0, 0); } catch (e) { return false; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function freeze(img) {
|
||||||
|
if (img.hasAttribute(S) || img.naturalWidth <= 1) return;
|
||||||
|
img.setAttribute(S, '');
|
||||||
|
if (!mayMove(img.currentSrc || img.getAttribute('src') || '')) return;
|
||||||
|
var canvas = document.createElement('canvas');
|
||||||
|
if (!draw(img, canvas)) return;
|
||||||
|
var width = img.getBoundingClientRect().width;
|
||||||
|
canvas.setAttribute(S, '');
|
||||||
|
['class', 'style'].forEach(function (a) { var v = img.getAttribute(a); if (v) canvas.setAttribute(a, v); });
|
||||||
|
if (width > 0) canvas.style.width = width + 'px';
|
||||||
|
var alt = (img.getAttribute('alt') || '').trim();
|
||||||
|
if (alt) { canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', alt); }
|
||||||
|
else canvas.setAttribute('aria-hidden', 'true');
|
||||||
|
var wrap = document.createElement('span');
|
||||||
|
wrap.setAttribute(T + '-wrap', '');
|
||||||
|
var button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.setAttribute(T, 'paused');
|
||||||
|
button.textContent = C.play;
|
||||||
|
img.parentNode && img.parentNode.insertBefore(wrap, img);
|
||||||
|
wrap.appendChild(button);
|
||||||
|
wrap.appendChild(canvas);
|
||||||
|
wrap.appendChild(img);
|
||||||
|
img.style.setProperty('display', 'none', 'important');
|
||||||
|
}
|
||||||
|
var style = document.createElement('style');
|
||||||
|
style.textContent = C.style;
|
||||||
|
(document.head || document.body).appendChild(style);
|
||||||
|
// Capture phase, so a button inside a linked image doesn't open the link.
|
||||||
|
document.addEventListener('click', function (event) {
|
||||||
|
var button = event.target && event.target.closest ? event.target.closest('button[' + T + ']') : null;
|
||||||
|
if (!button) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
var wrap = button.parentElement;
|
||||||
|
var canvas = wrap && wrap.querySelector('canvas[' + S + ']');
|
||||||
|
var img = wrap && wrap.querySelector('img');
|
||||||
|
if (!canvas || !img) return;
|
||||||
|
if (button.getAttribute(T) === 'paused') {
|
||||||
|
img.style.removeProperty('display');
|
||||||
|
canvas.style.setProperty('display', 'none');
|
||||||
|
button.setAttribute(T, 'playing');
|
||||||
|
button.textContent = C.pause;
|
||||||
|
} else {
|
||||||
|
draw(img, canvas);
|
||||||
|
canvas.style.removeProperty('display');
|
||||||
|
img.style.setProperty('display', 'none', 'important');
|
||||||
|
button.setAttribute(T, 'paused');
|
||||||
|
button.textContent = C.play;
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
var imgs = document.querySelectorAll('img');
|
||||||
|
for (var n = 0; n < imgs.length; n++) {
|
||||||
|
(function (img) {
|
||||||
|
if (img.complete) freeze(img);
|
||||||
|
else img.addEventListener('load', function () { freeze(img); }, { once: true });
|
||||||
|
})(imgs[n]);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
true;
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* How the app's text size reaches a message body (webmail lib/mail-reading.ts,
|
||||||
|
* c07ba1a37). The body renders in a WebView the app's styles don't reach, so
|
||||||
|
* the size is carried in: as the base font size of a plain-text body, and as
|
||||||
|
* a pass over the computed styles of an HTML body once it has laid out.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Size of body text in a message, in CSS px at 100 %: what mail used to be fixed at. */
|
||||||
|
export const MAIL_BASE_FONT_PX = 14;
|
||||||
|
|
||||||
|
const MIN_FACTOR = 0.5;
|
||||||
|
const MAX_FACTOR = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The factor message text is drawn at: the app's text size times the
|
||||||
|
* system font scale (Android font size, iOS Dynamic Type). Android's WebView
|
||||||
|
* would apply the system scale on its own; the body view turns that off
|
||||||
|
* (`textZoom` 100) so both platforms scale the same way, once.
|
||||||
|
*/
|
||||||
|
export function mailTextFactor(appFactor: number, systemFontScale: number): number {
|
||||||
|
const app = appFactor > 0 ? appFactor : 1;
|
||||||
|
const system = systemFontScale > 0 ? systemFontScale : 1;
|
||||||
|
const factor = Math.round(app * system * 100) / 100;
|
||||||
|
return Math.min(MAX_FACTOR, Math.max(MIN_FACTOR, factor));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The base font size of a body at `factor`, in CSS px. */
|
||||||
|
export function mailBaseFontPx(factor: number): number {
|
||||||
|
return Math.round(MAIL_BASE_FONT_PX * factor * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DONE_ATTR = 'data-bulwark-text-size';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script injected into an HTML body that scales its text by `factor`: reads
|
||||||
|
* every element's computed font size first and writes the scaled sizes after,
|
||||||
|
* pinning each element to its own px size so a child sized in em doesn't grow
|
||||||
|
* twice. A line height in px grows by the same ratio. Runs once per document
|
||||||
|
* (the body view injects its scripts again after load). Text size works on
|
||||||
|
* the text rather than zooming the whole mail, so a fixed-width newsletter
|
||||||
|
* keeps its width and its text wraps inside it. Empty at factor 1.
|
||||||
|
*/
|
||||||
|
export function buildMailTextSizeScript(factor: number): string {
|
||||||
|
if (!(factor > 0) || Math.abs(factor - 1) < 0.005) return '';
|
||||||
|
return `
|
||||||
|
(function () {
|
||||||
|
var F = ${JSON.stringify(factor)};
|
||||||
|
var body = document.body;
|
||||||
|
if (!body || body.hasAttribute('${DONE_ATTR}')) return;
|
||||||
|
body.setAttribute('${DONE_ATTR}', '');
|
||||||
|
var NO_TEXT = { IMG: 1, BR: 1, HR: 1, WBR: 1, PICTURE: 1, SOURCE: 1, VIDEO: 1, AUDIO: 1, CANVAS: 1, IFRAME: 1, OBJECT: 1, EMBED: 1, STYLE: 1, SCRIPT: 1, TEMPLATE: 1 };
|
||||||
|
var els = [body];
|
||||||
|
var all = body.querySelectorAll('*');
|
||||||
|
for (var i = 0; i < all.length; i++) {
|
||||||
|
var el = all[i];
|
||||||
|
if (NO_TEXT[el.tagName] || el.closest('svg')) continue;
|
||||||
|
els.push(el);
|
||||||
|
}
|
||||||
|
var sizes = [];
|
||||||
|
for (var j = 0; j < els.length; j++) {
|
||||||
|
var cs = window.getComputedStyle(els[j]);
|
||||||
|
var font = parseFloat(cs.fontSize);
|
||||||
|
if (!(font > 0)) continue;
|
||||||
|
var lh = /px$/.test(cs.lineHeight) ? parseFloat(cs.lineHeight) : null;
|
||||||
|
sizes.push([els[j], font * F, lh === null ? null : lh * F]);
|
||||||
|
}
|
||||||
|
for (var k = 0; k < sizes.length; k++) {
|
||||||
|
var s = sizes[k];
|
||||||
|
s[0].style.setProperty('font-size', Math.round(s[1] * 100) / 100 + 'px', 'important');
|
||||||
|
if (s[2] !== null) s[0].style.setProperty('line-height', Math.round(s[2] * 100) / 100 + 'px', 'important');
|
||||||
|
}
|
||||||
|
if (window.__rnReport) setTimeout(window.__rnReport, 0);
|
||||||
|
})();
|
||||||
|
true;
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Alert } from 'react-native';
|
||||||
|
import { useSettingsStore } from '../stores/settings-store';
|
||||||
|
|
||||||
|
type Translate = (key: string, fallback?: string, params?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
|
/** Shows the question; resolves true when the user went ahead. */
|
||||||
|
export type AskFn = (question: { title: string; message: string; confirmText: string; destructive: boolean; cancelText: string }) => Promise<boolean>;
|
||||||
|
|
||||||
|
const alertAsk: AskFn = ({ title, message, confirmText, destructive, cancelText }) => new Promise((resolve) => {
|
||||||
|
Alert.alert(
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
[
|
||||||
|
{ text: cancelText, style: 'cancel', onPress: () => resolve(false) },
|
||||||
|
{ text: confirmText, style: destructive ? 'destructive' : 'default', onPress: () => resolve(true) },
|
||||||
|
],
|
||||||
|
{ cancelable: true, onDismiss: () => resolve(false) },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks before messages are deleted or archived when the user turned on
|
||||||
|
* "Confirm before deleting" or "Confirm before archiving" (webmail
|
||||||
|
* lib/mail-removal-confirm.ts). Applies to the open message, the selection
|
||||||
|
* bar and swipes. Resolves true when the action may go ahead: the setting is
|
||||||
|
* off, or the user confirmed.
|
||||||
|
*
|
||||||
|
* A permanent delete has its own, stronger question; ask this one only when
|
||||||
|
* no other confirmation is shown, so nobody is asked twice.
|
||||||
|
*/
|
||||||
|
export async function confirmMailRemoval(
|
||||||
|
kind: 'delete' | 'archive',
|
||||||
|
count: number,
|
||||||
|
t: Translate,
|
||||||
|
ask: AskFn = alertAsk,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const settings = useSettingsStore.getState();
|
||||||
|
if (!(kind === 'delete' ? settings.confirmDelete : settings.confirmArchive)) return true;
|
||||||
|
if (count <= 0) return true;
|
||||||
|
const params = { count };
|
||||||
|
return kind === 'delete'
|
||||||
|
? ask({
|
||||||
|
title: t('email_list.confirm_remove.delete_title', '{count, plural, one {Delete this email?} other {Delete # emails?}}', params),
|
||||||
|
message: t('email_list.confirm_remove.delete_message', '{count, plural, one {It moves to the trash.} other {They move to the trash.}}', params),
|
||||||
|
confirmText: t('email_list.confirm_remove.delete', 'Delete', params),
|
||||||
|
cancelText: t('common.cancel', 'Cancel'),
|
||||||
|
destructive: true,
|
||||||
|
})
|
||||||
|
: ask({
|
||||||
|
title: t('email_list.confirm_remove.archive_title', '{count, plural, one {Archive this email?} other {Archive # emails?}}', params),
|
||||||
|
message: t('email_list.confirm_remove.archive_message', '{count, plural, one {It moves to your archive.} other {They move to your archive.}}', params),
|
||||||
|
confirmText: t('email_list.confirm_remove.archive', 'Archive', params),
|
||||||
|
cancelText: t('common.cancel', 'Cancel'),
|
||||||
|
destructive: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Email, Mailbox } from '../api/types';
|
||||||
|
|
||||||
|
/** Whether a list row sits in `mailbox` (a shared folder's id carries its account). */
|
||||||
|
function rowInMailbox(row: Pick<Email, 'mailboxIds' | 'jmapAccountId'>, mailbox: Mailbox): boolean {
|
||||||
|
if (row.jmapAccountId && mailbox.accountId && row.jmapAccountId !== mailbox.accountId) return false;
|
||||||
|
const raw = mailbox.originalId ?? mailbox.id;
|
||||||
|
return !!row.mailboxIds?.[raw] || !!row.mailboxIds?.[mailbox.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The folder the move sheet marks as current (checked, not pickable). The
|
||||||
|
* open folder only when every message being moved is already in it: a search
|
||||||
|
* across folders lists messages from other folders while the open one stays
|
||||||
|
* selected, and those can be moved into it (webmail lib/move-targets.ts,
|
||||||
|
* #1452). Null when it can be picked.
|
||||||
|
*/
|
||||||
|
export function moveSheetCurrentId(
|
||||||
|
mailboxes: readonly Mailbox[],
|
||||||
|
currentMailboxId: string | null,
|
||||||
|
rows: ReadonlyArray<Pick<Email, 'mailboxIds' | 'jmapAccountId'>>,
|
||||||
|
): string | null {
|
||||||
|
if (!currentMailboxId) return null;
|
||||||
|
const current = mailboxes.find((m) => m.id === currentMailboxId);
|
||||||
|
if (!current || rows.length === 0) return currentMailboxId;
|
||||||
|
return rows.every((row) => rowInMailbox(row, current)) ? currentMailboxId : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { KeywordColor, KeywordDef } from '../stores/keywords-store';
|
||||||
|
|
||||||
|
// "New tag" in the tag pickers (webmail components/email/tag-picker.tsx,
|
||||||
|
// 19676a14f; ids from lib/keyword-nesting.ts).
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Longest keyword Stalwart keeps (1.0 refuses a longer one, 0.16 cuts it),
|
||||||
|
* less the `$label:` prefix.
|
||||||
|
*/
|
||||||
|
export const MAX_KEYWORD_ID_LENGTH = 128 - '$label:'.length;
|
||||||
|
|
||||||
|
/** Palette row 1 of the webmail's tag colours: a new tag takes the next one. */
|
||||||
|
export const NEW_TAG_PALETTE: KeywordColor[] = [
|
||||||
|
'red', 'orange', 'amber', 'yellow', 'lime', 'green', 'teal', 'cyan', 'blue', 'indigo', 'purple', 'pink', 'gray',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One level of a tag id from a display name: lowercase, everything outside
|
||||||
|
* `[a-z0-9_-]` folded to a single dash (a slash typed into the name is part
|
||||||
|
* of the name, not a level).
|
||||||
|
*/
|
||||||
|
export function normalizeKeywordLevel(name: string): string {
|
||||||
|
return name
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9_-]/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The id a tag named `name` gets under `parentId` (null = top level). */
|
||||||
|
export function composeKeywordId(parentId: string | null, name: string): string {
|
||||||
|
const level = normalizeKeywordLevel(name);
|
||||||
|
if (!parentId || !level) return level;
|
||||||
|
return `${parentId}/${level}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NewTagError = 'tag_exists' | 'tag_name_invalid';
|
||||||
|
|
||||||
|
/** What a typed tag name would make: its id, and why it can't be made (null = it can). */
|
||||||
|
export function checkNewTag(name: string, keywords: readonly Pick<KeywordDef, 'id'>[]): { id: string; error: NewTagError | null } {
|
||||||
|
const id = composeKeywordId(null, name);
|
||||||
|
if (!name.trim()) return { id, error: null };
|
||||||
|
if (keywords.some((k) => k.id === id)) return { id, error: 'tag_exists' };
|
||||||
|
if (!id || id.length > MAX_KEYWORD_ID_LENGTH) return { id, error: 'tag_name_invalid' };
|
||||||
|
return { id, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The definition for a new tag, or null when the name can't make one. */
|
||||||
|
export function newTagDefinition(name: string, keywords: readonly Pick<KeywordDef, 'id'>[]): KeywordDef | null {
|
||||||
|
const { id, error } = checkNewTag(name, keywords);
|
||||||
|
if (!name.trim() || error) return null;
|
||||||
|
return { id, label: name.trim(), color: NEW_TAG_PALETTE[keywords.length % NEW_TAG_PALETTE.length] };
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// Message previews ready to show (webmail lib/utils.ts cleanPreview, #1136,
|
||||||
|
// #1260): some servers build the preview from an HTML body without skipping
|
||||||
|
// its <style>, so a marketing mail's preview began with CSS; preheaders pad
|
||||||
|
// with invisible characters; text/plain emphasis markers ("*bold*") show.
|
||||||
|
// Copied from the webmail as it is.
|
||||||
|
|
||||||
|
// Marketing emails pad the preheader with whitespace, format chars (soft
|
||||||
|
// hyphens, zero-width chars, BOM, directional marks) and combining marks
|
||||||
|
// (e.g. U+034F) to push real content past the preview window. Strip them all.
|
||||||
|
// \p{Cf} = Format, \p{Mn} = combining marks; \s covers figure space, NBSP, etc.
|
||||||
|
const LEADING_INVISIBLE_RE = /^[\s\p{Cf}\p{Mn}]+/u;
|
||||||
|
// After stripping, a server-side truncation indicator like "..." may be all
|
||||||
|
// that's left. Treat that as no preview so callers can fall back.
|
||||||
|
const ONLY_PUNCTUATION_RE = /^[.\u2026\s]+$/;
|
||||||
|
|
||||||
|
export function stripInvisibleLeading(text: string): string {
|
||||||
|
const stripped = text.replace(LEADING_INVISIBLE_RE, '');
|
||||||
|
if (ONLY_PUNCTUATION_RE.test(stripped)) return '';
|
||||||
|
return stripped;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A style sheet at the start of the text: an at-rule, or a selector opening a
|
||||||
|
// rule. Plain prose does not start like this - "@" or "#" followed by a word
|
||||||
|
// and then, before any sentence ends, a "{".
|
||||||
|
const DECLARATIONS_RE = /^\s*(?:[-\w]+\s*:|[.#@a-z*][^{}]*\{)/i;
|
||||||
|
// At-rule names are matched case-sensitively: style sheets write them in lower
|
||||||
|
// case, while "@Page …" at the start of a preview is a mention. A selector's
|
||||||
|
// colon is a pseudo-class ("a:hover"), never followed by a space as in
|
||||||
|
// "Reminder: your appointment {date: …}".
|
||||||
|
const AT_RULE_RE = /^@(?:media|font-face|import|supports|keyframes|charset|page)\b/;
|
||||||
|
const SELECTOR_RE = /^[.#]?[a-z*](?:[\w\-.#,>~+*\s[\]="']|:(?!\s))*\{/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops a style sheet from the start of a text preview.
|
||||||
|
*
|
||||||
|
* Some servers build the preview from an HTML body without skipping its
|
||||||
|
* <style> element, so a marketing mail's preview can begin with
|
||||||
|
* "@media screen and (min-width:600px){.hide{display:none!important;…" and
|
||||||
|
* never reach the text. Whole leading rules are removed; a preview that ends
|
||||||
|
* inside one was nothing but CSS and becomes empty, so callers fall back to
|
||||||
|
* their "no preview" text.
|
||||||
|
*/
|
||||||
|
export function stripLeadingCss(text: string): string {
|
||||||
|
let rest = text.trimStart();
|
||||||
|
for (let guard = 0; guard < 100 && (AT_RULE_RE.test(rest) || SELECTOR_RE.test(rest)); guard++) {
|
||||||
|
const brace = rest.indexOf('{');
|
||||||
|
const semicolon = rest.indexOf(';');
|
||||||
|
// `@import url(...);` and `@charset "x";` end without a block.
|
||||||
|
if (semicolon >= 0 && (brace < 0 || semicolon < brace) && /^@(?:import|charset)\b/.test(rest)) {
|
||||||
|
rest = rest.slice(semicolon + 1).trimStart();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// An at-rule word with no block after it ("@media team, …", "@page 3 …")
|
||||||
|
// starts prose, not a style sheet.
|
||||||
|
if (brace < 0) return rest;
|
||||||
|
let depth = 0;
|
||||||
|
let end = -1;
|
||||||
|
for (let i = brace; i < rest.length; i++) {
|
||||||
|
if (rest[i] === '{') depth++;
|
||||||
|
else if (rest[i] === '}' && --depth === 0) { end = i; break; }
|
||||||
|
}
|
||||||
|
if (end < 0) return rest.startsWith('@') || DECLARATIONS_RE.test(rest.slice(brace + 1)) ? '' : rest;
|
||||||
|
// A rule's block holds declarations ("color: red") or further rules; a
|
||||||
|
// brace in prose ("at {time} tomorrow") holds neither and ends the strip.
|
||||||
|
if (!rest.startsWith('@') && !DECLARATIONS_RE.test(rest.slice(brace + 1, end))) return rest;
|
||||||
|
rest = rest.slice(end + 1).trimStart();
|
||||||
|
}
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain-text emphasis: "*bold*", "**bold**", "_italic_", "__bold__". The
|
||||||
|
// markers sit at word edges, so "2 * 3 * 4", "a*b*c", "snake_case_name" and
|
||||||
|
// "__init__.py" are left alone.
|
||||||
|
const EMPHASIS_RE = /(^|[\s([{"'])(\*\*|__|\*|_)([^\s*_]|[^\s*_].*?[^\s])\2(?=$|[\s)\]}"']|[.,;:!?](?!\w))/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops plain-text emphasis markers. The server builds the preview from the
|
||||||
|
* text/plain part, where senders like GitHub write "*@user* mentioned you"
|
||||||
|
* while the HTML part shows it in bold.
|
||||||
|
*/
|
||||||
|
export function stripEmphasisMarkers(text: string): string {
|
||||||
|
return text.replace(EMPHASIS_RE, '$1$3');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A message preview ready to show: no leading padding, style sheet or emphasis markers. */
|
||||||
|
export function cleanPreview(text: string | null | undefined): string {
|
||||||
|
return stripEmphasisMarkers(stripInvisibleLeading(stripLeadingCss(stripInvisibleLeading(text ?? ''))));
|
||||||
|
}
|
||||||
+14
-16
@@ -92,24 +92,22 @@ export function buildQuoteCollapseScript(labels: QuoteCollapseLabels): string {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
function precedingTextEndsWithColon(el) {
|
// One walk in document order, remembering the last non-blank text: a
|
||||||
var walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
// blockquote is reached before its own text, so that is the text preceding
|
||||||
|
// it. Walking from the start again per blockquote took quadratic time and
|
||||||
|
// froze the reader on a mail full of blockquotes (webmail quote-collapse.ts).
|
||||||
|
function findAttributedBlockquote() {
|
||||||
|
var walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
|
||||||
var last = null;
|
var last = null;
|
||||||
for (var node = walker.nextNode(); node; node = walker.nextNode()) {
|
for (var node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||||
var rel = el.compareDocumentPosition(node);
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
if (rel & Node.DOCUMENT_POSITION_CONTAINED_BY) continue;
|
var text = (node.nodeValue || '').trim();
|
||||||
if (!(rel & Node.DOCUMENT_POSITION_PRECEDING)) break;
|
if (text !== '') last = text;
|
||||||
var text = (node.nodeValue || '').trim();
|
continue;
|
||||||
if (text !== '') last = text;
|
}
|
||||||
}
|
if (node.tagName !== 'BLOCKQUOTE') continue;
|
||||||
return last !== null && /:$/.test(last);
|
if (node.parentElement && node.parentElement.closest('blockquote')) continue;
|
||||||
}
|
if (last !== null && /:$/.test(last)) return node;
|
||||||
function findAttributedBlockquote() {
|
|
||||||
var bqs = body.querySelectorAll('blockquote');
|
|
||||||
for (var i = 0; i < bqs.length; i++) {
|
|
||||||
var bq = bqs[i];
|
|
||||||
if (bq.parentElement && bq.parentElement.closest('blockquote')) continue;
|
|
||||||
if (precedingTextEndsWithColon(bq)) return bq;
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Mailbox } from '../api/types';
|
||||||
|
import { buildMailboxTree, flattenVisible, type MailboxNode } from './mailbox-tree';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The folders the search panel's Folder picker offers: every folder (it was
|
||||||
|
* cut at twelve), in sidebar order with each subfolder right after its parent
|
||||||
|
* and its `depth` for indenting (webmail lib/search-scope-folders.ts). The
|
||||||
|
* open folder is left out: "This folder" picks it. A shared account's
|
||||||
|
* grouping row is no folder and is left out too.
|
||||||
|
*/
|
||||||
|
export function searchScopeFolders(mailboxes: Mailbox[], currentMailboxId: string | null): MailboxNode[] {
|
||||||
|
const tree = buildMailboxTree(mailboxes);
|
||||||
|
const all = new Set<string>();
|
||||||
|
const collect = (nodes: MailboxNode[]) => {
|
||||||
|
for (const n of nodes) {
|
||||||
|
all.add(n.id);
|
||||||
|
collect(n.children);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
collect(tree);
|
||||||
|
return flattenVisible(tree, all).filter((n) => !n.isAccountNode && n.id !== currentMailboxId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { isDefaultOrder, sanitizeSortLevels, type SortLevel } from './message-list-order';
|
||||||
|
|
||||||
|
// The Sort menu of the list header (webmail components/email/mail-list-menus.tsx,
|
||||||
|
// e3f1ccce7). It writes the "Message list order" setting, so Settings agrees.
|
||||||
|
// The app keeps oldest/newest in its own `mailSortAscending` switch (the
|
||||||
|
// header button flipped it), so Newest and Oldest set that and clear the order.
|
||||||
|
|
||||||
|
export type SortChoiceKey = 'newest' | 'oldest' | 'unread' | 'starred' | 'sender' | 'subject' | 'largest';
|
||||||
|
|
||||||
|
export const SORT_CHOICES: ReadonlyArray<{ key: SortChoiceKey; levels: SortLevel[]; ascending: boolean }> = [
|
||||||
|
{ key: 'newest', levels: [], ascending: false },
|
||||||
|
{ key: 'oldest', levels: [], ascending: true },
|
||||||
|
{ key: 'unread', levels: [{ criterion: 'unread', direction: 'desc' }], ascending: false },
|
||||||
|
{ key: 'starred', levels: [{ criterion: 'starred', direction: 'desc' }], ascending: false },
|
||||||
|
{ key: 'sender', levels: [{ criterion: 'from', direction: 'asc' }], ascending: false },
|
||||||
|
{ key: 'subject', levels: [{ criterion: 'subject', direction: 'asc' }], ascending: false },
|
||||||
|
{ key: 'largest', levels: [{ criterion: 'size', direction: 'desc' }], ascending: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The choice the open folder's order is, or null for an order only Settings makes. */
|
||||||
|
export function sortChoiceFor(levels: SortLevel[], ascending: boolean): SortChoiceKey | null {
|
||||||
|
const last = levels[levels.length - 1];
|
||||||
|
const core = levels.length > 1 && last.criterion === 'receivedAt' && last.direction === 'desc'
|
||||||
|
? levels.slice(0, -1)
|
||||||
|
: levels;
|
||||||
|
if (isDefaultOrder(core)) return ascending ? 'oldest' : 'newest';
|
||||||
|
if (core.length !== 1 || ascending) return null;
|
||||||
|
const match = SORT_CHOICES.find((choice) =>
|
||||||
|
choice.levels.length === 1
|
||||||
|
&& choice.levels[0].criterion === core[0].criterion
|
||||||
|
&& choice.levels[0].direction === core[0].direction);
|
||||||
|
return match?.key ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SortChoiceChanges {
|
||||||
|
messageListOrder: SortLevel[];
|
||||||
|
mailSortAscending: boolean;
|
||||||
|
/** Set when the order only applied to the Inbox and was picked in another folder. */
|
||||||
|
messageListOrderScope?: 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The settings a pick writes. Picked outside the Inbox while the order applies
|
||||||
|
* to the Inbox only, it applies to all folders, or the pick would do nothing.
|
||||||
|
*/
|
||||||
|
export function sortChoiceChanges(
|
||||||
|
key: SortChoiceKey,
|
||||||
|
scope: 'inbox' | 'all',
|
||||||
|
folderRole: string | null | undefined,
|
||||||
|
): SortChoiceChanges | null {
|
||||||
|
const choice = SORT_CHOICES.find((c) => c.key === key);
|
||||||
|
if (!choice) return null;
|
||||||
|
return {
|
||||||
|
messageListOrder: sanitizeSortLevels(choice.levels.map((level) => ({ ...level }))),
|
||||||
|
mailSortAscending: choice.ascending,
|
||||||
|
...(scope === 'inbox' && folderRole !== 'inbox' && choice.levels.length > 0 ? { messageListOrderScope: 'all' as const } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -178,3 +178,30 @@ export function expandThreadSelection(
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a conversation member can be replied to: not the user's own draft
|
||||||
|
* and not a message waiting for its scheduled send (webmail 8b29254a6,
|
||||||
|
* #1404).
|
||||||
|
*/
|
||||||
|
export function isReplyableMember(
|
||||||
|
email: Pick<Email, 'id' | 'keywords'>,
|
||||||
|
scheduledIds: ReadonlySet<string>,
|
||||||
|
): boolean {
|
||||||
|
return !email.keywords?.$draft && !scheduledIds.has(email.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The message the quick reply answers: the newest member that can be
|
||||||
|
* replied to, so it never quotes the user's own draft or scheduled reply.
|
||||||
|
* Null when there is none.
|
||||||
|
*/
|
||||||
|
export function quickReplyTarget<T extends Pick<Email, 'id' | 'keywords'>>(
|
||||||
|
members: readonly T[],
|
||||||
|
scheduledIds: ReadonlySet<string>,
|
||||||
|
): T | null {
|
||||||
|
for (let i = members.length - 1; i >= 0; i--) {
|
||||||
|
if (isReplyableMember(members[i], scheduledIds)) return members[i];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -363,7 +363,7 @@ const LETTER_ENTITIES: Record<string, string> = {
|
|||||||
upsih: 'ϒ', upsilon: 'υ', uuml: 'ü', xi: 'ξ', yacute: 'ý', yuml: 'ÿ', zeta: 'ζ',
|
upsih: 'ϒ', upsilon: 'υ', uuml: 'ü', xi: 'ξ', yacute: 'ý', yuml: 'ÿ', zeta: 'ζ',
|
||||||
};
|
};
|
||||||
|
|
||||||
function decodeEntities(text: string): string {
|
export function decodeEntities(text: string): string {
|
||||||
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);/gi, (m, ref: string) => {
|
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);/gi, (m, ref: string) => {
|
||||||
if (ref[0] === '#') {
|
if (ref[0] === '#') {
|
||||||
const cp = ref[1] === 'x' || ref[1] === 'X' ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1), 10);
|
const cp = ref[1] === 'x' || ref[1] === 'X' ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1), 10);
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { handleDeepLink, parseDeepLink } from '../linking';
|
||||||
|
import { usePendingMailFolder } from '../pending-mail-folder';
|
||||||
|
import { resolveFolderRef } from '../../lib/folder-ref';
|
||||||
|
import type { Mailbox } from '../../api/types';
|
||||||
|
|
||||||
|
// A `mail/folder/<ref>` link opened only the Mail tab; the folder it named
|
||||||
|
// was dropped (file 10, "/mail/folder/<ref> deep link opens only the tab").
|
||||||
|
|
||||||
|
const box = (id: string, role: string | null, isShared = false) => ({ id, name: id, role, isShared } as unknown as Mailbox);
|
||||||
|
|
||||||
|
describe('mail/folder deep link', () => {
|
||||||
|
it('parks the folder for the mail list', async () => {
|
||||||
|
const link = parseDeepLink('bulwarkmobile://mail/folder/archive');
|
||||||
|
const navigation = { isReady: () => true, navigate: vi.fn() };
|
||||||
|
expect(await handleDeepLink(link!, { navigation: navigation as never, resolveThreadId: async () => null })).toBe(true);
|
||||||
|
expect(usePendingMailFolder.getState().consume()).toBe('archive');
|
||||||
|
expect(navigation.navigate).toHaveBeenCalledWith('MainTabs', { screen: 'Mail' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves an id or a role, the own folder first, and nothing for an unknown one', () => {
|
||||||
|
const boxes = [box('g:in', 'inbox', true), box('in', 'inbox'), box('f1', null)];
|
||||||
|
expect(resolveFolderRef('f1', boxes)).toBe('f1');
|
||||||
|
expect(resolveFolderRef('inbox', boxes)).toBe('in');
|
||||||
|
expect(resolveFolderRef('trash', boxes)).toBeNull();
|
||||||
|
expect(resolveFolderRef('gone', boxes)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is opened by the mail list', () => {
|
||||||
|
const list = fs.readFileSync(path.resolve(__dirname, '../../screens/EmailListScreen.tsx'), 'utf8');
|
||||||
|
expect(list).toMatch(/const id = ref === null \? null : resolveFolderRef\(ref, mailboxes\);/);
|
||||||
|
expect(list).toMatch(/if \(id !== currentMailboxId\) void selectMailbox\(id\);/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ import type { RootStackParamList } from './types';
|
|||||||
import { setPendingSettingsTab } from './pending-settings-tab';
|
import { setPendingSettingsTab } from './pending-settings-tab';
|
||||||
import { setPendingCalendarOpen } from './pending-calendar-open';
|
import { setPendingCalendarOpen } from './pending-calendar-open';
|
||||||
import { setPendingMailSearch } from './pending-mail-search';
|
import { setPendingMailSearch } from './pending-mail-search';
|
||||||
|
import { setPendingMailFolder } from './pending-mail-folder';
|
||||||
import { setPendingSignInLink, usePendingSignInLinkStore } from './pending-sign-in-link';
|
import { setPendingSignInLink, usePendingSignInLinkStore } from './pending-sign-in-link';
|
||||||
import { insecurePairingLinkError, parseQrLoginPayload, type QrLoginPayload } from '../lib/oauth';
|
import { insecurePairingLinkError, parseQrLoginPayload, type QrLoginPayload } from '../lib/oauth';
|
||||||
import { MAX_ACCOUNTS } from '../lib/account-utils';
|
import { MAX_ACCOUNTS } from '../lib/account-utils';
|
||||||
@@ -283,6 +284,8 @@ export async function handleDeepLink(link: DeepLink, nav: DeepLinkNavigator): Pr
|
|||||||
navigation.navigate('MainTabs', { screen: 'Mail' } as never);
|
navigation.navigate('MainTabs', { screen: 'Mail' } as never);
|
||||||
return true;
|
return true;
|
||||||
case 'folder':
|
case 'folder':
|
||||||
|
// The mail list opens the folder once its folders are loaded.
|
||||||
|
setPendingMailFolder(link.ref);
|
||||||
navigation.navigate('MainTabs', { screen: 'Mail' } as never);
|
navigation.navigate('MainTabs', { screen: 'Mail' } as never);
|
||||||
return true;
|
return true;
|
||||||
case 'calendar':
|
case 'calendar':
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
// A `mail/folder/<ref>` deep link arrives before the mail list is mounted or
|
||||||
|
// its folders are loaded. The reference is parked here and consumed by
|
||||||
|
// EmailListScreen, which opens the folder once it can resolve it.
|
||||||
|
interface PendingMailFolderState {
|
||||||
|
ref: string | null;
|
||||||
|
set: (ref: string | null) => void;
|
||||||
|
consume: () => string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePendingMailFolder = create<PendingMailFolderState>((set, get) => ({
|
||||||
|
ref: null,
|
||||||
|
set: (ref) => set({ ref }),
|
||||||
|
consume: () => {
|
||||||
|
const ref = get().ref;
|
||||||
|
if (ref !== null) set({ ref: null });
|
||||||
|
return ref;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function setPendingMailFolder(ref: string | null): void {
|
||||||
|
usePendingMailFolder.getState().set(ref);
|
||||||
|
}
|
||||||
+157
-32
@@ -7,8 +7,11 @@ import {
|
|||||||
Search, SquarePen, Menu, Filter, Square, SquareCheck, Minus, X,
|
Search, SquarePen, Menu, Filter, Square, SquareCheck, Minus, X,
|
||||||
Star, Paperclip, Mail as MailIcon, MailOpen, Trash2, RotateCcw, CalendarDays,
|
Star, Paperclip, Mail as MailIcon, MailOpen, Trash2, RotateCcw, CalendarDays,
|
||||||
Archive, FolderInput, Tag, Import, ArrowDownWideNarrow, ArrowUpNarrowWide,
|
Archive, FolderInput, Tag, Import, ArrowDownWideNarrow, ArrowUpNarrowWide,
|
||||||
Pin, Reply, Forward, ShieldAlert, ShieldCheck, Folder,
|
Pin, Reply, Forward, ShieldAlert, ShieldCheck, Folder, ArrowUpDown, Check,
|
||||||
} from '../components/icons';
|
} from '../components/icons';
|
||||||
|
import { ActionSheet } from '../components/email/ActionSheet';
|
||||||
|
import { SORT_CHOICES, sortChoiceChanges, sortChoiceFor, type SortChoiceKey } from '../lib/sort-choices';
|
||||||
|
import { orderForMailbox, sanitizeSortLevels } from '../lib/message-list-order';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { spacing, radius, typography, componentSizes, mix, type ThemePalette } from '../theme/tokens';
|
import { spacing, radius, typography, componentSizes, mix, type ThemePalette } from '../theme/tokens';
|
||||||
@@ -21,9 +24,11 @@ import { MoveSheet } from '../components/MoveSheet';
|
|||||||
import { TagSheet } from '../components/TagSheet';
|
import { TagSheet } from '../components/TagSheet';
|
||||||
import { OfflineBanner } from '../components/OfflineBanner';
|
import { OfflineBanner } from '../components/OfflineBanner';
|
||||||
import {
|
import {
|
||||||
ListAttachmentChips, ListAttachmentOpener, useListRowAttachments,
|
ListAttachmentChips, ListAttachmentOpener, useListAttachments, useListRowAttachments,
|
||||||
} from '../components/email/ListAttachmentChips';
|
} from '../components/email/ListAttachmentChips';
|
||||||
|
import { showsPaperclip } from '../lib/list-attachments';
|
||||||
import { VerificationCodeChip, useListVerificationCode } from '../components/email/VerificationCodeChip';
|
import { VerificationCodeChip, useListVerificationCode } from '../components/email/VerificationCodeChip';
|
||||||
|
import { useSenderWarning } from '../components/email/use-sender-warning';
|
||||||
import type { LoadListAttachments } from '../lib/list-attachments';
|
import type { LoadListAttachments } from '../lib/list-attachments';
|
||||||
import { useNetworkStore } from '../stores/network-store';
|
import { useNetworkStore } from '../stores/network-store';
|
||||||
import {
|
import {
|
||||||
@@ -47,6 +52,7 @@ import { toast } from '../stores/toast-store';
|
|||||||
import { getContactDisplayName } from '../lib/contact-utils';
|
import { getContactDisplayName } from '../lib/contact-utils';
|
||||||
import { formatListDate } from '../lib/date-format';
|
import { formatListDate } from '../lib/date-format';
|
||||||
import { singleLine } from '../lib/single-line';
|
import { singleLine } from '../lib/single-line';
|
||||||
|
import { cleanPreview } from '../lib/preview-text';
|
||||||
import {
|
import {
|
||||||
findTrashMailbox, findArchiveMailbox, findJunkMailbox, mailboxesForSiblingOf, ownMailboxes,
|
findTrashMailbox, findArchiveMailbox, findJunkMailbox, mailboxesForSiblingOf, ownMailboxes,
|
||||||
} from '../lib/mailbox-tree';
|
} from '../lib/mailbox-tree';
|
||||||
@@ -55,10 +61,16 @@ import {
|
|||||||
collapseThreads, groupByThread, expandThreadSelection, getThreadTagIds, threadKeyOf, accountScopedId, rowKeyOf,
|
collapseThreads, groupByThread, expandThreadSelection, getThreadTagIds, threadKeyOf, accountScopedId, rowKeyOf,
|
||||||
} from '../lib/thread-utils';
|
} from '../lib/thread-utils';
|
||||||
import { isPermanentDelete, confirmPermanentDelete } from '../lib/delete-confirm';
|
import { isPermanentDelete, confirmPermanentDelete } from '../lib/delete-confirm';
|
||||||
|
import { confirmMailRemoval } from '../lib/mail-removal-confirm';
|
||||||
|
import { moveSheetCurrentId } from '../lib/move-targets';
|
||||||
|
import { listViewKey } from '../lib/list-view-key';
|
||||||
|
import { searchScopeFolders } from '../lib/search-scope-folders';
|
||||||
import { draftContextFromEmail, isDraftEmail } from '../lib/draft-context';
|
import { draftContextFromEmail, isDraftEmail } from '../lib/draft-context';
|
||||||
import { getFullEmail, emptyMailbox as apiEmptyMailbox } from '../api/email';
|
import { getFullEmail, emptyMailbox as apiEmptyMailbox } from '../api/email';
|
||||||
import type { RootStackParamList } from '../navigation/types';
|
import type { RootStackParamList } from '../navigation/types';
|
||||||
import { usePendingMailSearch } from '../navigation/pending-mail-search';
|
import { usePendingMailSearch } from '../navigation/pending-mail-search';
|
||||||
|
import { usePendingMailFolder } from '../navigation/pending-mail-folder';
|
||||||
|
import { resolveFolderRef } from '../lib/folder-ref';
|
||||||
import type { Attachment, Email } from '../api/types';
|
import type { Attachment, Email } from '../api/types';
|
||||||
import { createFrameMonitor, logModuleTimes, perfOnce, perfStart } from '../lib/perf-marks';
|
import { createFrameMonitor, logModuleTimes, perfOnce, perfStart } from '../lib/perf-marks';
|
||||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||||
@@ -100,6 +112,19 @@ function isPinned(email: Email): boolean {
|
|||||||
return !!email.keywords?.$pinned;
|
return !!email.keywords?.$pinned;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Written out so the string harvest finds every key.
|
||||||
|
function sortChoiceLabel(key: SortChoiceKey, t: (key: string, fallback?: string) => string): string {
|
||||||
|
switch (key) {
|
||||||
|
case 'newest': return t('email_list.sort.newest', 'Newest first');
|
||||||
|
case 'oldest': return t('email_list.sort.oldest', 'Oldest first');
|
||||||
|
case 'unread': return t('email_list.sort.unread', 'Unread first');
|
||||||
|
case 'starred': return t('email_list.sort.starred', 'Starred first');
|
||||||
|
case 'sender': return t('email_list.sort.sender', 'Sender (A–Z)');
|
||||||
|
case 'subject': return t('email_list.sort.subject', 'Subject (A–Z)');
|
||||||
|
case 'largest': return t('email_list.sort.largest', 'Largest first');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Height of the sender line when the avatar is hidden (extra-compact density),
|
// Height of the sender line when the avatar is hidden (extra-compact density),
|
||||||
// used to anchor the unread dot on that first line.
|
// used to anchor the unread dot on that first line.
|
||||||
const UNREAD_DOT_TEXT_LINE = 20;
|
const UNREAD_DOT_TEXT_LINE = 20;
|
||||||
@@ -176,6 +201,11 @@ const EmailRow = React.memo(function EmailRow({
|
|||||||
const handlePress = React.useCallback(() => onPress(key), [onPress, key]);
|
const handlePress = React.useCallback(() => onPress(key), [onPress, key]);
|
||||||
const handleLongPress = React.useCallback(() => onLongPress(key), [onLongPress, key]);
|
const handleLongPress = React.useCallback(() => onLongPress(key), [onLongPress, key]);
|
||||||
const verificationCode = useListVerificationCode(item);
|
const verificationCode = useListVerificationCode(item);
|
||||||
|
// The row's parts, loaded once it is on screen: the chips show them and
|
||||||
|
// the paperclip goes when they are only inline images.
|
||||||
|
const attachments = useListAttachments(item, onOpenAttachment ? loadAttachments : undefined);
|
||||||
|
// Sent and Drafts rows show the recipient: the check says nothing about them.
|
||||||
|
const senderWarning = useSenderWarning(showRecipient ? undefined : item);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -210,12 +240,27 @@ const EmailRow = React.memo(function EmailRow({
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{density.showAvatar && (
|
{density.showAvatar && (
|
||||||
<SenderAvatar
|
// A tap on the avatar picks the row and starts selection mode, like
|
||||||
name={senderName}
|
// a long press (#91).
|
||||||
email={senderEmail}
|
<Pressable
|
||||||
size={componentSizes.avatarMd}
|
onPress={handleLongPress}
|
||||||
disableImages={disableAvatarImages}
|
hitSlop={4}
|
||||||
/>
|
accessibilityRole="checkbox"
|
||||||
|
accessibilityState={{ checked: selected }}
|
||||||
|
accessibilityLabel={tr('email_list.batch_actions.select_row', 'Select {sender}: {subject}', {
|
||||||
|
sender: senderName,
|
||||||
|
subject: singleLine(item.subject) || tr('email_viewer.no_subject', '(No Subject)'),
|
||||||
|
})}
|
||||||
|
testID="email-row-avatar"
|
||||||
|
>
|
||||||
|
<SenderAvatar
|
||||||
|
name={senderName}
|
||||||
|
email={senderEmail}
|
||||||
|
size={componentSizes.avatarMd}
|
||||||
|
disableImages={disableAvatarImages}
|
||||||
|
senderWarning={senderWarning}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
@@ -238,7 +283,7 @@ const EmailRow = React.memo(function EmailRow({
|
|||||||
{forwarded && (
|
{forwarded && (
|
||||||
<Forward size={componentSizes.statusIcon} color={c.textMuted} />
|
<Forward size={componentSizes.statusIcon} color={c.textMuted} />
|
||||||
)}
|
)}
|
||||||
{item.hasAttachment && (
|
{showsPaperclip(item.hasAttachment, attachments) && (
|
||||||
<Paperclip size={componentSizes.statusIcon} color={c.textMuted} />
|
<Paperclip size={componentSizes.statusIcon} color={c.textMuted} />
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -271,14 +316,15 @@ const EmailRow = React.memo(function EmailRow({
|
|||||||
{/* Row 3: Preview - hidden in compact density modes regardless of toggle */}
|
{/* Row 3: Preview - hidden in compact density modes regardless of toggle */}
|
||||||
{showPreview && density.showPreview && (
|
{showPreview && density.showPreview && (
|
||||||
<Text style={[styles.emailPreview, dyn.body]} numberOfLines={2}>
|
<Text style={[styles.emailPreview, dyn.body]} numberOfLines={2}>
|
||||||
{singleLine(item.preview)}
|
{singleLine(cleanPreview(item.preview))}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{verificationCode && <VerificationCodeChip code={verificationCode} disabled={selectionMode} inList />}
|
{verificationCode && <VerificationCodeChip code={verificationCode} disabled={selectionMode} inList />}
|
||||||
{onOpenAttachment && (
|
{onOpenAttachment && (
|
||||||
<ListAttachmentChips
|
<ListAttachmentChips
|
||||||
email={item}
|
email={item}
|
||||||
load={loadAttachments}
|
attachments={attachments}
|
||||||
|
pending={!!loadAttachments && item.hasAttachment && !item.attachments}
|
||||||
onOpen={onOpenAttachment}
|
onOpen={onOpenAttachment}
|
||||||
disabled={selectionMode}
|
disabled={selectionMode}
|
||||||
/>
|
/>
|
||||||
@@ -419,6 +465,13 @@ export default function EmailListScreen({
|
|||||||
const currentMailboxId = useEmailStore((s) => s.currentMailboxId);
|
const currentMailboxId = useEmailStore((s) => s.currentMailboxId);
|
||||||
const storeSearchQuery = useEmailStore((s) => s.searchQuery);
|
const storeSearchQuery = useEmailStore((s) => s.searchQuery);
|
||||||
const filters = useEmailStore((s) => s.filters);
|
const filters = useEmailStore((s) => s.filters);
|
||||||
|
// Another folder, tag or search starts at the top of its list, not at the
|
||||||
|
// offset the last one was scrolled to (webmail #1138).
|
||||||
|
const listRef = React.useRef<FlatList<Email>>(null);
|
||||||
|
const viewKey = listViewKey(currentMailboxId, storeSearchQuery, filters);
|
||||||
|
React.useEffect(() => {
|
||||||
|
listRef.current?.scrollToOffset({ offset: 0, animated: false });
|
||||||
|
}, [viewKey]);
|
||||||
const accountErrors = useEmailStore((s) => s.accountErrors);
|
const accountErrors = useEmailStore((s) => s.accountErrors);
|
||||||
const fetchMailboxes = useEmailStore((s) => s.fetchMailboxes);
|
const fetchMailboxes = useEmailStore((s) => s.fetchMailboxes);
|
||||||
const ensureMailboxes = useEmailStore((s) => s.ensureMailboxes);
|
const ensureMailboxes = useEmailStore((s) => s.ensureMailboxes);
|
||||||
@@ -440,7 +493,7 @@ export default function EmailListScreen({
|
|||||||
const moveEmailsToMailbox = useEmailStore((s) => s.moveEmailsToMailbox);
|
const moveEmailsToMailbox = useEmailStore((s) => s.moveEmailsToMailbox);
|
||||||
const deleteEmailsBatch = useEmailStore((s) => s.deleteEmailsBatch);
|
const deleteEmailsBatch = useEmailStore((s) => s.deleteEmailsBatch);
|
||||||
const setKeywordForEmails = useEmailStore((s) => s.setKeywordForEmails);
|
const setKeywordForEmails = useEmailStore((s) => s.setKeywordForEmails);
|
||||||
const setSortAscending = useEmailStore((s) => s.setSortAscending);
|
const invalidateListOrder = useEmailStore((s) => s.invalidateListOrder);
|
||||||
const markSpam = useEmailStore((s) => s.markSpam);
|
const markSpam = useEmailStore((s) => s.markSpam);
|
||||||
const unmarkSpam = useEmailStore((s) => s.unmarkSpam);
|
const unmarkSpam = useEmailStore((s) => s.unmarkSpam);
|
||||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||||
@@ -475,6 +528,15 @@ export default function EmailListScreen({
|
|||||||
[mailboxes, currentMailboxId],
|
[mailboxes, currentMailboxId],
|
||||||
);
|
);
|
||||||
const currentRole = currentMailbox?.role ?? null;
|
const currentRole = currentMailbox?.role ?? null;
|
||||||
|
// The Sort menu of the header (webmail e3f1ccce7): the order presets of
|
||||||
|
// Settings > Layout, one tap away.
|
||||||
|
const [sortMenuOpen, setSortMenuOpen] = React.useState(false);
|
||||||
|
const messageListOrder = useSettingsStore((s) => s.messageListOrder);
|
||||||
|
const messageListOrderScope = useSettingsStore((s) => s.messageListOrderScope);
|
||||||
|
const currentSortChoice = sortChoiceFor(
|
||||||
|
orderForMailbox(sanitizeSortLevels(messageListOrder), messageListOrderScope, currentRole),
|
||||||
|
sortAscending,
|
||||||
|
);
|
||||||
// The JMAP account behind the open folder (undefined = the user's own).
|
// The JMAP account behind the open folder (undefined = the user's own).
|
||||||
const currentOwnerAccountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined;
|
const currentOwnerAccountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined;
|
||||||
|
|
||||||
@@ -721,7 +783,11 @@ export default function EmailListScreen({
|
|||||||
deleteAction,
|
deleteAction,
|
||||||
permanentlyDeleteJunk,
|
permanentlyDeleteJunk,
|
||||||
});
|
});
|
||||||
if (permanent && !(await confirmPermanentDelete(ids.length, t))) return;
|
if (permanent) {
|
||||||
|
if (!(await confirmPermanentDelete(ids.length, t))) return;
|
||||||
|
} else if (!(await confirmMailRemoval('delete', ids.length, t))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (ids.length === 1) await deleteEmailAction(ids[0], trashMailboxId, currentMailboxId);
|
if (ids.length === 1) await deleteEmailAction(ids[0], trashMailboxId, currentMailboxId);
|
||||||
else await deleteEmailsBatch(ids, trashMailboxId, currentMailboxId);
|
else await deleteEmailsBatch(ids, trashMailboxId, currentMailboxId);
|
||||||
}, [currentMailboxId, trashMailboxId, inJunk, deleteAction, permanentlyDeleteJunk, deleteEmailAction, deleteEmailsBatch, t]);
|
}, [currentMailboxId, trashMailboxId, inJunk, deleteAction, permanentlyDeleteJunk, deleteEmailAction, deleteEmailsBatch, t]);
|
||||||
@@ -734,8 +800,10 @@ export default function EmailListScreen({
|
|||||||
case 'archive':
|
case 'archive':
|
||||||
if (archiveMailboxId && currentMailboxId !== archiveMailboxId) {
|
if (archiveMailboxId && currentMailboxId !== archiveMailboxId) {
|
||||||
const ids = idsForRow(id);
|
const ids = idsForRow(id);
|
||||||
if (ids.length > 1) reportFailure(archiveEmailsBatch(ids));
|
reportFailure(confirmMailRemoval('archive', ids.length, t).then((ok) => {
|
||||||
else reportFailure(archiveEmailAction(id));
|
if (!ok) return undefined;
|
||||||
|
return ids.length > 1 ? archiveEmailsBatch(ids) : archiveEmailAction(id);
|
||||||
|
}));
|
||||||
} else if (!archiveMailboxId) {
|
} else if (!archiveMailboxId) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t('email_list.error', 'Error'),
|
t('email_list.error', 'Error'),
|
||||||
@@ -857,6 +925,14 @@ export default function EmailListScreen({
|
|||||||
() => expandThreadSelection(selectedIds, emails, disableThreading),
|
() => expandThreadSelection(selectedIds, emails, disableThreading),
|
||||||
[selectedIds, emails, disableThreading],
|
[selectedIds, emails, disableThreading],
|
||||||
);
|
);
|
||||||
|
// The open folder can't be picked in the move sheets only when the rows
|
||||||
|
// being moved are all in it: search hits from other folders can move into
|
||||||
|
// it (#1452).
|
||||||
|
const pendingMoveCurrentId = React.useMemo(() => {
|
||||||
|
if (!pendingMoveId) return currentMailboxId;
|
||||||
|
const wanted = new Set(expandThreadSelection([pendingMoveId], emails, disableThreading));
|
||||||
|
return moveSheetCurrentId(mailboxes, currentMailboxId, emails.filter((e) => wanted.has(rowKeyOf(e))));
|
||||||
|
}, [pendingMoveId, emails, disableThreading, mailboxes, currentMailboxId]);
|
||||||
const selectedEmails = React.useMemo(() => {
|
const selectedEmails = React.useMemo(() => {
|
||||||
const wanted = new Set(selectedMessageIds);
|
const wanted = new Set(selectedMessageIds);
|
||||||
return emails.filter((e) => wanted.has(rowKeyOf(e)));
|
return emails.filter((e) => wanted.has(rowKeyOf(e)));
|
||||||
@@ -900,12 +976,17 @@ export default function EmailListScreen({
|
|||||||
deleteAction,
|
deleteAction,
|
||||||
permanentlyDeleteJunk,
|
permanentlyDeleteJunk,
|
||||||
});
|
});
|
||||||
if (permanent && !(await confirmPermanentDelete(ids.length, t))) return;
|
if (permanent) {
|
||||||
|
if (!(await confirmPermanentDelete(ids.length, t))) return;
|
||||||
|
} else if (!(await confirmMailRemoval('delete', ids.length, t))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await deleteEmailsBatch(ids, trash.id, currentMailboxId);
|
await deleteEmailsBatch(ids, trash.id, currentMailboxId);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBulkArchive = async () => {
|
const handleBulkArchive = async () => {
|
||||||
|
if (!(await confirmMailRemoval('archive', selectedMessageIds.length, t))) return;
|
||||||
await archiveEmailsBatch(selectedMessageIds);
|
await archiveEmailsBatch(selectedMessageIds);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
};
|
};
|
||||||
@@ -1127,6 +1208,18 @@ export default function EmailListScreen({
|
|||||||
}
|
}
|
||||||
}, [ensureMailboxes, mailboxes.length]);
|
}, [ensureMailboxes, mailboxes.length]);
|
||||||
|
|
||||||
|
// A `mail/folder/<ref>` link opens its folder once the folders are here;
|
||||||
|
// one that names no folder of this account leaves the list as it is.
|
||||||
|
const pendingFolder = usePendingMailFolder((s) => s.ref);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (pendingFolder === null || mailboxes.length === 0) return;
|
||||||
|
const ref = usePendingMailFolder.getState().consume();
|
||||||
|
const id = ref === null ? null : resolveFolderRef(ref, mailboxes);
|
||||||
|
if (!id) return;
|
||||||
|
clearSearchAndFilters();
|
||||||
|
if (id !== currentMailboxId) void selectMailbox(id);
|
||||||
|
}, [pendingFolder, mailboxes, currentMailboxId, selectMailbox, clearSearchAndFilters]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (mailboxes.length > 0 && !currentMailboxId) {
|
if (mailboxes.length > 0 && !currentMailboxId) {
|
||||||
const own = ownMailboxes(mailboxes);
|
const own = ownMailboxes(mailboxes);
|
||||||
@@ -1190,17 +1283,17 @@ export default function EmailListScreen({
|
|||||||
</View>
|
</View>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.filterButton}
|
style={styles.filterButton}
|
||||||
onPress={() => setSortAscending(!sortAscending)}
|
onPress={() => setSortMenuOpen(true)}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={sortAscending
|
accessibilityLabel={t('email_list.sort.title', 'Sort')}
|
||||||
? t('settings.appearance.message_list_order.direction.oldest_first', 'Oldest first')
|
accessibilityValue={currentSortChoice ? { text: sortChoiceLabel(currentSortChoice, t) } : undefined}
|
||||||
: t('settings.appearance.message_list_order.direction.newest_first', 'Newest first')}
|
|
||||||
accessibilityHint={t('email_list.sort_toggle_hint', 'Reverses the mail sort order')}
|
|
||||||
>
|
>
|
||||||
{sortAscending ? (
|
{currentSortChoice === 'newest' ? (
|
||||||
|
<ArrowDownWideNarrow size={18} color={c.textMuted} />
|
||||||
|
) : currentSortChoice === 'oldest' ? (
|
||||||
<ArrowUpNarrowWide size={18} color={c.primary} />
|
<ArrowUpNarrowWide size={18} color={c.primary} />
|
||||||
) : (
|
) : (
|
||||||
<ArrowDownWideNarrow size={18} color={c.textMuted} />
|
<ArrowUpDown size={18} color={c.primary} />
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -1621,6 +1714,7 @@ export default function EmailListScreen({
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
|
ref={listRef}
|
||||||
data={visibleEmails}
|
data={visibleEmails}
|
||||||
keyExtractor={emailKeyExtractor}
|
keyExtractor={emailKeyExtractor}
|
||||||
renderItem={renderEmailRow}
|
renderItem={renderEmailRow}
|
||||||
@@ -1764,17 +1858,18 @@ export default function EmailListScreen({
|
|||||||
active={folderScope === 'current'}
|
active={folderScope === 'current'}
|
||||||
onPress={() => setFolderScope('current')}
|
onPress={() => setFolderScope('current')}
|
||||||
/>
|
/>
|
||||||
{scopedMailboxes
|
</View>
|
||||||
.filter((m) => m.id !== currentMailboxId)
|
{/* Every folder, as a tree (it was a flat list cut at 12). */}
|
||||||
.slice(0, 12)
|
<View style={styles.scopeFolderList}>
|
||||||
.map((m) => (
|
{searchScopeFolders(scopedMailboxes, currentMailboxId).map((m) => (
|
||||||
|
<View key={m.id} style={{ marginLeft: m.depth * spacing.lg }}>
|
||||||
<ScopeChip
|
<ScopeChip
|
||||||
key={m.id}
|
|
||||||
label={localizeMailboxName(m.role, m.name, t)}
|
label={localizeMailboxName(m.role, m.name, t)}
|
||||||
active={folderScope === m.id}
|
active={folderScope === m.id}
|
||||||
onPress={() => setFolderScope(m.id)}
|
onPress={() => setFolderScope(m.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
</View>
|
||||||
|
))}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -1905,11 +2000,11 @@ export default function EmailListScreen({
|
|||||||
visible={pendingMoveId !== null}
|
visible={pendingMoveId !== null}
|
||||||
onClose={() => setPendingMoveId(null)}
|
onClose={() => setPendingMoveId(null)}
|
||||||
mailboxes={mailboxes}
|
mailboxes={mailboxes}
|
||||||
currentMailboxId={currentMailboxId}
|
currentMailboxId={pendingMoveCurrentId}
|
||||||
onPick={(toId) => {
|
onPick={(toId) => {
|
||||||
const id = pendingMoveId;
|
const id = pendingMoveId;
|
||||||
setPendingMoveId(null);
|
setPendingMoveId(null);
|
||||||
if (id && currentMailboxId && toId !== currentMailboxId) {
|
if (id && currentMailboxId && toId !== pendingMoveCurrentId) {
|
||||||
const ids = idsForRow(id);
|
const ids = idsForRow(id);
|
||||||
if (ids.length > 1) reportFailure(moveEmailsToMailbox(ids, toId));
|
if (ids.length > 1) reportFailure(moveEmailsToMailbox(ids, toId));
|
||||||
else reportFailure(moveToMailboxAction(id, currentMailboxId, toId));
|
else reportFailure(moveToMailboxAction(id, currentMailboxId, toId));
|
||||||
@@ -1917,11 +2012,40 @@ export default function EmailListScreen({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ActionSheet
|
||||||
|
visible={sortMenuOpen}
|
||||||
|
title={t('email_list.sort.title', 'Sort')}
|
||||||
|
onClose={() => setSortMenuOpen(false)}
|
||||||
|
items={[
|
||||||
|
...SORT_CHOICES.map((choice) => ({
|
||||||
|
key: choice.key,
|
||||||
|
label: sortChoiceLabel(choice.key, t),
|
||||||
|
trailing: currentSortChoice === choice.key ? <Check size={16} color={c.primary} /> : undefined,
|
||||||
|
onPress: () => {
|
||||||
|
setSortMenuOpen(false);
|
||||||
|
const changes = sortChoiceChanges(choice.key, messageListOrderScope, currentRole);
|
||||||
|
if (!changes) return;
|
||||||
|
const { updateSetting } = useSettingsStore.getState();
|
||||||
|
updateSetting('messageListOrder', changes.messageListOrder);
|
||||||
|
updateSetting('mailSortAscending', changes.mailSortAscending);
|
||||||
|
if (changes.messageListOrderScope) updateSetting('messageListOrderScope', changes.messageListOrderScope);
|
||||||
|
invalidateListOrder();
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
...(currentSortChoice === null ? [{
|
||||||
|
key: 'custom',
|
||||||
|
label: t('email_list.sort.custom', 'Custom order (in Settings)'),
|
||||||
|
trailing: <Check size={16} color={c.primary} />,
|
||||||
|
onPress: () => setSortMenuOpen(false),
|
||||||
|
}] : []),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
<MoveSheet
|
<MoveSheet
|
||||||
visible={batchMoveOpen}
|
visible={batchMoveOpen}
|
||||||
onClose={() => setBatchMoveOpen(false)}
|
onClose={() => setBatchMoveOpen(false)}
|
||||||
mailboxes={mailboxes}
|
mailboxes={mailboxes}
|
||||||
currentMailboxId={currentMailboxId}
|
currentMailboxId={moveSheetCurrentId(mailboxes, currentMailboxId, selectedEmails)}
|
||||||
onPick={handleBatchMovePick}
|
onPick={handleBatchMovePick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -2308,6 +2432,7 @@ function makeStyles(c: ThemePalette) {
|
|||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
},
|
},
|
||||||
|
scopeFolderList: { alignItems: 'flex-start', gap: spacing.xs, marginTop: spacing.sm },
|
||||||
triToggle: {
|
triToggle: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ import {
|
|||||||
ArrowLeft, Star, Trash2, MoreVertical, Reply, ReplyAll, Forward,
|
ArrowLeft, Star, Trash2, MoreVertical, Reply, ReplyAll, Forward,
|
||||||
ChevronLeft, ChevronRight, Archive, Mail, MailOpen,
|
ChevronLeft, ChevronRight, Archive, Mail, MailOpen,
|
||||||
FolderInput, ShieldAlert, ShieldCheck, X, Check,
|
FolderInput, ShieldAlert, ShieldCheck, X, Check,
|
||||||
Code, Download, Tag, Sun, Moon, FileInput, UserRoundPlus, Filter,
|
Code, Download, Tag, Sun, Moon, FileInput, UserRoundPlus, Filter, FileText, Type,
|
||||||
} from '../components/icons';
|
} from '../components/icons';
|
||||||
|
import { pickEmailBody } from '../lib/email-body';
|
||||||
|
import { confirmMailRemoval } from '../lib/mail-removal-confirm';
|
||||||
|
import { NewTagRow } from '../components/NewTagRow';
|
||||||
import { QuickRuleSheet } from '../components/filters/QuickRuleSheet';
|
import { QuickRuleSheet } from '../components/filters/QuickRuleSheet';
|
||||||
import { isSieveSupported } from '../api/sieve';
|
import { isSieveSupported } from '../api/sieve';
|
||||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||||
@@ -47,7 +50,8 @@ import { replyComposeParams } from '../lib/reply-compose';
|
|||||||
import { singleLine } from '../lib/single-line';
|
import { singleLine } from '../lib/single-line';
|
||||||
import { buildForwardAsAttachmentPayload } from '../lib/forward-as-attachment';
|
import { buildForwardAsAttachmentPayload } from '../lib/forward-as-attachment';
|
||||||
import { viewerInstance, viewerPages, type ViewerInstance } from '../lib/viewer-pages';
|
import { viewerInstance, viewerPages, type ViewerInstance } from '../lib/viewer-pages';
|
||||||
import { accountScopedId } from '../lib/thread-utils';
|
import { accountScopedId, isReplyableMember, quickReplyTarget } from '../lib/thread-utils';
|
||||||
|
import { scheduledEmailIdsInThread } from '../api/thread-scheduled';
|
||||||
import type { Email, EmailAddress, Identity } from '../api/types';
|
import type { Email, EmailAddress, Identity } from '../api/types';
|
||||||
import type { RootStackParamList } from '../navigation/types';
|
import type { RootStackParamList } from '../navigation/types';
|
||||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||||
@@ -177,6 +181,13 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
|
|||||||
const [addressSheet, setAddressSheet] = React.useState<EmailAddress | null>(null);
|
const [addressSheet, setAddressSheet] = React.useState<EmailAddress | null>(null);
|
||||||
// Per-message override of the light/dark rendering (More sheet toggle).
|
// Per-message override of the light/dark rendering (More sheet toggle).
|
||||||
const [themeOverrides, setThemeOverrides] = React.useState<Record<string, 'light' | 'dark'>>({});
|
const [themeOverrides, setThemeOverrides] = React.useState<Record<string, 'light' | 'dark'>>({});
|
||||||
|
// Per-message "Show as plain text" (More sheet); unset follows "Plain Text Only".
|
||||||
|
const [plainOverrides, setPlainOverrides] = React.useState<Record<string, boolean>>({});
|
||||||
|
const plainTextMode = useSettingsStore((s) => s.plainTextMode);
|
||||||
|
const showFormatted = React.useCallback(
|
||||||
|
(m: Email) => setPlainOverrides((prev) => ({ ...prev, [m.id]: false })),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// Message details and conversations come from the shared cache
|
// Message details and conversations come from the shared cache
|
||||||
// (lib/email-detail-cache): every rendered pane, the active one and its
|
// (lib/email-detail-cache): every rendered pane, the active one and its
|
||||||
@@ -537,14 +548,18 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
performDelete();
|
// "Confirm before deleting" (webmail lib/mail-removal-confirm.ts).
|
||||||
|
void confirmMailRemoval('delete', 1, t).then((ok) => { if (ok) performDelete(); });
|
||||||
};
|
};
|
||||||
|
|
||||||
const onArchive = () => {
|
const onArchive = () => {
|
||||||
if (!email || !canArchive) return;
|
if (!email || !canArchive) return;
|
||||||
archiveEmailAction(email.id, { email, accountId: ownerAccountId })
|
void confirmMailRemoval('archive', 1, t).then((ok) => {
|
||||||
.catch((err) => toastFailure(t('notifications.error_archiving', 'Failed to archive email'), err));
|
if (!ok) return;
|
||||||
navigation.goBack();
|
archiveEmailAction(email.id, { email, accountId: ownerAccountId })
|
||||||
|
.catch((err) => toastFailure(t('notifications.error_archiving', 'Failed to archive email'), err));
|
||||||
|
navigation.goBack();
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// The same store path as the list and swipe actions: files into the
|
// The same store path as the list and swipe actions: files into the
|
||||||
@@ -679,7 +694,8 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
|
|||||||
label: t('email_viewer.tag', 'Tag'),
|
label: t('email_viewer.tag', 'Tag'),
|
||||||
icon: (s, col) => <Tag size={s} color={col} />,
|
icon: (s, col) => <Tag size={s} color={col} />,
|
||||||
onPress: () => setTagMenuOpen(true),
|
onPress: () => setTagMenuOpen(true),
|
||||||
available: keywordDefs.length > 0,
|
// Always: the tag sheet can make a new tag.
|
||||||
|
available: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -693,6 +709,10 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
|
|||||||
const showMarkUnread = windowWidth >= 340;
|
const showMarkUnread = windowWidth >= 340;
|
||||||
const showArchive = windowWidth >= 400 && canArchive;
|
const showArchive = windowWidth >= 400 && canArchive;
|
||||||
|
|
||||||
|
// The active message read as plain text, and whether it has HTML to switch from.
|
||||||
|
const activePlainView = email ? plainOverrides[email.id] ?? plainTextMode : false;
|
||||||
|
const activeHasHtml = !!email && !!pickEmailBody(email).html;
|
||||||
|
|
||||||
// Current rendering mode of the active message, for the More sheet label.
|
// Current rendering mode of the active message, for the More sheet label.
|
||||||
const activeRenderDark = email
|
const activeRenderDark = email
|
||||||
? (themeOverrides[email.id] ? themeOverrides[email.id] === 'dark' : !emailAlwaysLightMode && resolvedTheme === 'dark')
|
? (themeOverrides[email.id] ? themeOverrides[email.id] === 'dark' : !emailAlwaysLightMode && resolvedTheme === 'dark')
|
||||||
@@ -817,6 +837,8 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
|
|||||||
currentMailboxRole={currentMailboxRole}
|
currentMailboxRole={currentMailboxRole}
|
||||||
identities={identities}
|
identities={identities}
|
||||||
themeOverrides={themeOverrides}
|
themeOverrides={themeOverrides}
|
||||||
|
plainOverrides={plainOverrides}
|
||||||
|
onShowFormatted={showFormatted}
|
||||||
ensureDetail={ensureDetail}
|
ensureDetail={ensureDetail}
|
||||||
ensureDetails={ensureDetails}
|
ensureDetails={ensureDetails}
|
||||||
ensureThread={ensureThread}
|
ensureThread={ensureThread}
|
||||||
@@ -878,8 +900,14 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
|
|||||||
isInJunk={isInJunk}
|
isInJunk={isInJunk}
|
||||||
canViewSource={!!email?.blobId}
|
canViewSource={!!email?.blobId}
|
||||||
canExport={!!email?.blobId}
|
canExport={!!email?.blobId}
|
||||||
canTag={keywordDefs.length > 0}
|
canTag
|
||||||
renderDark={activeRenderDark}
|
renderDark={activeRenderDark}
|
||||||
|
plainView={activeHasHtml ? activePlainView : null}
|
||||||
|
onTogglePlainView={() => {
|
||||||
|
setMoreMenuOpen(false);
|
||||||
|
if (!email) return;
|
||||||
|
setPlainOverrides((prev) => ({ ...prev, [email.id]: !activePlainView }));
|
||||||
|
}}
|
||||||
hasSender={!!email?.from?.[0]?.email}
|
hasSender={!!email?.from?.[0]?.email}
|
||||||
onArchive={() => { setMoreMenuOpen(false); onArchive(); }}
|
onArchive={() => { setMoreMenuOpen(false); onArchive(); }}
|
||||||
onToggleUnread={() => { setMoreMenuOpen(false); onToggleUnread(); }}
|
onToggleUnread={() => { setMoreMenuOpen(false); onToggleUnread(); }}
|
||||||
@@ -971,6 +999,8 @@ interface EmailPaneProps {
|
|||||||
currentMailboxRole: string | null;
|
currentMailboxRole: string | null;
|
||||||
identities: Identity[];
|
identities: Identity[];
|
||||||
themeOverrides: Record<string, 'light' | 'dark'>;
|
themeOverrides: Record<string, 'light' | 'dark'>;
|
||||||
|
plainOverrides: Record<string, boolean>;
|
||||||
|
onShowFormatted: (email: Email) => void;
|
||||||
ensureDetail: (id: string) => Promise<Email | null>;
|
ensureDetail: (id: string) => Promise<Email | null>;
|
||||||
ensureDetails: (ids: string[]) => void;
|
ensureDetails: (ids: string[]) => void;
|
||||||
ensureThread: (threadId: string) => void;
|
ensureThread: (threadId: string) => void;
|
||||||
@@ -986,6 +1016,8 @@ interface EmailPaneProps {
|
|||||||
onZoomChange: (zoom: { pinching: boolean; zoomed: boolean }) => void;
|
onZoomChange: (zoom: { pinching: boolean; zoomed: boolean }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NO_IDS: ReadonlySet<string> = new Set();
|
||||||
|
|
||||||
// One swipeable page: the subject plus either a single message or the whole
|
// One swipeable page: the subject plus either a single message or the whole
|
||||||
// conversation as collapsible cards (newest + unread expanded, mark-read on
|
// conversation as collapsible cards (newest + unread expanded, mark-read on
|
||||||
// expand). The pager keeps three of these mounted (prev, current, next) so a
|
// expand). The pager keeps three of these mounted (prev, current, next) so a
|
||||||
@@ -993,12 +1025,15 @@ interface EmailPaneProps {
|
|||||||
// members' headers; bodies are only downloaded for the cards that are open.
|
// members' headers; bodies are only downloaded for the cards that are open.
|
||||||
function EmailPane({
|
function EmailPane({
|
||||||
id, active, bodyEnabled, onBodySettled, threadIdHint, email, row, threadIds, memberOf, threadSizeHint,
|
id, active, bodyEnabled, onBodySettled, threadIdHint, email, row, threadIds, memberOf, threadSizeHint,
|
||||||
threading, jmapAccountId, currentMailboxRole, identities, themeOverrides, ensureDetail, ensureDetails,
|
threading, jmapAccountId, currentMailboxRole, identities, themeOverrides, plainOverrides, onShowFormatted,
|
||||||
|
ensureDetail, ensureDetails,
|
||||||
ensureThread, scheduleMarkRead, styles, bottomBarHeight, kbObstruction, onToggleStar, onAddressPress,
|
ensureThread, scheduleMarkRead, styles, bottomBarHeight, kbObstruction, onToggleStar, onAddressPress,
|
||||||
onEmailPatched, onReply, onSwipe, onZoomChange,
|
onEmailPatched, onReply, onSwipe, onZoomChange,
|
||||||
}: EmailPaneProps) {
|
}: EmailPaneProps) {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const t = useLocaleStore((s) => s.t);
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
// The quick reply box can be turned off (webmail #1370).
|
||||||
|
const quickReplyEnabled = useSettingsStore((s) => s.quickReplyEnabled);
|
||||||
// Freeze the pane's vertical scroll while a pinch is in flight so a two-
|
// Freeze the pane's vertical scroll while a pinch is in flight so a two-
|
||||||
// finger zoom can't fling the page.
|
// finger zoom can't fling the page.
|
||||||
const [pinching, setPinching] = React.useState(false);
|
const [pinching, setPinching] = React.useState(false);
|
||||||
@@ -1042,6 +1077,19 @@ function EmailPane({
|
|||||||
|
|
||||||
React.useEffect(() => () => { readTimers.forEach((cancel) => cancel()); readTimers.clear(); }, [readTimers]);
|
React.useEffect(() => () => { readTimers.forEach((cancel) => cancel()); readTimers.clear(); }, [readTimers]);
|
||||||
|
|
||||||
|
// Members waiting for a scheduled send: no Reply on them, and the quick
|
||||||
|
// reply answers the newest message that is neither one nor a draft (#1404).
|
||||||
|
const [scheduledIds, setScheduledIds] = React.useState<ReadonlySet<string>>(NO_IDS);
|
||||||
|
const isConversation = !!threadIds && threadIds.length > 1;
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!active || !threading || !threadId || !isConversation) return undefined;
|
||||||
|
let cancelled = false;
|
||||||
|
void scheduledEmailIdsInThread(threadId, jmapAccountId).then((ids) => {
|
||||||
|
if (!cancelled) setScheduledIds(ids.size > 0 ? ids : NO_IDS);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [active, threading, threadId, isConversation, jmapAccountId]);
|
||||||
|
|
||||||
// Keeps the quick reply in view while the keyboard opens. Above the
|
// Keeps the quick reply in view while the keyboard opens. Above the
|
||||||
// skeleton's early return: a page opened by id alone (a notification)
|
// skeleton's early return: a page opened by id alone (a notification)
|
||||||
// renders the skeleton first, and a hook after it crashed the app.
|
// renders the skeleton first, and a hook after it crashed the app.
|
||||||
@@ -1121,7 +1169,7 @@ function EmailPane({
|
|||||||
// and WebView stay mounted when a conversation arrives around it.
|
// and WebView stay mounted when a conversation arrives around it.
|
||||||
const members = conversation ?? [shown];
|
const members = conversation ?? [shown];
|
||||||
const firstOpenId = conversation?.find((m) => expanded?.has(m.id) ?? m.id === id)?.id;
|
const firstOpenId = conversation?.find((m) => expanded?.has(m.id) ?? m.id === id)?.id;
|
||||||
const newest = conversation ? conversation[conversation.length - 1] : email;
|
const newest = quickReplyTarget(conversation ?? (email ? [email] : []), scheduledIds);
|
||||||
// The quick reply quotes the message, so it waits for the newest one's body.
|
// The quick reply quotes the message, so it waits for the newest one's body.
|
||||||
const newestLoaded = !!newest && !!peekDetail(newest.id, jmapAccountId);
|
const newestLoaded = !!newest && !!peekDetail(newest.id, jmapAccountId);
|
||||||
const onPaneZoom = (z: { pinching: boolean; zoomed: boolean }) => {
|
const onPaneZoom = (z: { pinching: boolean; zoomed: boolean }) => {
|
||||||
@@ -1209,7 +1257,7 @@ function EmailPane({
|
|||||||
fill={!asConversation}
|
fill={!asConversation}
|
||||||
email={full ?? m}
|
email={full ?? m}
|
||||||
expanded={open}
|
expanded={open}
|
||||||
replyable={!!full}
|
replyable={!!full && isReplyableMember(m, scheduledIds)}
|
||||||
deferBody={!full || !bodyEnabled}
|
deferBody={!full || !bodyEnabled}
|
||||||
onBodySettled={onBodySettled}
|
onBodySettled={onBodySettled}
|
||||||
onToggleExpanded={() => toggleCard(m.id)}
|
onToggleExpanded={() => toggleCard(m.id)}
|
||||||
@@ -1219,6 +1267,8 @@ function EmailPane({
|
|||||||
currentMailboxRole={currentMailboxRole}
|
currentMailboxRole={currentMailboxRole}
|
||||||
active={active}
|
active={active}
|
||||||
themeOverride={themeOverrides[m.id] ?? null}
|
themeOverride={themeOverrides[m.id] ?? null}
|
||||||
|
plainView={plainOverrides[m.id] ?? null}
|
||||||
|
onShowFormatted={onShowFormatted}
|
||||||
onSwipe={onSwipe}
|
onSwipe={onSwipe}
|
||||||
onZoomChange={onPaneZoom}
|
onZoomChange={onPaneZoom}
|
||||||
onToggleStar={onToggleStar}
|
onToggleStar={onToggleStar}
|
||||||
@@ -1238,7 +1288,7 @@ function EmailPane({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.quickReplySlot}>
|
<View style={styles.quickReplySlot}>
|
||||||
{newest && newestLoaded && (
|
{quickReplyEnabled && newest && newestLoaded && (
|
||||||
<QuickReplyBox
|
<QuickReplyBox
|
||||||
email={newest}
|
email={newest}
|
||||||
jmapAccountId={jmapAccountId}
|
jmapAccountId={jmapAccountId}
|
||||||
@@ -1307,6 +1357,9 @@ interface MoreMenuSheetProps {
|
|||||||
canViewSource: boolean;
|
canViewSource: boolean;
|
||||||
canExport: boolean;
|
canExport: boolean;
|
||||||
renderDark: boolean;
|
renderDark: boolean;
|
||||||
|
/** The active message read as plain text; null when it has no HTML to switch from. */
|
||||||
|
plainView: boolean | null;
|
||||||
|
onTogglePlainView: () => void;
|
||||||
hasSender: boolean;
|
hasSender: boolean;
|
||||||
onArchive: () => void;
|
onArchive: () => void;
|
||||||
onToggleUnread: () => void;
|
onToggleUnread: () => void;
|
||||||
@@ -1324,7 +1377,7 @@ interface MoreMenuSheetProps {
|
|||||||
|
|
||||||
function MoreMenuSheet({
|
function MoreMenuSheet({
|
||||||
visible, onClose, unread, canArchive, canMarkUnread, canMove, canTag,
|
visible, onClose, unread, canArchive, canMarkUnread, canMove, canTag,
|
||||||
showSpam, isInJunk, canViewSource, canExport, renderDark, hasSender,
|
showSpam, isInJunk, canViewSource, canExport, renderDark, plainView, onTogglePlainView, hasSender,
|
||||||
onArchive, onToggleUnread, onMove, onTag, onToggleSpam, onToggleTheme, onSenderActions,
|
onArchive, onToggleUnread, onMove, onTag, onToggleSpam, onToggleTheme, onSenderActions,
|
||||||
onForwardAsAttachment, onRules, onViewSource, onExport,
|
onForwardAsAttachment, onRules, onViewSource, onExport,
|
||||||
}: MoreMenuSheetProps) {
|
}: MoreMenuSheetProps) {
|
||||||
@@ -1430,6 +1483,13 @@ function MoreMenuSheet({
|
|||||||
onPress={onToggleSpam}
|
onPress={onToggleSpam}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{plainView !== null && (
|
||||||
|
<MoreMenuItem
|
||||||
|
icon={plainView ? <Type size={18} color={c.textSecondary} /> : <FileText size={18} color={c.textSecondary} />}
|
||||||
|
label={plainView ? t('email_viewer.show_formatted', 'Show formatted') : t('email_viewer.show_as_plain_text', 'Show as plain text')}
|
||||||
|
onPress={onTogglePlainView}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<MoreMenuItem
|
<MoreMenuItem
|
||||||
icon={renderDark ? <Sun size={18} color={c.textSecondary} /> : <Moon size={18} color={c.textSecondary} />}
|
icon={renderDark ? <Sun size={18} color={c.textSecondary} /> : <Moon size={18} color={c.textSecondary} />}
|
||||||
label={renderDark ? t('email_viewer.view_light_mode', 'View in light mode') : t('email_viewer.view_dark_mode', 'View in dark mode')}
|
label={renderDark ? t('email_viewer.view_light_mode', 'View in light mode') : t('email_viewer.view_dark_mode', 'View in dark mode')}
|
||||||
@@ -1539,14 +1599,14 @@ function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }:
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{keywords.length === 0 ? (
|
{/* Scrolls like the More sheet: a long tag list (or a landscape
|
||||||
<Text style={{ ...typography.body, color: c.textMuted, paddingVertical: spacing.lg, paddingHorizontal: spacing.lg }}>
|
window) ran past the screen with no way to reach the last tags. */}
|
||||||
{t('email_viewer.tag_no_matches', 'No matching tags')}
|
<ScrollView style={styles.sheetScroll} bounces={false} keyboardShouldPersistTaps="handled">
|
||||||
</Text>
|
{keywords.length === 0 && (
|
||||||
) : (
|
<Text style={{ ...typography.body, color: c.textMuted, paddingVertical: spacing.lg, paddingHorizontal: spacing.lg }}>
|
||||||
// Scrolls like the More sheet: a long tag list (or a landscape
|
{t('email_viewer.tag_no_matches', 'No matching tags')}
|
||||||
// window) ran past the screen with no way to reach the last tags.
|
</Text>
|
||||||
<ScrollView style={styles.sheetScroll} bounces={false}>
|
)}
|
||||||
{keywords.map((kw) => {
|
{keywords.map((kw) => {
|
||||||
const token = keywordToken(kw.id);
|
const token = keywordToken(kw.id);
|
||||||
const active = !!activeKeywords[token];
|
const active = !!activeKeywords[token];
|
||||||
@@ -1561,8 +1621,9 @@ function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }:
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ScrollView>
|
{/* "New tag" (webmail 19676a14f): made, saved and put on the message. */}
|
||||||
)}
|
<NewTagRow keywords={keywords} onCreated={onToggle} />
|
||||||
|
</ScrollView>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,9 +23,12 @@ import { useSettingsStore, type SwipeAction } from '../stores/settings-store';
|
|||||||
import { useLocaleStore } from '../stores/locale-store';
|
import { useLocaleStore } from '../stores/locale-store';
|
||||||
import { formatListDate } from '../lib/date-format';
|
import { formatListDate } from '../lib/date-format';
|
||||||
import { singleLine } from '../lib/single-line';
|
import { singleLine } from '../lib/single-line';
|
||||||
|
import { cleanPreview } from '../lib/preview-text';
|
||||||
import { prefetchMessage, rememberRows } from '../lib/email-detail-cache';
|
import { prefetchMessage, rememberRows } from '../lib/email-detail-cache';
|
||||||
import { isPermanentDelete, confirmPermanentDelete } from '../lib/delete-confirm';
|
import { isPermanentDelete, confirmPermanentDelete } from '../lib/delete-confirm';
|
||||||
|
import { confirmMailRemoval } from '../lib/mail-removal-confirm';
|
||||||
import { onStateChangeType } from '../lib/state-change-bus';
|
import { onStateChangeType } from '../lib/state-change-bus';
|
||||||
|
import { describeSenderVerification, senderWarningOf } from '../lib/email-headers';
|
||||||
import { createUnifiedReloadTracker } from '../lib/unified-reload';
|
import { createUnifiedReloadTracker } from '../lib/unified-reload';
|
||||||
import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens';
|
import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens';
|
||||||
import { useColors } from '../theme/colors';
|
import { useColors } from '../theme/colors';
|
||||||
@@ -243,9 +246,12 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
|
|||||||
patchRows(targets, { $flagged: starred ? true : null });
|
patchRows(targets, { $flagged: starred ? true : null });
|
||||||
patchUnifiedKeywords(targets, { $flagged: starred ? true : null }).catch(fail);
|
patchUnifiedKeywords(targets, { $flagged: starred ? true : null }).catch(fail);
|
||||||
};
|
};
|
||||||
const archive = (targets: UnifiedEmail[]) => {
|
// Resolve whether the messages went: a confirm can say no.
|
||||||
|
const archive = async (targets: UnifiedEmail[]): Promise<boolean> => {
|
||||||
|
if (!(await confirmMailRemoval('archive', targets.length, t))) return false;
|
||||||
removeRows(targets);
|
removeRows(targets);
|
||||||
moveUnifiedEmails(targets, 'archive').catch(fail);
|
moveUnifiedEmails(targets, 'archive').catch(fail);
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
const spam = (targets: UnifiedEmail[]) => {
|
const spam = (targets: UnifiedEmail[]) => {
|
||||||
removeRows(targets);
|
removeRows(targets);
|
||||||
@@ -253,24 +259,29 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
|
|||||||
markRead: role !== 'junk' && deleteAction === 'trash-and-read',
|
markRead: role !== 'junk' && deleteAction === 'trash-and-read',
|
||||||
}).catch(fail);
|
}).catch(fail);
|
||||||
};
|
};
|
||||||
const remove = async (targets: UnifiedEmail[]) => {
|
const remove = async (targets: UnifiedEmail[]): Promise<boolean> => {
|
||||||
const permanent = targets.some((e) => isPermanentDelete({
|
const permanent = targets.some((e) => isPermanentDelete({
|
||||||
inTrash: role === 'trash' || isInUnifiedTrash(e),
|
inTrash: role === 'trash' || isInUnifiedTrash(e),
|
||||||
inJunk: role === 'junk',
|
inJunk: role === 'junk',
|
||||||
deleteAction,
|
deleteAction,
|
||||||
permanentlyDeleteJunk,
|
permanentlyDeleteJunk,
|
||||||
}));
|
}));
|
||||||
if (permanent && !(await confirmPermanentDelete(targets.length, t))) return;
|
if (permanent) {
|
||||||
|
if (!(await confirmPermanentDelete(targets.length, t))) return false;
|
||||||
|
} else if (!(await confirmMailRemoval('delete', targets.length, t))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
removeRows(targets);
|
removeRows(targets);
|
||||||
deleteUnifiedEmails(targets, {
|
deleteUnifiedEmails(targets, {
|
||||||
permanent: deleteAction === 'permanent' || (permanentlyDeleteJunk && role === 'junk'),
|
permanent: deleteAction === 'permanent' || (permanentlyDeleteJunk && role === 'junk'),
|
||||||
markRead: deleteAction === 'trash-and-read',
|
markRead: deleteAction === 'trash-and-read',
|
||||||
}).catch(fail);
|
}).catch(fail);
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSwipe = (email: UnifiedEmail, action: SwipeAction) => {
|
const handleSwipe = (email: UnifiedEmail, action: SwipeAction) => {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'archive': if (role !== 'archive') archive([email]); break;
|
case 'archive': if (role !== 'archive') void archive([email]); break;
|
||||||
case 'delete': void remove([email]); break;
|
case 'delete': void remove([email]); break;
|
||||||
case 'spam': if (role !== 'sent' && role !== 'drafts') spam([email]); break;
|
case 'spam': if (role !== 'sent' && role !== 'drafts') spam([email]); break;
|
||||||
case 'read': setRead([email], !email.keywords?.$seen); break;
|
case 'read': setRead([email], !email.keywords?.$seen); break;
|
||||||
@@ -313,6 +324,8 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
|
|||||||
const unread = !item.keywords?.$seen;
|
const unread = !item.keywords?.$seen;
|
||||||
const starred = !!item.keywords?.$flagged;
|
const starred = !!item.keywords?.$flagged;
|
||||||
const isSelected = selected.has(rowKey(item));
|
const isSelected = selected.has(rowKey(item));
|
||||||
|
// Sent and Drafts rows show the recipient: the check says nothing about them.
|
||||||
|
const verification = showRecipient ? null : senderWarningOf(item);
|
||||||
return (
|
return (
|
||||||
<SwipeableRow
|
<SwipeableRow
|
||||||
leftAction={selectionMode ? 'none' : swipeLeftAction}
|
leftAction={selectionMode ? 'none' : swipeLeftAction}
|
||||||
@@ -344,6 +357,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
|
|||||||
email={showRecipient ? item.to?.[0]?.email : item.from?.[0]?.email}
|
email={showRecipient ? item.to?.[0]?.email : item.from?.[0]?.email}
|
||||||
size={componentSizes.avatarMd}
|
size={componentSizes.avatarMd}
|
||||||
disableImages={role === 'junk' && !showAvatarsInJunk}
|
disableImages={role === 'junk' && !showAvatarsInJunk}
|
||||||
|
senderWarning={verification ? describeSenderVerification(verification, t).label : undefined}
|
||||||
/>
|
/>
|
||||||
{acc && accountIds.length > 1 && (
|
{acc && accountIds.length > 1 && (
|
||||||
<View style={[styles.accountDot, { backgroundColor: acc.avatarColor }]} />
|
<View style={[styles.accountDot, { backgroundColor: acc.avatarColor }]} />
|
||||||
@@ -383,7 +397,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
|
|||||||
</View>
|
</View>
|
||||||
{/* Same settings as the folder list: preview on/off, density, text size. */}
|
{/* Same settings as the folder list: preview on/off, density, text size. */}
|
||||||
{item.preview && showPreview && density.showPreview ? (
|
{item.preview && showPreview && density.showPreview ? (
|
||||||
<Text style={[styles.preview, dyn.body]} numberOfLines={2}>{singleLine(item.preview)}</Text>
|
<Text style={[styles.preview, dyn.body]} numberOfLines={2}>{singleLine(cleanPreview(item.preview))}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -440,7 +454,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
|
|||||||
)}
|
)}
|
||||||
{role !== 'archive' && (
|
{role !== 'archive' && (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => { archive(selectedEmails); clearSelection(); }}
|
onPress={() => { void archive(selectedEmails).then((done) => { if (done) clearSelection(); }); }}
|
||||||
style={styles.headerBtn}
|
style={styles.headerBtn}
|
||||||
hitSlop={6}
|
hitSlop={6}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
@@ -450,7 +464,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => { void remove(selectedEmails).then(clearSelection); }}
|
onPress={() => { void remove(selectedEmails).then((done) => { if (done) clearSelection(); }); }}
|
||||||
style={styles.headerBtn}
|
style={styles.headerBtn}
|
||||||
hitSlop={6}
|
hitSlop={6}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import type { Email } from '../../api/types';
|
import type { Email, Mailbox } from '../../api/types';
|
||||||
import {
|
import {
|
||||||
boundEmailCache,
|
boundEmailCache,
|
||||||
PERSISTED_ROWS_PER_FOLDER,
|
PERSISTED_ROWS_PER_FOLDER,
|
||||||
@@ -102,6 +102,8 @@ describe('boundEmailCache', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const INBOX = { id: 'inbox', name: 'Inbox', role: 'inbox', isShared: false } as unknown as Mailbox;
|
||||||
|
|
||||||
describe('email-store persistence', () => {
|
describe('email-store persistence', () => {
|
||||||
const { partialize, merge } = useEmailStore.persist.getOptions();
|
const { partialize, merge } = useEmailStore.persist.getOptions();
|
||||||
|
|
||||||
@@ -149,6 +151,7 @@ describe('email-store persistence', () => {
|
|||||||
it('restores the open folder into the list on hydration', () => {
|
it('restores the open folder into the list on hydration', () => {
|
||||||
const live = rows('live', 3);
|
const live = rows('live', 3);
|
||||||
const persisted = JSON.parse(JSON.stringify(partialize!(viewState({
|
const persisted = JSON.parse(JSON.stringify(partialize!(viewState({
|
||||||
|
mailboxes: [INBOX],
|
||||||
emails: live,
|
emails: live,
|
||||||
totalEmails: 42,
|
totalEmails: 42,
|
||||||
queryState: 'q2',
|
queryState: 'q2',
|
||||||
@@ -170,7 +173,7 @@ describe('email-store persistence', () => {
|
|||||||
const legacy = {
|
const legacy = {
|
||||||
accountSnapshots: {},
|
accountSnapshots: {},
|
||||||
activeAccountId: 'me',
|
activeAccountId: 'me',
|
||||||
mailboxes: [],
|
mailboxes: [INBOX],
|
||||||
emailStates: {},
|
emailStates: {},
|
||||||
currentMailboxId: 'inbox',
|
currentMailboxId: 'inbox',
|
||||||
mailboxSnapshots: { inbox: snap },
|
mailboxSnapshots: { inbox: snap },
|
||||||
@@ -190,4 +193,24 @@ describe('email-store persistence', () => {
|
|||||||
const initial = useEmailStore.getInitialState();
|
const initial = useEmailStore.getInitialState();
|
||||||
expect(merge!(undefined, initial)).toBe(initial);
|
expect(merge!(undefined, initial)).toBe(initial);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A cold start reopened the folder (or the search) the app was left in;
|
||||||
|
// the webmail opens the Inbox (64b39c504).
|
||||||
|
it('opens the Inbox on a cold start, with its cached rows', () => {
|
||||||
|
const work = { id: 'work', name: 'Work', role: null, isShared: false } as unknown as Mailbox;
|
||||||
|
const persisted = JSON.parse(JSON.stringify(partialize!(viewState({
|
||||||
|
mailboxes: [INBOX, work],
|
||||||
|
currentMailboxId: 'work',
|
||||||
|
emails: rows('work', 2),
|
||||||
|
totalEmails: 2,
|
||||||
|
mailboxSnapshots: { inbox: { emails: rows('in', 3), total: 3, queryState: 'qi' } },
|
||||||
|
}))));
|
||||||
|
|
||||||
|
const restored = merge!(persisted, useEmailStore.getInitialState());
|
||||||
|
|
||||||
|
expect(restored.currentMailboxId).toBe('inbox');
|
||||||
|
expect(restored.emails.map((e) => e.id)).toEqual(['in0', 'in1', 'in2']);
|
||||||
|
// The folder's own rows are still cached for when it is opened.
|
||||||
|
expect(restored.mailboxSnapshots.work.emails).toHaveLength(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -626,6 +626,8 @@ describe('tag view across the own and the team account (#1038)', () => {
|
|||||||
mail('o1', 'inbox', 20, 'own red 1', { keywords: { [RED]: true } }),
|
mail('o1', 'inbox', 20, 'own red 1', { keywords: { [RED]: true } }),
|
||||||
mail('o2', 'archive', 10, 'own red 2', { keywords: { [RED]: true, $seen: true } }),
|
mail('o2', 'archive', 10, 'own red 2', { keywords: { [RED]: true, $seen: true } }),
|
||||||
mail('o3', 'inbox', 21, 'own plain'),
|
mail('o3', 'inbox', 21, 'own plain'),
|
||||||
|
// Deleted, but it keeps its keywords: not listed under the tag (#1156).
|
||||||
|
mail('o4', 'trash', 24, 'own red deleted', { keywords: { [RED]: true } }),
|
||||||
],
|
],
|
||||||
team: [
|
team: [
|
||||||
mail('m1', 't-inbox', 22, 'team red 1', { keywords: { [RED]: true } }),
|
mail('m1', 't-inbox', 22, 'team red 1', { keywords: { [RED]: true } }),
|
||||||
@@ -647,9 +649,10 @@ describe('tag view across the own and the team account (#1038)', () => {
|
|||||||
expect(ids()).toEqual(['m1', 'o1', 'm2', 'o2']);
|
expect(ids()).toEqual(['m1', 'o1', 'm2', 'o2']);
|
||||||
expect(server.current!.requests).toHaveLength(1);
|
expect(server.current!.requests).toHaveLength(1);
|
||||||
const queries = server.current!.callsOf('Email/query');
|
const queries = server.current!.callsOf('Email/query');
|
||||||
|
// Each account leaves its own Trash and Junk out (webmail #1156).
|
||||||
expect(queries.map(([, a]) => [a.accountId, a.filter])).toEqual([
|
expect(queries.map(([, a]) => [a.accountId, a.filter])).toEqual([
|
||||||
['c', { hasKeyword: RED }],
|
['c', { operator: 'AND', conditions: [{ hasKeyword: RED }, { inMailboxOtherThan: ['trash', 'junk'] }] }],
|
||||||
['team', { hasKeyword: RED }],
|
['team', { operator: 'AND', conditions: [{ hasKeyword: RED }, { inMailboxOtherThan: ['t-trash', 't-junk'] }] }],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -669,8 +672,8 @@ describe('tag view across the own and the team account (#1038)', () => {
|
|||||||
|
|
||||||
const last = server.current!.requests.at(-1)!.filter(([n]) => n === 'Email/query');
|
const last = server.current!.requests.at(-1)!.filter(([n]) => n === 'Email/query');
|
||||||
expect(last.map(([, a]) => a.filter)).toEqual([
|
expect(last.map(([, a]) => a.filter)).toEqual([
|
||||||
{ operator: 'AND', conditions: [{ text: 'red 2' }, { hasKeyword: RED }] },
|
{ operator: 'AND', conditions: [{ text: 'red 2' }, { hasKeyword: RED }, { inMailboxOtherThan: ['trash', 'junk'] }] },
|
||||||
{ operator: 'AND', conditions: [{ text: 'red 2' }, { hasKeyword: RED }] },
|
{ operator: 'AND', conditions: [{ text: 'red 2' }, { hasKeyword: RED }, { inMailboxOtherThan: ['t-trash', 't-junk'] }] },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -689,7 +692,7 @@ describe('tag view across the own and the team account (#1038)', () => {
|
|||||||
const last = server.current!.requests.at(-1)!.filter(([n]) => n === 'Email/query');
|
const last = server.current!.requests.at(-1)!.filter(([n]) => n === 'Email/query');
|
||||||
expect(last[0][1].filter).toEqual({
|
expect(last[0][1].filter).toEqual({
|
||||||
operator: 'AND',
|
operator: 'AND',
|
||||||
conditions: [{ text: '"red 2"' }, { text: 'notes' }, { hasKeyword: RED }],
|
conditions: [{ text: '"red 2"' }, { text: 'notes' }, { hasKeyword: RED }, { inMailboxOtherThan: ['trash', 'junk'] }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
await vi.waitFor(() => expect(useEmailStore.getState().loading).toBe(false));
|
await vi.waitFor(() => expect(useEmailStore.getState().loading).toBe(false));
|
||||||
|
|||||||
@@ -1566,6 +1566,44 @@ describe('email-store', () => {
|
|||||||
useSettingsStore.getState().updateSetting('deleteAction', 'trash');
|
useSettingsStore.getState().updateSetting('deleteAction', 'trash');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Read, star and tag changes from the list had no Undo (webmail
|
||||||
|
// ee17c0dba, lib/email-action-toast.ts): now each message gets back the
|
||||||
|
// keyword it had, and nothing else moves.
|
||||||
|
it('offers an undo for a read, star or tag change that puts back only that keyword', async () => {
|
||||||
|
const mockRestore = emailApi.restoreEmailMailboxes as ReturnType<typeof vi.fn>;
|
||||||
|
const mockPatchPerEmail = emailApi.patchKeywordsPerEmail as ReturnType<typeof vi.fn>;
|
||||||
|
useEmailStore.setState({
|
||||||
|
mailboxes: [inbox, junk],
|
||||||
|
currentMailboxId: 'mb-1',
|
||||||
|
emails: [
|
||||||
|
{ id: 'e1', keywords: {}, mailboxIds: { 'mb-1': true } } as any,
|
||||||
|
{ id: 'e2', keywords: { $seen: true }, mailboxIds: { 'mb-1': true } } as any,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await useEmailStore.getState().setKeywordForEmails(['e1', 'e2'], '$seen', true);
|
||||||
|
const undo = useEmailStore.getState().pendingUndo!;
|
||||||
|
expect(undo.kind).toBe('keywords');
|
||||||
|
expect(undo.label).toContain('marked as read');
|
||||||
|
|
||||||
|
await useEmailStore.getState().undoLast();
|
||||||
|
expect(mockRestore).not.toHaveBeenCalled();
|
||||||
|
expect(mockPatchPerEmail).toHaveBeenCalledWith(
|
||||||
|
[{ id: 'e1', patch: { $seen: null } }, { id: 'e2', patch: { $seen: true } }],
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
const byId = Object.fromEntries(useEmailStore.getState().emails.map((e) => [e.id, e.keywords]));
|
||||||
|
expect(byId.e1.$seen).toBeUndefined();
|
||||||
|
expect(byId.e2.$seen).toBe(true);
|
||||||
|
|
||||||
|
// Tags and stars too; the reader's own toggles get no bar.
|
||||||
|
await useEmailStore.getState().setKeywordForEmails(['e1'], '$label:work', true);
|
||||||
|
expect(useEmailStore.getState().pendingUndo?.kind).toBe('keywords');
|
||||||
|
useEmailStore.getState().clearUndo();
|
||||||
|
await useEmailStore.getState().setKeywordForEmails(['e1'], '$flagged', true, { email: useEmailStore.getState().emails[0] });
|
||||||
|
expect(useEmailStore.getState().pendingUndo).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('unmarkSpam moves back to Inbox with $notjunk', async () => {
|
it('unmarkSpam moves back to Inbox with $notjunk', async () => {
|
||||||
const mockUndoSpam = emailApi.undoSpam as ReturnType<typeof vi.fn>;
|
const mockUndoSpam = emailApi.undoSpam as ReturnType<typeof vi.fn>;
|
||||||
useEmailStore.setState({
|
useEmailStore.setState({
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { useSettingsStore } from '../settings-store';
|
||||||
|
|
||||||
|
// Defaults the reader pass changed. Kept apart from settings-store.test.ts so
|
||||||
|
// the agents working on settings in parallel don't edit the same test file.
|
||||||
|
|
||||||
|
describe('settings defaults (reader pass)', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await AsyncStorage.clear();
|
||||||
|
useSettingsStore.getState().resetToDefaults();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps sender favicons off until the user turns them on', () => {
|
||||||
|
// The app fetches them from icons.duckduckgo.com itself, which tells a
|
||||||
|
// third party every sender domain and the device's IP. The webmail
|
||||||
|
// proxies them through its own server, so its default doesn't carry over.
|
||||||
|
expect(useSettingsStore.getState().senderFavicons).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Quick reply could not be turned off (webmail #1370). On by default like
|
||||||
|
// the webmail, under the webmail's key so export and import carry it.
|
||||||
|
it('keeps the quick reply on by default and lets it be turned off', async () => {
|
||||||
|
expect(useSettingsStore.getState().quickReplyEnabled).toBe(true);
|
||||||
|
const { mergeWithDefaults, toExportShape } = await import('../settings-store');
|
||||||
|
expect(mergeWithDefaults({ quickReplyEnabled: false }).quickReplyEnabled).toBe(false);
|
||||||
|
expect(toExportShape(useSettingsStore.getState())).toHaveProperty('quickReplyEnabled', true);
|
||||||
|
const fs = await import('node:fs');
|
||||||
|
const path = await import('node:path');
|
||||||
|
const screen = fs.readFileSync(path.resolve(__dirname, '../../screens/EmailThreadScreen.tsx'), 'utf8');
|
||||||
|
expect(screen).toMatch(/\{quickReplyEnabled && newest && newestLoaded && \(/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -303,9 +303,12 @@ export function searchSkipsTrashAndJunk(
|
|||||||
filters: EmailFilters,
|
filters: EmailFilters,
|
||||||
openRole?: string | null,
|
openRole?: string | null,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (filters.keyword) return false;
|
|
||||||
if (filters.folder === SCOPE_EXCEPT_TRASH_JUNK) return true;
|
if (filters.folder === SCOPE_EXCEPT_TRASH_JUNK) return true;
|
||||||
if (filters.folder) return false;
|
if (filters.folder) return false;
|
||||||
|
// A tag view leaves Trash and Junk out, as Gmail's label views do: a
|
||||||
|
// deleted message keeps its keywords and stayed listed under the tag
|
||||||
|
// (webmail #1156). It is still in the Trash folder itself.
|
||||||
|
if (filters.keyword) return true;
|
||||||
return effectiveFolderScope(searchQuery, filters, openRole) === 'all';
|
return effectiveFolderScope(searchQuery, filters, openRole) === 'all';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,11 +318,34 @@ function openRoleOf(state: Partial<Pick<EmailState, 'mailboxes' | 'currentMailbo
|
|||||||
return state.mailboxes.find((m) => m.id === state.currentMailboxId)?.role ?? undefined;
|
return state.mailboxes.find((m) => m.id === state.currentMailboxId)?.role ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The undo bar's label after a read, star or tag change from the list (webmail
|
||||||
|
* lib/email-action-toast.ts, ee17c0dba). Null for other keywords, which get
|
||||||
|
* no Undo.
|
||||||
|
*/
|
||||||
|
export function keywordUndoLabel(token: string, on: boolean, count: number): string | null {
|
||||||
|
if (token === '$seen') {
|
||||||
|
return on
|
||||||
|
? t('notifications.emails_marked_read', '{count, plural, one {# email marked as read} other {# emails marked as read}}', { count })
|
||||||
|
: t('notifications.emails_marked_unread', '{count, plural, one {# email marked as unread} other {# emails marked as unread}}', { count });
|
||||||
|
}
|
||||||
|
if (token === '$flagged') {
|
||||||
|
return on ? t('notifications.email_starred', 'Email starred') : t('notifications.email_unstarred', 'Email unstarred');
|
||||||
|
}
|
||||||
|
if (token.startsWith('$label:')) {
|
||||||
|
return on
|
||||||
|
? t('notifications.email_tagged', 'Email tagged')
|
||||||
|
: t('notifications.tag_removed_count', '{count, plural, one {Tag removed from # email} other {Tag removed from # emails}}', { count });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Snapshot of an action that can still be reversed via the undo snackbar.
|
// Snapshot of an action that can still be reversed via the undo snackbar.
|
||||||
// We store the full email object so undo can re-insert it into the visible list
|
// We store the full email object so undo can re-insert it into the visible list
|
||||||
// optimistically without waiting for a refetch.
|
// optimistically without waiting for a refetch.
|
||||||
export interface UndoEntry {
|
export interface UndoEntry {
|
||||||
kind: 'archive' | 'delete' | 'move' | 'spam';
|
/** `keywords`: a read, star or tag change; undo puts back only `keywordPatch`'s keywords. */
|
||||||
|
kind: 'archive' | 'delete' | 'move' | 'spam' | 'keywords';
|
||||||
/** Human-readable label shown in the snackbar (e.g. "Email archived"). */
|
/** Human-readable label shown in the snackbar (e.g. "Email archived"). */
|
||||||
label: string;
|
label: string;
|
||||||
/** Time the entry was created - the snackbar uses this to drive its timer. */
|
/** Time the entry was created - the snackbar uses this to drive its timer. */
|
||||||
@@ -603,7 +629,7 @@ export function spansAccounts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The raw JMAP ids of one account's Trash and Junk (undefined = the user's own).
|
// The raw JMAP ids of one account's Trash and Junk (undefined = the user's own).
|
||||||
function trashAndJunkIds(mailboxes: Mailbox[], accountId: string | undefined): string[] {
|
export function trashAndJunkIds(mailboxes: Mailbox[], accountId: string | undefined): string[] {
|
||||||
return accountMailboxes(mailboxes, accountId)
|
return accountMailboxes(mailboxes, accountId)
|
||||||
.filter((m) => isTrashOrJunkRole(m.role))
|
.filter((m) => isTrashOrJunkRole(m.role))
|
||||||
.map((m) => m.originalId ?? m.id);
|
.map((m) => m.originalId ?? m.id);
|
||||||
@@ -886,6 +912,16 @@ function snapshotFromActive(state: EmailState): AccountSnapshot {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A cold start opens the Inbox, not the folder the app was last left in
|
||||||
|
* (webmail 64b39c504); its cached rows still show at once. Snapshots of the
|
||||||
|
* other accounts keep their folder for an account switch.
|
||||||
|
*/
|
||||||
|
export function coldStartView(snap: AccountSnapshot): AccountSnapshot {
|
||||||
|
const inbox = snap.mailboxes?.find((m) => m.role === 'inbox' && !m.isShared);
|
||||||
|
return { ...snap, currentMailboxId: inbox?.id ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
function viewFromSnapshot(snap: AccountSnapshot | null): {
|
function viewFromSnapshot(snap: AccountSnapshot | null): {
|
||||||
mailboxes: Mailbox[];
|
mailboxes: Mailbox[];
|
||||||
mailboxState?: string;
|
mailboxState?: string;
|
||||||
@@ -2106,6 +2142,26 @@ export const useEmailStore = create<EmailState>()(
|
|||||||
for (const { accountId, emails } of written) {
|
for (const { accountId, emails } of written) {
|
||||||
for (const e of emails) patchCache(e.id, { keywords: patch }, accountId);
|
for (const e of emails) patchCache(e.id, { keywords: patch }, accountId);
|
||||||
}
|
}
|
||||||
|
// Undo for a read, star or tag change from the list: each message gets
|
||||||
|
// back the keyword it had (the reader's own toggles get no bar).
|
||||||
|
const changed = written.flatMap(({ accountId, emails }) => emails.map((email) => ({ email, accountId })));
|
||||||
|
const label = !viewed && changed.length > 0 ? keywordUndoLabel(token, on, changed.length) : null;
|
||||||
|
if (label) {
|
||||||
|
set({
|
||||||
|
pendingUndo: {
|
||||||
|
kind: 'keywords',
|
||||||
|
label,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
keywordPatch: patch,
|
||||||
|
items: changed.map(({ email, accountId }) => ({
|
||||||
|
email,
|
||||||
|
originalMailboxIds: { ...email.mailboxIds },
|
||||||
|
originalKeywords: { ...email.keywords },
|
||||||
|
accountId,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
if (refusal) throw refusal;
|
if (refusal) throw refusal;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -2114,6 +2170,44 @@ export const useEmailStore = create<EmailState>()(
|
|||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
set({ pendingUndo: null });
|
set({ pendingUndo: null });
|
||||||
|
|
||||||
|
// A read, star or tag change: only those keywords go back; the messages
|
||||||
|
// stay in whatever folder they are in now.
|
||||||
|
if (entry.kind === 'keywords') {
|
||||||
|
const keywordPatch = entry.keywordPatch ?? {};
|
||||||
|
const undo = entry.items.map((it) => ({
|
||||||
|
id: it.email.id,
|
||||||
|
key: rowKeyOf(it.email),
|
||||||
|
accountId: it.accountId ?? entry.accountId,
|
||||||
|
patch: revertKeywordPatch(keywordPatch, it.originalKeywords),
|
||||||
|
}));
|
||||||
|
const accounts = new Set(undo.map((u) => u.accountId ?? ''));
|
||||||
|
try {
|
||||||
|
await applyOrQueueBatch(
|
||||||
|
undo.map((u): OutboxOp => ({ kind: 'keywords', emailId: u.id, accountId: u.accountId, patch: u.patch })),
|
||||||
|
async () => {
|
||||||
|
for (const account of accounts) {
|
||||||
|
await patchKeywordsPerEmail(
|
||||||
|
undo.filter((u) => (u.accountId ?? '') === account).map(({ id, patch }) => ({ id, patch })),
|
||||||
|
account || undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
set({ error: err instanceof Error ? err.message : t('email_list.undo_failed', 'Undo failed') });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const byKey = new Map(undo.map((u) => [u.key, u.patch]));
|
||||||
|
set({
|
||||||
|
emails: get().emails.map((e) => {
|
||||||
|
const patch = byKey.get(rowKeyOf(e));
|
||||||
|
return patch ? { ...e, keywords: applyKeywordPatch(e.keywords, patch) } : e;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
for (const u of undo) patchCache(u.id, { keywords: u.patch }, u.accountId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// An action on a list spanning accounts is undone in each item's account.
|
// An action on a list spanning accounts is undone in each item's account.
|
||||||
const accountOf = (it: UndoEntry['items'][number]) => it.accountId ?? entry.accountId;
|
const accountOf = (it: UndoEntry['items'][number]) => it.accountId ?? entry.accountId;
|
||||||
const byAccount = new Map<string, { accountId?: string; items: UndoEntry['items'] }>();
|
const byAccount = new Map<string, { accountId?: string; items: UndoEntry['items'] }>();
|
||||||
@@ -2332,7 +2426,7 @@ export const useEmailStore = create<EmailState>()(
|
|||||||
merge: (persisted, current) => {
|
merge: (persisted, current) => {
|
||||||
if (!persisted) return current;
|
if (!persisted) return current;
|
||||||
const cache = persisted as PersistedEmailCache;
|
const cache = persisted as PersistedEmailCache;
|
||||||
return { ...current, ...cache, ...viewFromSnapshot(cache) };
|
return { ...current, ...cache, ...viewFromSnapshot(coldStartView(cache)) };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -322,6 +322,9 @@ export interface PersistedSettings {
|
|||||||
toastDuration: number;
|
toastDuration: number;
|
||||||
// Push only for mail that lands in the Inbox (webmail #983, same key).
|
// Push only for mail that lands in the Inbox (webmail #983, same key).
|
||||||
pushNotifyInboxOnly: boolean;
|
pushNotifyInboxOnly: boolean;
|
||||||
|
// reader: the quick reply box under a conversation can be turned off
|
||||||
|
// (webmail #1370, same key as the webmail's for export/import).
|
||||||
|
quickReplyEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_PERSISTED: PersistedSettings = {
|
const DEFAULT_PERSISTED: PersistedSettings = {
|
||||||
@@ -332,7 +335,7 @@ const DEFAULT_PERSISTED: PersistedSettings = {
|
|||||||
externalContentPolicy: 'ask',
|
externalContentPolicy: 'ask',
|
||||||
trustedSenders: [],
|
trustedSenders: [],
|
||||||
trustedSendersAddressBook: null,
|
trustedSendersAddressBook: null,
|
||||||
senderFavicons: true,
|
senderFavicons: false,
|
||||||
hideInlineImageAttachments: true,
|
hideInlineImageAttachments: true,
|
||||||
|
|
||||||
groupContactsByLetter: true,
|
groupContactsByLetter: true,
|
||||||
@@ -482,6 +485,8 @@ const DEFAULT_PERSISTED: PersistedSettings = {
|
|||||||
// platform:
|
// platform:
|
||||||
toastDuration: -1,
|
toastDuration: -1,
|
||||||
pushNotifyInboxOnly: false,
|
pushNotifyInboxOnly: false,
|
||||||
|
// reader:
|
||||||
|
quickReplyEnabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface SettingsState extends PersistedSettings {
|
export interface SettingsState extends PersistedSettings {
|
||||||
|
|||||||
@@ -20,7 +20,13 @@ interface TagCountsState {
|
|||||||
/** `generation` the counts were fetched at. */
|
/** `generation` the counts were fetched at. */
|
||||||
fetchedAt: number;
|
fetchedAt: number;
|
||||||
/** Fetch the counts unless the cached ones are current. */
|
/** Fetch the counts unless the cached ones are current. */
|
||||||
ensure: (login: string, tagIds: string[], accountIds: Array<string | undefined>) => Promise<void>;
|
ensure: (
|
||||||
|
login: string,
|
||||||
|
tagIds: string[],
|
||||||
|
accountIds: Array<string | undefined>,
|
||||||
|
/** Each account's Trash and Junk ids (key '' = the own account), left out of the counts. */
|
||||||
|
excluded?: Record<string, string[]>,
|
||||||
|
) => Promise<void>;
|
||||||
/** An Email change arrived: the next `ensure` fetches again. */
|
/** An Email change arrived: the next `ensure` fetches again. */
|
||||||
invalidate: () => void;
|
invalidate: () => void;
|
||||||
}
|
}
|
||||||
@@ -35,8 +41,8 @@ export const useTagCountsStore = create<TagCountsState>((set, get) => ({
|
|||||||
generation: 0,
|
generation: 0,
|
||||||
fetchedAt: -1,
|
fetchedAt: -1,
|
||||||
|
|
||||||
ensure: (login, tagIds, accountIds) => {
|
ensure: (login, tagIds, accountIds, excluded = {}) => {
|
||||||
const key = `${login}|${accountIds.map((id) => id ?? '').join(',')}|${tagIds.join(',')}`;
|
const key = `${login}|${accountIds.map((id) => id ?? '').join(',')}|${tagIds.join(',')}|${JSON.stringify(excluded)}`;
|
||||||
if (get().login !== login) set({ counts: {}, login, key: null });
|
if (get().login !== login) set({ counts: {}, login, key: null });
|
||||||
const { generation } = get();
|
const { generation } = get();
|
||||||
if (get().key === key && get().fetchedAt === generation) return Promise.resolve();
|
if (get().key === key && get().fetchedAt === generation) return Promise.resolve();
|
||||||
@@ -44,7 +50,7 @@ export const useTagCountsStore = create<TagCountsState>((set, get) => ({
|
|||||||
const seq = ++lastSeq;
|
const seq = ++lastSeq;
|
||||||
const promise = (async () => {
|
const promise = (async () => {
|
||||||
try {
|
try {
|
||||||
const list = await fetchTagCounts(tagIds, accountIds);
|
const list = await fetchTagCounts(tagIds, accountIds, excluded);
|
||||||
// A later ensure (another login, other tags) supersedes this one.
|
// A later ensure (another login, other tags) supersedes this one.
|
||||||
if (inflight?.seq !== seq || get().login !== login) return;
|
if (inflight?.seq !== seq || get().login !== login) return;
|
||||||
set({ counts: Object.fromEntries(list.map((c) => [c.id, c])), key, fetchedAt: generation });
|
set({ counts: Object.fromEntries(list.map((c) => [c.id, c])), key, fetchedAt: generation });
|
||||||
|
|||||||
Reference in New Issue
Block a user