mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +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
|
||||
- [ ] **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
|
||||
- [ ] **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-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)
|
||||
|
||||
- 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
|
||||
- [ ] **#85 keep the shade open on Delete** — question 1
|
||||
- [ ] **#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.
|
||||
- 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 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.
|
||||
@@ -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 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`.
|
||||
|
||||
- [ ] **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`.
|
||||
|
||||
- [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`).
|
||||
|
||||
- [ ] **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`).
|
||||
|
||||
- [ ] **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`.
|
||||
|
||||
- [ ] **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.
|
||||
|
||||
- [ ] **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`.
|
||||
|
||||
- [ ] **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`.
|
||||
|
||||
- [ ] **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`.
|
||||
|
||||
- [ ] **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`
|
||||
- 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)`).
|
||||
|
||||
- [ ] **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)
|
||||
- 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.
|
||||
|
||||
### 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 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`).
|
||||
|
||||
- [ ] **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`.
|
||||
|
||||
- [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`
|
||||
- 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`.
|
||||
|
||||
- [ ] **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)
|
||||
- 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`).
|
||||
|
||||
### Composer and sending
|
||||
|
||||
@@ -19,7 +19,7 @@ Classification: `bug` = fixed in the webmail, still live in the app; `missing`;
|
||||
|
||||
### 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 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.
|
||||
@@ -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`).
|
||||
- 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 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".
|
||||
|
||||
- [ ] **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 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()`.
|
||||
|
||||
- [ ] **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 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.
|
||||
@@ -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`).
|
||||
- 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 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.
|
||||
|
||||
- [ ] **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 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.
|
||||
|
||||
- [ ] **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 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.
|
||||
|
||||
- [ ] **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 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.
|
||||
|
||||
- [ ] **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 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.
|
||||
|
||||
### 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 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.
|
||||
|
||||
- [ ] **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 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.
|
||||
|
||||
- [ ] **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 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.
|
||||
|
||||
- [ ] **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 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.
|
||||
|
||||
- [ ] **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 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".
|
||||
@@ -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`).
|
||||
- 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 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.
|
||||
|
||||
- [ ] **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 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.
|
||||
|
||||
+39
-1
@@ -453,6 +453,17 @@
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"emails_archived_count": "{count, plural, one {# email} other {# emails}} archived",
|
||||
"emails_moved_to": "{count, plural, one {# email} other {# emails}} moved to {mailbox}",
|
||||
@@ -490,6 +501,17 @@
|
||||
"scope_this_folder": "This folder",
|
||||
"search_placeholder": "Search 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",
|
||||
"swipe_move": "Move",
|
||||
"swipe_spam": "Spam",
|
||||
@@ -540,6 +562,10 @@
|
||||
"load_failed": "Failed to load email",
|
||||
"next": "Next",
|
||||
"not_spam_short": "Not spam",
|
||||
"new_tag": "New tag",
|
||||
"new_tag_placeholder": "Tag name",
|
||||
"pause_animation": "Pause animation",
|
||||
"play_animation": "Play animation",
|
||||
"preview": {
|
||||
"archive_files": "{count, plural, one {# file} other {# files}}",
|
||||
"archive_first_files": "The first {count} files",
|
||||
@@ -570,7 +596,10 @@
|
||||
"sender_actions": "Sender…",
|
||||
"set_color": "Set tag",
|
||||
"show_all_count": "Show all ({count})",
|
||||
"show_as_plain_text": "Show as plain text",
|
||||
"show_formatted": "Show formatted",
|
||||
"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",
|
||||
"source_too_large": "The message is too large to display in full. Share the full source instead.",
|
||||
"spam_short": "Spam",
|
||||
@@ -639,6 +668,8 @@
|
||||
"tracking_number": "Tracking number",
|
||||
"until_time": "until {time}"
|
||||
},
|
||||
"tag_exists": "A tag with this name already exists",
|
||||
"tag_name_invalid": "Choose a shorter name",
|
||||
"thread_loading": "Loading conversation...",
|
||||
"undo_send_failed": "Undo send failed",
|
||||
"unsubscribe_banner": {
|
||||
@@ -844,6 +875,9 @@
|
||||
}
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"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.",
|
||||
"unknown_user": "Unknown user"
|
||||
},
|
||||
"shell": {
|
||||
"name_unread": "{name}, {count} unread"
|
||||
},
|
||||
"sidebar": {
|
||||
"account_error": "Needs attention",
|
||||
"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_title": "Sign out?",
|
||||
"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": {
|
||||
"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,
|
||||
sendEmail,
|
||||
MAILBOX_PROPERTIES,
|
||||
queryEmailPage,
|
||||
} 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 { getIdentities, updateIdentity } from '../identity';
|
||||
import { isJMAPSetError } from '../jmap-result';
|
||||
@@ -460,4 +464,75 @@ live('live Stalwart: mail, contacts, search (0.16 and 1.0)', () => {
|
||||
}
|
||||
}, 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',
|
||||
request: vi.fn(),
|
||||
getMaxCallsInRequest: vi.fn(() => 16),
|
||||
currentSession: { capabilities: {} as Record<string, unknown> },
|
||||
hasAccountCapability: vi.fn(() => true),
|
||||
},
|
||||
}));
|
||||
|
||||
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 mockMaxCalls = jmapClient.getMaxCallsInRequest as ReturnType<typeof vi.fn>;
|
||||
@@ -30,6 +32,7 @@ function answer(totals: Record<string, { total: number; unread: number }>, faili
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockMaxCalls.mockReturnValue(16);
|
||||
jmapClient.currentSession!.capabilities = {};
|
||||
});
|
||||
|
||||
describe('fetchTagCounts', () => {
|
||||
@@ -39,11 +42,11 @@ describe('fetchTagCounts', () => {
|
||||
expect(await fetchTagCounts(['red'])).toEqual([{ id: 'red', total: 2, unread: 1 }]);
|
||||
const calls = mockRequest.mock.calls[0][0];
|
||||
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', {
|
||||
accountId: 'c',
|
||||
filter: { operator: 'AND', conditions: [{ hasKeyword: '$label:red' }, { notKeyword: '$seen' }] },
|
||||
limit: 0,
|
||||
limit: 1,
|
||||
calculateTotal: true,
|
||||
}, 'u0:red'],
|
||||
]);
|
||||
@@ -77,4 +80,53 @@ describe('fetchTagCounts', () => {
|
||||
|
||||
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', () => {
|
||||
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 { stalwartStateWentBack } from './stalwart-state';
|
||||
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',
|
||||
'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
|
||||
// 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 = [
|
||||
...EMAIL_LIST_PROPERTIES,
|
||||
...EMAIL_SUMMARY_PROPERTIES,
|
||||
'bodyStructure', 'textBody', 'htmlBody', 'bodyValues',
|
||||
'attachments', 'blobId', 'bcc', 'replyTo', 'sentAt',
|
||||
'attachments', 'blobId', 'bcc', 'replyTo',
|
||||
'messageId', 'inReplyTo', 'references', 'headers',
|
||||
];
|
||||
|
||||
@@ -1014,7 +1027,7 @@ export async function getEmailFlags(
|
||||
}
|
||||
|
||||
// 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. */
|
||||
export interface ThreadHeaders {
|
||||
|
||||
+64
-6
@@ -1,5 +1,8 @@
|
||||
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 {
|
||||
/** 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
|
||||
* call that fails, such as for an account that can't be reached, counts 0
|
||||
* 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(
|
||||
tagIds: string[],
|
||||
accountIds: Array<string | undefined> = [undefined],
|
||||
excluded: Record<string, string[]> = {},
|
||||
): Promise<TagCount[]> {
|
||||
if (tagIds.length === 0) return [];
|
||||
const perRequest = Math.max(2, jmapClient.getMaxCallsInRequest());
|
||||
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 targets = new Map<string, { id: string; kind: 'total' | 'unread' }>();
|
||||
accountIds.forEach((override, index) => {
|
||||
queried.forEach((override, index) => {
|
||||
const accountId = override ?? jmapClient.accountId;
|
||||
const skip = excluded[override ?? ''] ?? [];
|
||||
const exclusion = skip.length > 0 ? [{ inMailboxOtherThan: skip }] : [];
|
||||
for (const id of tagIds) {
|
||||
const keyword = `$label:${id}`;
|
||||
targets.set(`t${index}:${id}`, { id, kind: 'total' });
|
||||
calls.push(['Email/query', {
|
||||
accountId,
|
||||
filter: { hasKeyword: keyword },
|
||||
limit: 0,
|
||||
filter: exclusion.length > 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,
|
||||
}, `t${index}:${id}`]);
|
||||
targets.set(`u${index}:${id}`, { id, kind: 'unread' });
|
||||
calls.push(['Email/query', {
|
||||
accountId,
|
||||
filter: { operator: 'AND', conditions: [{ hasKeyword: keyword }, { notKeyword: '$seen' }] },
|
||||
limit: 0,
|
||||
filter: { operator: 'AND', conditions: [{ hasKeyword: keyword }, { notKeyword: '$seen' }, ...exclusion] },
|
||||
limit: 1,
|
||||
calculateTotal: true,
|
||||
}, `u${index}:${id}`]);
|
||||
}
|
||||
@@ -63,3 +94,30 @@ export async function fetchTagCounts(
|
||||
}
|
||||
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 { freshStoredCredentials } from '../lib/stored-token-refresh';
|
||||
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 /
|
||||
// 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 = [
|
||||
'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;
|
||||
@@ -29,9 +32,13 @@ const MAILBOX_CACHE_TTL_MS = 60 * 1000;
|
||||
export type UnifiedRole = 'inbox' | 'sent' | 'drafts' | 'junk' | 'archive' | 'trash';
|
||||
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([
|
||||
'junk', 'spam', 'sent', 'archive', 'trash', 'drafts',
|
||||
'junk', 'spam', 'sent', 'archive', 'trash', 'drafts', 'all',
|
||||
]);
|
||||
|
||||
export interface UnifiedEmail extends Email {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
@@ -14,11 +14,15 @@ import { parseMailtoUrl } from '../lib/unsubscribe';
|
||||
import { fetchInlineImageDataUri } from '../lib/email-export';
|
||||
import { isSenderContentTrusted, isTrustedSendersSyncOn } from '../lib/trusted-senders';
|
||||
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 { useSettingsStore } from '../stores/settings-store';
|
||||
import { useContactsStore } from '../stores/contacts-store';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { spacing, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useShouldAnimate, useTypography } from '../theme/dynamic';
|
||||
import { useColors, useResolvedTheme } from '../theme/colors';
|
||||
import { perfEndAfterPaint, perfLap } from '../lib/perf-marks';
|
||||
import type { RootStackParamList } from '../navigation/types';
|
||||
@@ -54,6 +58,13 @@ interface EmailBodyViewProps {
|
||||
* page below the header) rather than a guessed height.
|
||||
*/
|
||||
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
|
||||
@@ -521,6 +532,7 @@ const PINCH_ZOOM = `
|
||||
|
||||
export default function EmailBodyView({
|
||||
email, senderEmail, jmapAccountId, onSwipe, onZoomChange, themeOverride, bodyOverride, onSettled, fill,
|
||||
plainView: plainViewOverride, onShowFormatted,
|
||||
}: EmailBodyViewProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
@@ -535,6 +547,16 @@ export default function EmailBodyView({
|
||||
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
|
||||
const plainTextFont = useSettingsStore((s) => s.plainTextFont);
|
||||
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 trustedSendersLoaded = useContactsStore((s) => s.trustedSendersLoaded);
|
||||
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 };
|
||||
return pickEmailBody(email);
|
||||
}, [email, bodyOverride]);
|
||||
const rawHtml = React.useMemo(() => selectRenderableHtml(picked), [picked]);
|
||||
const text = picked.text;
|
||||
const formattedHtml = React.useMemo(() => selectRenderableHtml(picked), [picked]);
|
||||
// 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
|
||||
// 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
|
||||
@@ -660,6 +693,13 @@ export default function EmailBodyView({
|
||||
// Sanitised and wrapped once per message, body and settings, and reused
|
||||
// when the same body mounts again (lib/email-body-document).
|
||||
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({
|
||||
key: `${jmapAccountId ?? ''}|${email.id}`,
|
||||
rawHtml,
|
||||
@@ -671,12 +711,21 @@ export default function EmailBodyView({
|
||||
messageSpacing,
|
||||
plainTextFont,
|
||||
quoteLabels,
|
||||
textFactor,
|
||||
plainDarkColors,
|
||||
}), [
|
||||
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;
|
||||
// Still cut off after the API's larger refetch (or the refetch failed).
|
||||
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.
|
||||
const injectedJs = React.useMemo(
|
||||
() =>
|
||||
(prepared.isHtml ? buildQuoteCollapseScript(quoteLabels) : '')
|
||||
(prepared.isHtml ? buildMailTextSizeScript(textFactor) : '')
|
||||
+ (prepared.isHtml ? buildQuoteCollapseScript(quoteLabels) : '')
|
||||
+ (prepared.applyInversion ? DARK_REINVERT_SCRIPT : '')
|
||||
+ (prepared.isHtml ? IMG_FIXUP_SCRIPT : '')
|
||||
+ (prepared.isHtml && reduceMotion ? buildPauseAnimatedImagesScript(pauseLabels) : '')
|
||||
+ 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.
|
||||
@@ -765,6 +816,18 @@ export default function EmailBodyView({
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<View style={styles.banner}>
|
||||
<Text style={styles.bannerText}>
|
||||
@@ -821,6 +884,9 @@ export default function EmailBodyView({
|
||||
// Native Android zoom would scale the viewport inside the fixed-size
|
||||
// container; pinch zoom is implemented in the page instead.
|
||||
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}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
setSupportMultipleWindows={false}
|
||||
@@ -895,6 +961,19 @@ function makeStyles(c: ThemePalette) {
|
||||
...typography.caption,
|
||||
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: {
|
||||
width: '100%',
|
||||
backgroundColor: 'transparent',
|
||||
|
||||
@@ -13,19 +13,24 @@ import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailb
|
||||
import { useSheetDrag } from '../lib/use-sheet-drag';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { localizeMailboxName } from '../lib/mailbox-label';
|
||||
import { mailboxIconKind, type MailboxIconKind } from '../lib/mailbox-icon';
|
||||
import type { Mailbox } from '../api/types';
|
||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||
import { useSheetInsets } from '../lib/use-layout';
|
||||
|
||||
function moveTargetIcon(role: string | null | undefined, name: string): AppIcon {
|
||||
const lower = name.toLowerCase();
|
||||
if (role === 'inbox' || lower.includes('inbox')) return Inbox;
|
||||
if (role === 'sent' || lower.includes('sent')) return Send;
|
||||
if (role === 'drafts' || lower.includes('draft')) return FileIcon;
|
||||
if (role === 'trash' || lower.includes('trash') || lower.includes('deleted')) return Trash2;
|
||||
if (role === 'junk' || role === 'spam' || lower.includes('junk') || lower.includes('spam')) return Ban;
|
||||
if (role === 'archive' || lower.includes('archive')) return Archive;
|
||||
return Folder;
|
||||
// By role only, like the sidebar (mailboxIconKind, #1235): a user folder
|
||||
// named "web/sentry" is not the Sent folder.
|
||||
const MOVE_TARGET_ICONS: Partial<Record<MailboxIconKind, AppIcon>> = {
|
||||
inbox: Inbox,
|
||||
sent: Send,
|
||||
drafts: FileIcon,
|
||||
trash: Trash2,
|
||||
junk: Ban,
|
||||
archive: Archive,
|
||||
};
|
||||
|
||||
function moveTargetIcon(role: string | null | undefined): AppIcon {
|
||||
return MOVE_TARGET_ICONS[mailboxIconKind(role, false, false)] ?? Folder;
|
||||
}
|
||||
|
||||
interface MoveSheetProps {
|
||||
@@ -106,7 +111,7 @@ export function MoveSheet({
|
||||
</View>
|
||||
<ScrollView>
|
||||
{visibleNodes.map((node) => {
|
||||
const Icon = moveTargetIcon(node.role, node.name);
|
||||
const Icon = moveTargetIcon(node.role);
|
||||
const isCurrent = node.id === currentMailboxId;
|
||||
// A shared account's header is a grouping row, not a folder, and
|
||||
// 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 { useShallow } from 'zustand/react/shallow';
|
||||
import {
|
||||
avatarContent,
|
||||
initialsColor,
|
||||
generateEmailAvatarColor,
|
||||
getContactPhotoForEmail,
|
||||
getEmailInitials,
|
||||
@@ -14,6 +16,8 @@ import type { ContactPhotoRef } from '../lib/contact-utils';
|
||||
import { useContactPhotoSource } from '../lib/use-contact-photo';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { useContactsStore } from '../stores/contacts-store';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { ShieldAlert } from './icons';
|
||||
|
||||
interface SenderAvatarProps {
|
||||
name?: string | null;
|
||||
@@ -32,23 +36,32 @@ interface SenderAvatarProps {
|
||||
* the contact has no photo, so no other card's photo is borrowed.
|
||||
*/
|
||||
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:
|
||||
// address-book photo → company favicon (if sender domain is non-personal and
|
||||
// `senderFavicons` is on) → initials over HSL-hashed color.
|
||||
export default function SenderAvatar({ name, email, size = 40, style, disableImages, photo }: SenderAvatarProps) {
|
||||
const senderFavicons = useSettingsStore((s) => s.senderFavicons) && !disableImages;
|
||||
// sender warning → address-book photo → company favicon (if sender domain is
|
||||
// non-personal and `senderFavicons` is on) → initials over HSL-hashed color.
|
||||
export default function SenderAvatar({ name, email, size = 40, style, disableImages, photo, senderWarning }: SenderAvatarProps) {
|
||||
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
|
||||
// contacts change; the index behind it is built once per contacts array.
|
||||
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;
|
||||
// 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.
|
||||
const { source: photoSource, onLoad: onPhotoLoad, onError: onPhotoError } =
|
||||
useContactPhotoSource(disableImages ? null : contactPhoto);
|
||||
useContactPhotoSource(disableImages || warned ? null : contactPhoto);
|
||||
const [imgError, setImgError] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -61,8 +74,11 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
|
||||
|
||||
const faviconDomain = getFaviconDomain(email ?? undefined);
|
||||
const domainFailed = faviconDomain ? hasFaviconFailed(faviconDomain) : false;
|
||||
const showPhoto = !disableImages && !!photoSource;
|
||||
const showFavicon = !showPhoto && senderFavicons && !!faviconDomain && !imgError && !domainFailed;
|
||||
const content = avatarContent({
|
||||
senderWarning,
|
||||
hasPhoto: !disableImages && !!photoSource,
|
||||
showFavicon: senderFavicons && !!faviconDomain && !imgError && !domainFailed,
|
||||
});
|
||||
|
||||
const handleError = React.useCallback(() => {
|
||||
setImgError(true);
|
||||
@@ -75,14 +91,29 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
backgroundColor: showPhoto ? 'transparent' : showFavicon ? '#ffffff' : bgColor,
|
||||
backgroundColor: content === 'warning'
|
||||
? c.errorBg
|
||||
: content === 'photo' ? 'transparent' : content === 'favicon' ? '#ffffff' : bgColor,
|
||||
},
|
||||
style,
|
||||
];
|
||||
|
||||
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 (
|
||||
<View style={containerStyle}>
|
||||
<Image
|
||||
@@ -96,7 +127,7 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
|
||||
);
|
||||
}
|
||||
|
||||
if (showFavicon && faviconDomain) {
|
||||
if (content === 'favicon' && faviconDomain) {
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<Image
|
||||
@@ -111,7 +142,7 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
|
||||
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<Text style={[styles.initials, { fontSize }]}>{initials}</Text>
|
||||
<Text style={[styles.initials, { fontSize, color: initialsColor(bgColor) }]}>{initials}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
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 { useAccountStore } from '../stores/account-store';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
@@ -128,7 +128,7 @@ function RowCounts({ unread, total, showTotal, onPressUnread }: {
|
||||
disabled={!onPressUnread}
|
||||
hitSlop={8}
|
||||
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>
|
||||
</Pressable>
|
||||
@@ -167,11 +167,19 @@ function SidebarRow({
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
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 (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
delayLongPress={350}
|
||||
accessibilityRole={isAccountHeader ? 'header' : 'button'}
|
||||
accessibilityLabel={accessibleName}
|
||||
accessibilityState={isAccountHeader ? undefined : { selected: isSelected }}
|
||||
style={({ pressed }) => [
|
||||
styles.row,
|
||||
isSelected && styles.rowSelected,
|
||||
@@ -454,8 +462,10 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
|
||||
React.useEffect(() => {
|
||||
if (!visible || !tagsExpanded || keywordDefs.length === 0 || !activeAccountId || !jmapClient.isConnected) return;
|
||||
const accounts = tagAccountsKey.split('|').map((id) => id || undefined);
|
||||
void ensureTagCounts(activeAccountId, keywordDefs.map((k) => k.id), accounts);
|
||||
}, [visible, tagsExpanded, keywordDefs, tagAccountsKey, activeAccountId, tagCountsGeneration, ensureTagCounts]);
|
||||
// Trash and Junk stay out of the counts as they do of the tag view (#1156).
|
||||
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) ───────────────────────────────────────
|
||||
const refFor = (mb: Mailbox) => ({
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getEmailTagIds } from '../lib/thread-utils';
|
||||
import type { Email } from '../api/types';
|
||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||
import { useSheetInsets } from '../lib/use-layout';
|
||||
import { NewTagRow } from './NewTagRow';
|
||||
|
||||
interface TagSheetProps {
|
||||
visible: boolean;
|
||||
@@ -132,6 +133,7 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
<NewTagRow keywords={keywords} onCreated={(token) => onToggle(token, true)} />
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</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 { 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 { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
getAttachmentDisplayName, visibleAttachments, formatSize,
|
||||
} from '../../lib/attachment-display';
|
||||
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 { AttachmentPreviewModal } from './AttachmentPreviewModal';
|
||||
import type { ExtractedAttachment } from './use-body-override';
|
||||
@@ -232,6 +234,9 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
|
||||
<View style={styles.row}>
|
||||
{visible.map((item) => {
|
||||
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 (
|
||||
<Pressable
|
||||
key={item.key}
|
||||
@@ -241,7 +246,7 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
|
||||
disabled={!!busyKey}
|
||||
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.size}>{formatSize(item.size)}</Text>
|
||||
<Download size={14} color={c.textMuted} />
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
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 { getEmailAttachments } from '../../api/email';
|
||||
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 {
|
||||
attachmentKind, realAttachments, requestListAttachments, shortAttachmentName,
|
||||
type AttachmentKind, type LoadListAttachments,
|
||||
type LoadListAttachments,
|
||||
} from '../../lib/list-attachments';
|
||||
import { ATTACHMENT_ICONS, attachmentIconColor } from './attachment-icons';
|
||||
import { getAttachmentDisplayName } from '../../lib/attachment-display';
|
||||
import { attachmentSaveName, downloadAttachment } from '../../lib/email-export';
|
||||
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. */
|
||||
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
|
||||
* 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 needsLoad = !!load && !!email.hasAttachment && !email.attachments;
|
||||
// Rows of a list spanning accounts can share an id (#1082).
|
||||
@@ -67,7 +47,10 @@ function useListAttachments(email: Email, load?: LoadListAttachments): Attachmen
|
||||
|
||||
interface ChipsProps {
|
||||
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;
|
||||
/** Selection mode: taps belong to the row. */
|
||||
disabled?: boolean;
|
||||
@@ -79,13 +62,23 @@ interface ChipsProps {
|
||||
* that file directly.
|
||||
*/
|
||||
export const ListAttachmentChips = React.memo(function ListAttachmentChips({
|
||||
email, load, onOpen, disabled,
|
||||
email, attachments, pending, onOpen, disabled,
|
||||
}: ChipsProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const dyn = useTypography();
|
||||
const attachments = useListAttachments(email, load);
|
||||
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;
|
||||
|
||||
const shown = real.slice(0, MAX_CHIPS);
|
||||
@@ -94,7 +87,7 @@ export const ListAttachmentChips = React.memo(function ListAttachmentChips({
|
||||
<View style={styles.row}>
|
||||
{shown.map((a, i) => {
|
||||
const kind = attachmentKind(a.type, a.name);
|
||||
const Icon = ICONS[kind];
|
||||
const Icon = ATTACHMENT_ICONS[kind];
|
||||
return (
|
||||
<Pressable
|
||||
key={`${a.blobId}-${i}`}
|
||||
@@ -106,7 +99,7 @@ export const ListAttachmentChips = React.memo(function ListAttachmentChips({
|
||||
accessibilityRole="button"
|
||||
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>
|
||||
</Pressable>
|
||||
);
|
||||
@@ -234,6 +227,8 @@ function makeStyles(c: ThemePalette) {
|
||||
backgroundColor: c.surface,
|
||||
},
|
||||
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 },
|
||||
more: {
|
||||
paddingHorizontal: 6,
|
||||
|
||||
@@ -22,6 +22,10 @@ export interface MessageContentProps {
|
||||
/** On screen rather than pre-rendered by the pager (gates auto-sent read receipts). */
|
||||
active: boolean;
|
||||
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;
|
||||
onZoomChange?: (zoom: { pinching: boolean; zoomed: boolean }) => void;
|
||||
onToggleStar?: (email: Email) => void;
|
||||
@@ -52,8 +56,8 @@ export interface MessageContentProps {
|
||||
* cards.
|
||||
*/
|
||||
export function MessageContent({
|
||||
email, jmapAccountId, identities, currentMailboxRole, active, themeOverride, onSwipe, onZoomChange,
|
||||
onToggleStar, onAddressPress, onEmailPatched, compact, deferBody, onBodySettled, fill,
|
||||
email, jmapAccountId, identities, currentMailboxRole, active, themeOverride, plainView, onShowFormatted,
|
||||
onSwipe, onZoomChange, onToggleStar, onAddressPress, onEmailPatched, compact, deferBody, onBodySettled, fill,
|
||||
}: MessageContentProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
@@ -124,6 +128,8 @@ export function MessageContent({
|
||||
onSwipe={onSwipe}
|
||||
onZoomChange={onZoomChange}
|
||||
themeOverride={themeOverride}
|
||||
plainView={plainView}
|
||||
onShowFormatted={onShowFormatted ? () => onShowFormatted(email) : undefined}
|
||||
bodyOverride={unwrap.override}
|
||||
onSettled={onBodySettled}
|
||||
fill={fill}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { useAccountKeywordDefs } from '../../stores/keywords-store';
|
||||
import { emailDisplayDate, formatHeaderDate, formatHeaderTime, formatFullDateTime } from '../../lib/email-date';
|
||||
import {
|
||||
deriveHeaderInfo, deliveryDeltaMs, formatDelta, isAuthenticationSpoofed, findReceivingIdentity,
|
||||
deriveHeaderInfo, deliveryDeltaMs, formatDelta, isAuthenticationSpoofed, findReceivingIdentity, viaIdentityNameShown,
|
||||
getSenderVerification, describeSenderVerification,
|
||||
type AuthenticationResults, type EmailHeaderInfo,
|
||||
} from '../../lib/email-headers';
|
||||
@@ -138,7 +138,7 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
|
||||
if (spoofed || identities.length === 0) return null;
|
||||
const fromEmail = from?.email?.toLowerCase();
|
||||
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);
|
||||
if (received && identities.length > 1 && received.id !== identities[0].id) {
|
||||
return { identity: received, direction: 'to' as const };
|
||||
@@ -172,7 +172,12 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
|
||||
accessibilityRole="button"
|
||||
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>
|
||||
<View style={styles.info}>
|
||||
<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 }]}>
|
||||
<Text style={[styles.badgeText, { color: c.primary }]}>
|
||||
{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 })}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -10,6 +10,8 @@ import SenderAvatar from '../SenderAvatar';
|
||||
import { MessageContent, type MessageContentProps } from './MessageContent';
|
||||
import { emailDisplayDate, formatHeaderDate, formatHeaderTime } from '../../lib/email-date';
|
||||
import { singleLine } from '../../lib/single-line';
|
||||
import { cleanPreview } from '../../lib/preview-text';
|
||||
import { useSenderWarning } from './use-sender-warning';
|
||||
|
||||
interface Props extends MessageContentProps {
|
||||
expanded: boolean;
|
||||
@@ -43,11 +45,12 @@ export function ThreadMessageCard({
|
||||
const unread = !email.keywords?.$seen;
|
||||
const starred = !!email.keywords?.$flagged;
|
||||
const date = emailDisplayDate(email);
|
||||
const senderWarning = useSenderWarning(expanded ? undefined : email);
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<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.collapsedTop}>
|
||||
<Text style={[styles.collapsedName, unread && styles.unread]} numberOfLines={1}>
|
||||
@@ -56,7 +59,7 @@ export function ThreadMessageCard({
|
||||
{email.hasAttachment && <Paperclip size={12} color={c.textMuted} />}
|
||||
<Text style={styles.collapsedDate}>{formatHeaderDate(date, locale)} {formatHeaderTime(date, timeFormat, locale)}</Text>
|
||||
</View>
|
||||
<Text style={styles.collapsedPreview} numberOfLines={1}>{singleLine(email.preview)}</Text>
|
||||
<Text style={styles.collapsedPreview} numberOfLines={1}>{singleLine(cleanPreview(email.preview))}</Text>
|
||||
</View>
|
||||
{starred && <Star size={14} color={c.starred} fill={c.starred} />}
|
||||
</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');
|
||||
});
|
||||
|
||||
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 () => {
|
||||
vi.mocked(File.downloadFileAsync).mockRejectedValueOnce(new Error('Download failed: 404'));
|
||||
|
||||
|
||||
@@ -85,6 +85,16 @@ describe('getSenderVerification', () => {
|
||||
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', () => {
|
||||
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');
|
||||
|
||||
@@ -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).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 { pickEmailBody, selectRenderableHtml } from './email-body';
|
||||
import { extractCidRefs } from './email-html';
|
||||
import { stripBidiControls } from './bidi';
|
||||
|
||||
const MIME_TYPE_LABELS: Record<string, string> = {
|
||||
'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 {
|
||||
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) {
|
||||
const base = mimeType.split(';')[0].trim().toLowerCase();
|
||||
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());
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
const base = (type || '').split(';')[0].trim().toLowerCase();
|
||||
return base === 'message/disposition-notification' || base === 'message/delivery-status';
|
||||
return REPORT_PART_TYPES.has((type || '').split(';')[0].trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function isRfc822Attachment(att: Pick<Attachment, 'name' | 'type'>): boolean {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import type { ContactCard } from '../api/types';
|
||||
import { getContactPhotoRef, type ContactPhotoRef } from './contact-utils';
|
||||
import { parseColor, relativeLuminance } from './event-colors';
|
||||
|
||||
// --- Account avatar -------------------------------------------------------
|
||||
|
||||
@@ -48,6 +49,44 @@ export function generateEmailAvatarColor(name: string, email?: string): string {
|
||||
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
|
||||
* out: the first letters of the first and last name part, ignoring
|
||||
@@ -196,3 +235,24 @@ export function getContactPhotoForEmail(
|
||||
const key = email?.trim().toLowerCase();
|
||||
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 {
|
||||
prepareEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml,
|
||||
type MessageSpacing, type PlainTextFont,
|
||||
type MessageSpacing, type PlainTextFont, type PlainTextColors,
|
||||
} from './email-html';
|
||||
import { collapsePlainTextQuotes, type QuoteCollapseLabels } from './quote-collapse';
|
||||
import { mailBaseFontPx } from './mail-reading';
|
||||
|
||||
export interface BodyDocumentInput {
|
||||
/** The message (account and id); the sources below are compared as well. */
|
||||
@@ -25,6 +26,13 @@ export interface BodyDocumentInput {
|
||||
messageSpacing: MessageSpacing;
|
||||
plainTextFont: PlainTextFont;
|
||||
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 {
|
||||
@@ -55,7 +63,12 @@ export function buildBodyDocument(input: BodyDocumentInput): BodyDocument {
|
||||
}
|
||||
const safe = collapsePlainTextQuotes(plainTextToSafeHtml(text), input.quoteLabels);
|
||||
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,
|
||||
hasNativeDark: false,
|
||||
blockedExternal: false,
|
||||
@@ -83,7 +96,8 @@ function sameSources(a: BodyDocumentInput, b: BodyDocumentInput): boolean {
|
||||
export function bodyDocument(input: BodyDocumentInput): BodyDocument {
|
||||
const key = JSON.stringify([
|
||||
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);
|
||||
if (hit && sameSources(hit.input, input)) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from './download-filename';
|
||||
import { getClientCertAlias, secureFetch } from './client-cert';
|
||||
import { sniffImageMime } from './email-html';
|
||||
import { stripBidiControls } from './bidi';
|
||||
import { handoffMimeType } from './file-preview';
|
||||
|
||||
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 {
|
||||
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);
|
||||
return `attachment.${fallbackExt}`;
|
||||
}
|
||||
|
||||
@@ -97,6 +97,21 @@ const SPF_SEVERITY: Record<SpfResult, number> = {
|
||||
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
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
method: string;
|
||||
result: string;
|
||||
@@ -323,6 +360,12 @@ function parseResinfos(header: string): ResInfo[] {
|
||||
.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> = {
|
||||
fail: 3,
|
||||
permerror: 2,
|
||||
@@ -394,7 +437,9 @@ export function parseAuthenticationResults(headers: string | readonly string[]):
|
||||
.filter((info) => info.method === 'dkim')
|
||||
.map((info) => ({
|
||||
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'],
|
||||
}));
|
||||
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
|
||||
// (monospace on request, #830), `white-space: pre-wrap`, native dark theme
|
||||
// (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(
|
||||
innerHtml: string,
|
||||
options: { isDark?: boolean; font?: PlainTextFont } = {},
|
||||
options: { isDark?: boolean; font?: PlainTextFont; fontPx?: number; darkColors?: PlainTextColors } = {},
|
||||
): string {
|
||||
const { isDark = true, font = 'sans' } = options;
|
||||
const { isDark = true, font = 'sans', fontPx = 14, darkColors } = options;
|
||||
const cleaned = stripDangerousTags(innerHtml);
|
||||
|
||||
const bg = isDark ? '#0a0a0a' : '#ffffff';
|
||||
const fg = isDark ? '#fafafa' : '#1a1a1a';
|
||||
const link = isDark ? '#60a5fa' : '#1a73e8';
|
||||
// In dark mode the body takes the app theme's ground, text and link colours
|
||||
// when they are given (a built-in dark theme isn't #0a0a0a); light mode
|
||||
// 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'
|
||||
? 'ui-monospace, Menlo, Consolas, "SF Mono", monospace'
|
||||
: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||
@@ -976,7 +992,7 @@ body {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
font-family: ${fontFamily};
|
||||
font-size: 14px;
|
||||
font-size: ${fontPx}px;
|
||||
line-height: 1.6;
|
||||
color: ${fg};
|
||||
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 { isReportPart } from './attachment-display';
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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[] {
|
||||
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;
|
||||
}
|
||||
function precedingTextEndsWithColon(el) {
|
||||
var walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
||||
// One walk in document order, remembering the last non-blank text: a
|
||||
// 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;
|
||||
for (var node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
var rel = el.compareDocumentPosition(node);
|
||||
if (rel & Node.DOCUMENT_POSITION_CONTAINED_BY) continue;
|
||||
if (!(rel & Node.DOCUMENT_POSITION_PRECEDING)) break;
|
||||
var text = (node.nodeValue || '').trim();
|
||||
if (text !== '') last = text;
|
||||
}
|
||||
return last !== null && /:$/.test(last);
|
||||
}
|
||||
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;
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
var text = (node.nodeValue || '').trim();
|
||||
if (text !== '') last = text;
|
||||
continue;
|
||||
}
|
||||
if (node.tagName !== 'BLOCKQUOTE') continue;
|
||||
if (node.parentElement && node.parentElement.closest('blockquote')) continue;
|
||||
if (last !== null && /:$/.test(last)) return node;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: 'ζ',
|
||||
};
|
||||
|
||||
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) => {
|
||||
if (ref[0] === '#') {
|
||||
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 { setPendingCalendarOpen } from './pending-calendar-open';
|
||||
import { setPendingMailSearch } from './pending-mail-search';
|
||||
import { setPendingMailFolder } from './pending-mail-folder';
|
||||
import { setPendingSignInLink, usePendingSignInLinkStore } from './pending-sign-in-link';
|
||||
import { insecurePairingLinkError, parseQrLoginPayload, type QrLoginPayload } from '../lib/oauth';
|
||||
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);
|
||||
return true;
|
||||
case 'folder':
|
||||
// The mail list opens the folder once its folders are loaded.
|
||||
setPendingMailFolder(link.ref);
|
||||
navigation.navigate('MainTabs', { screen: 'Mail' } as never);
|
||||
return true;
|
||||
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,
|
||||
Star, Paperclip, Mail as MailIcon, MailOpen, Trash2, RotateCcw, CalendarDays,
|
||||
Archive, FolderInput, Tag, Import, ArrowDownWideNarrow, ArrowUpNarrowWide,
|
||||
Pin, Reply, Forward, ShieldAlert, ShieldCheck, Folder,
|
||||
Pin, Reply, Forward, ShieldAlert, ShieldCheck, Folder, ArrowUpDown, Check,
|
||||
} 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 type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
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 { OfflineBanner } from '../components/OfflineBanner';
|
||||
import {
|
||||
ListAttachmentChips, ListAttachmentOpener, useListRowAttachments,
|
||||
ListAttachmentChips, ListAttachmentOpener, useListAttachments, useListRowAttachments,
|
||||
} from '../components/email/ListAttachmentChips';
|
||||
import { showsPaperclip } from '../lib/list-attachments';
|
||||
import { VerificationCodeChip, useListVerificationCode } from '../components/email/VerificationCodeChip';
|
||||
import { useSenderWarning } from '../components/email/use-sender-warning';
|
||||
import type { LoadListAttachments } from '../lib/list-attachments';
|
||||
import { useNetworkStore } from '../stores/network-store';
|
||||
import {
|
||||
@@ -47,6 +52,7 @@ import { toast } from '../stores/toast-store';
|
||||
import { getContactDisplayName } from '../lib/contact-utils';
|
||||
import { formatListDate } from '../lib/date-format';
|
||||
import { singleLine } from '../lib/single-line';
|
||||
import { cleanPreview } from '../lib/preview-text';
|
||||
import {
|
||||
findTrashMailbox, findArchiveMailbox, findJunkMailbox, mailboxesForSiblingOf, ownMailboxes,
|
||||
} from '../lib/mailbox-tree';
|
||||
@@ -55,10 +61,16 @@ import {
|
||||
collapseThreads, groupByThread, expandThreadSelection, getThreadTagIds, threadKeyOf, accountScopedId, rowKeyOf,
|
||||
} from '../lib/thread-utils';
|
||||
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 { getFullEmail, emptyMailbox as apiEmptyMailbox } from '../api/email';
|
||||
import type { RootStackParamList } from '../navigation/types';
|
||||
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 { createFrameMonitor, logModuleTimes, perfOnce, perfStart } from '../lib/perf-marks';
|
||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||
@@ -100,6 +112,19 @@ function isPinned(email: Email): boolean {
|
||||
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),
|
||||
// used to anchor the unread dot on that first line.
|
||||
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 handleLongPress = React.useCallback(() => onLongPress(key), [onLongPress, key]);
|
||||
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 (
|
||||
<Pressable
|
||||
@@ -210,12 +240,27 @@ const EmailRow = React.memo(function EmailRow({
|
||||
</View>
|
||||
)}
|
||||
{density.showAvatar && (
|
||||
<SenderAvatar
|
||||
name={senderName}
|
||||
email={senderEmail}
|
||||
size={componentSizes.avatarMd}
|
||||
disableImages={disableAvatarImages}
|
||||
/>
|
||||
// A tap on the avatar picks the row and starts selection mode, like
|
||||
// a long press (#91).
|
||||
<Pressable
|
||||
onPress={handleLongPress}
|
||||
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 */}
|
||||
@@ -238,7 +283,7 @@ const EmailRow = React.memo(function EmailRow({
|
||||
{forwarded && (
|
||||
<Forward size={componentSizes.statusIcon} color={c.textMuted} />
|
||||
)}
|
||||
{item.hasAttachment && (
|
||||
{showsPaperclip(item.hasAttachment, attachments) && (
|
||||
<Paperclip size={componentSizes.statusIcon} color={c.textMuted} />
|
||||
)}
|
||||
</View>
|
||||
@@ -271,14 +316,15 @@ const EmailRow = React.memo(function EmailRow({
|
||||
{/* Row 3: Preview - hidden in compact density modes regardless of toggle */}
|
||||
{showPreview && density.showPreview && (
|
||||
<Text style={[styles.emailPreview, dyn.body]} numberOfLines={2}>
|
||||
{singleLine(item.preview)}
|
||||
{singleLine(cleanPreview(item.preview))}
|
||||
</Text>
|
||||
)}
|
||||
{verificationCode && <VerificationCodeChip code={verificationCode} disabled={selectionMode} inList />}
|
||||
{onOpenAttachment && (
|
||||
<ListAttachmentChips
|
||||
email={item}
|
||||
load={loadAttachments}
|
||||
attachments={attachments}
|
||||
pending={!!loadAttachments && item.hasAttachment && !item.attachments}
|
||||
onOpen={onOpenAttachment}
|
||||
disabled={selectionMode}
|
||||
/>
|
||||
@@ -419,6 +465,13 @@ export default function EmailListScreen({
|
||||
const currentMailboxId = useEmailStore((s) => s.currentMailboxId);
|
||||
const storeSearchQuery = useEmailStore((s) => s.searchQuery);
|
||||
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 fetchMailboxes = useEmailStore((s) => s.fetchMailboxes);
|
||||
const ensureMailboxes = useEmailStore((s) => s.ensureMailboxes);
|
||||
@@ -440,7 +493,7 @@ export default function EmailListScreen({
|
||||
const moveEmailsToMailbox = useEmailStore((s) => s.moveEmailsToMailbox);
|
||||
const deleteEmailsBatch = useEmailStore((s) => s.deleteEmailsBatch);
|
||||
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 unmarkSpam = useEmailStore((s) => s.unmarkSpam);
|
||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
@@ -475,6 +528,15 @@ export default function EmailListScreen({
|
||||
[mailboxes, currentMailboxId],
|
||||
);
|
||||
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).
|
||||
const currentOwnerAccountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined;
|
||||
|
||||
@@ -721,7 +783,11 @@ export default function EmailListScreen({
|
||||
deleteAction,
|
||||
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);
|
||||
else await deleteEmailsBatch(ids, trashMailboxId, currentMailboxId);
|
||||
}, [currentMailboxId, trashMailboxId, inJunk, deleteAction, permanentlyDeleteJunk, deleteEmailAction, deleteEmailsBatch, t]);
|
||||
@@ -734,8 +800,10 @@ export default function EmailListScreen({
|
||||
case 'archive':
|
||||
if (archiveMailboxId && currentMailboxId !== archiveMailboxId) {
|
||||
const ids = idsForRow(id);
|
||||
if (ids.length > 1) reportFailure(archiveEmailsBatch(ids));
|
||||
else reportFailure(archiveEmailAction(id));
|
||||
reportFailure(confirmMailRemoval('archive', ids.length, t).then((ok) => {
|
||||
if (!ok) return undefined;
|
||||
return ids.length > 1 ? archiveEmailsBatch(ids) : archiveEmailAction(id);
|
||||
}));
|
||||
} else if (!archiveMailboxId) {
|
||||
Alert.alert(
|
||||
t('email_list.error', 'Error'),
|
||||
@@ -857,6 +925,14 @@ export default function EmailListScreen({
|
||||
() => expandThreadSelection(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 wanted = new Set(selectedMessageIds);
|
||||
return emails.filter((e) => wanted.has(rowKeyOf(e)));
|
||||
@@ -900,12 +976,17 @@ export default function EmailListScreen({
|
||||
deleteAction,
|
||||
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);
|
||||
clearSelection();
|
||||
};
|
||||
|
||||
const handleBulkArchive = async () => {
|
||||
if (!(await confirmMailRemoval('archive', selectedMessageIds.length, t))) return;
|
||||
await archiveEmailsBatch(selectedMessageIds);
|
||||
clearSelection();
|
||||
};
|
||||
@@ -1127,6 +1208,18 @@ export default function EmailListScreen({
|
||||
}
|
||||
}, [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(() => {
|
||||
if (mailboxes.length > 0 && !currentMailboxId) {
|
||||
const own = ownMailboxes(mailboxes);
|
||||
@@ -1190,17 +1283,17 @@ export default function EmailListScreen({
|
||||
</View>
|
||||
<Pressable
|
||||
style={styles.filterButton}
|
||||
onPress={() => setSortAscending(!sortAscending)}
|
||||
onPress={() => setSortMenuOpen(true)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={sortAscending
|
||||
? t('settings.appearance.message_list_order.direction.oldest_first', 'Oldest first')
|
||||
: t('settings.appearance.message_list_order.direction.newest_first', 'Newest first')}
|
||||
accessibilityHint={t('email_list.sort_toggle_hint', 'Reverses the mail sort order')}
|
||||
accessibilityLabel={t('email_list.sort.title', 'Sort')}
|
||||
accessibilityValue={currentSortChoice ? { text: sortChoiceLabel(currentSortChoice, t) } : undefined}
|
||||
>
|
||||
{sortAscending ? (
|
||||
{currentSortChoice === 'newest' ? (
|
||||
<ArrowDownWideNarrow size={18} color={c.textMuted} />
|
||||
) : currentSortChoice === 'oldest' ? (
|
||||
<ArrowUpNarrowWide size={18} color={c.primary} />
|
||||
) : (
|
||||
<ArrowDownWideNarrow size={18} color={c.textMuted} />
|
||||
<ArrowUpDown size={18} color={c.primary} />
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
@@ -1621,6 +1714,7 @@ export default function EmailListScreen({
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={visibleEmails}
|
||||
keyExtractor={emailKeyExtractor}
|
||||
renderItem={renderEmailRow}
|
||||
@@ -1764,17 +1858,18 @@ export default function EmailListScreen({
|
||||
active={folderScope === 'current'}
|
||||
onPress={() => setFolderScope('current')}
|
||||
/>
|
||||
{scopedMailboxes
|
||||
.filter((m) => m.id !== currentMailboxId)
|
||||
.slice(0, 12)
|
||||
.map((m) => (
|
||||
</View>
|
||||
{/* Every folder, as a tree (it was a flat list cut at 12). */}
|
||||
<View style={styles.scopeFolderList}>
|
||||
{searchScopeFolders(scopedMailboxes, currentMailboxId).map((m) => (
|
||||
<View key={m.id} style={{ marginLeft: m.depth * spacing.lg }}>
|
||||
<ScopeChip
|
||||
key={m.id}
|
||||
label={localizeMailboxName(m.role, m.name, t)}
|
||||
active={folderScope === m.id}
|
||||
onPress={() => setFolderScope(m.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1905,11 +2000,11 @@ export default function EmailListScreen({
|
||||
visible={pendingMoveId !== null}
|
||||
onClose={() => setPendingMoveId(null)}
|
||||
mailboxes={mailboxes}
|
||||
currentMailboxId={currentMailboxId}
|
||||
currentMailboxId={pendingMoveCurrentId}
|
||||
onPick={(toId) => {
|
||||
const id = pendingMoveId;
|
||||
setPendingMoveId(null);
|
||||
if (id && currentMailboxId && toId !== currentMailboxId) {
|
||||
if (id && currentMailboxId && toId !== pendingMoveCurrentId) {
|
||||
const ids = idsForRow(id);
|
||||
if (ids.length > 1) reportFailure(moveEmailsToMailbox(ids, 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
|
||||
visible={batchMoveOpen}
|
||||
onClose={() => setBatchMoveOpen(false)}
|
||||
mailboxes={mailboxes}
|
||||
currentMailboxId={currentMailboxId}
|
||||
currentMailboxId={moveSheetCurrentId(mailboxes, currentMailboxId, selectedEmails)}
|
||||
onPick={handleBatchMovePick}
|
||||
/>
|
||||
|
||||
@@ -2308,6 +2432,7 @@ function makeStyles(c: ThemePalette) {
|
||||
gap: spacing.sm,
|
||||
marginTop: 4,
|
||||
},
|
||||
scopeFolderList: { alignItems: 'flex-start', gap: spacing.xs, marginTop: spacing.sm },
|
||||
triToggle: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -11,8 +11,11 @@ import {
|
||||
ArrowLeft, Star, Trash2, MoreVertical, Reply, ReplyAll, Forward,
|
||||
ChevronLeft, ChevronRight, Archive, Mail, MailOpen,
|
||||
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';
|
||||
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 { isSieveSupported } from '../api/sieve';
|
||||
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 { buildForwardAsAttachmentPayload } from '../lib/forward-as-attachment';
|
||||
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 { RootStackParamList } from '../navigation/types';
|
||||
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);
|
||||
// Per-message override of the light/dark rendering (More sheet toggle).
|
||||
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
|
||||
// (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;
|
||||
}
|
||||
performDelete();
|
||||
// "Confirm before deleting" (webmail lib/mail-removal-confirm.ts).
|
||||
void confirmMailRemoval('delete', 1, t).then((ok) => { if (ok) performDelete(); });
|
||||
};
|
||||
|
||||
const onArchive = () => {
|
||||
if (!email || !canArchive) return;
|
||||
archiveEmailAction(email.id, { email, accountId: ownerAccountId })
|
||||
.catch((err) => toastFailure(t('notifications.error_archiving', 'Failed to archive email'), err));
|
||||
navigation.goBack();
|
||||
void confirmMailRemoval('archive', 1, t).then((ok) => {
|
||||
if (!ok) return;
|
||||
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
|
||||
@@ -679,7 +694,8 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
|
||||
label: t('email_viewer.tag', 'Tag'),
|
||||
icon: (s, col) => <Tag size={s} color={col} />,
|
||||
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 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.
|
||||
const activeRenderDark = email
|
||||
? (themeOverrides[email.id] ? themeOverrides[email.id] === 'dark' : !emailAlwaysLightMode && resolvedTheme === 'dark')
|
||||
@@ -817,6 +837,8 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
|
||||
currentMailboxRole={currentMailboxRole}
|
||||
identities={identities}
|
||||
themeOverrides={themeOverrides}
|
||||
plainOverrides={plainOverrides}
|
||||
onShowFormatted={showFormatted}
|
||||
ensureDetail={ensureDetail}
|
||||
ensureDetails={ensureDetails}
|
||||
ensureThread={ensureThread}
|
||||
@@ -878,8 +900,14 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
|
||||
isInJunk={isInJunk}
|
||||
canViewSource={!!email?.blobId}
|
||||
canExport={!!email?.blobId}
|
||||
canTag={keywordDefs.length > 0}
|
||||
canTag
|
||||
renderDark={activeRenderDark}
|
||||
plainView={activeHasHtml ? activePlainView : null}
|
||||
onTogglePlainView={() => {
|
||||
setMoreMenuOpen(false);
|
||||
if (!email) return;
|
||||
setPlainOverrides((prev) => ({ ...prev, [email.id]: !activePlainView }));
|
||||
}}
|
||||
hasSender={!!email?.from?.[0]?.email}
|
||||
onArchive={() => { setMoreMenuOpen(false); onArchive(); }}
|
||||
onToggleUnread={() => { setMoreMenuOpen(false); onToggleUnread(); }}
|
||||
@@ -971,6 +999,8 @@ interface EmailPaneProps {
|
||||
currentMailboxRole: string | null;
|
||||
identities: Identity[];
|
||||
themeOverrides: Record<string, 'light' | 'dark'>;
|
||||
plainOverrides: Record<string, boolean>;
|
||||
onShowFormatted: (email: Email) => void;
|
||||
ensureDetail: (id: string) => Promise<Email | null>;
|
||||
ensureDetails: (ids: string[]) => void;
|
||||
ensureThread: (threadId: string) => void;
|
||||
@@ -986,6 +1016,8 @@ interface EmailPaneProps {
|
||||
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
|
||||
// conversation as collapsible cards (newest + unread expanded, mark-read on
|
||||
// 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.
|
||||
function EmailPane({
|
||||
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,
|
||||
onEmailPatched, onReply, onSwipe, onZoomChange,
|
||||
}: EmailPaneProps) {
|
||||
const c = useColors();
|
||||
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-
|
||||
// finger zoom can't fling the page.
|
||||
const [pinching, setPinching] = React.useState(false);
|
||||
@@ -1042,6 +1077,19 @@ function EmailPane({
|
||||
|
||||
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
|
||||
// skeleton's early return: a page opened by id alone (a notification)
|
||||
// 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.
|
||||
const members = conversation ?? [shown];
|
||||
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.
|
||||
const newestLoaded = !!newest && !!peekDetail(newest.id, jmapAccountId);
|
||||
const onPaneZoom = (z: { pinching: boolean; zoomed: boolean }) => {
|
||||
@@ -1209,7 +1257,7 @@ function EmailPane({
|
||||
fill={!asConversation}
|
||||
email={full ?? m}
|
||||
expanded={open}
|
||||
replyable={!!full}
|
||||
replyable={!!full && isReplyableMember(m, scheduledIds)}
|
||||
deferBody={!full || !bodyEnabled}
|
||||
onBodySettled={onBodySettled}
|
||||
onToggleExpanded={() => toggleCard(m.id)}
|
||||
@@ -1219,6 +1267,8 @@ function EmailPane({
|
||||
currentMailboxRole={currentMailboxRole}
|
||||
active={active}
|
||||
themeOverride={themeOverrides[m.id] ?? null}
|
||||
plainView={plainOverrides[m.id] ?? null}
|
||||
onShowFormatted={onShowFormatted}
|
||||
onSwipe={onSwipe}
|
||||
onZoomChange={onPaneZoom}
|
||||
onToggleStar={onToggleStar}
|
||||
@@ -1238,7 +1288,7 @@ function EmailPane({
|
||||
</View>
|
||||
|
||||
<View style={styles.quickReplySlot}>
|
||||
{newest && newestLoaded && (
|
||||
{quickReplyEnabled && newest && newestLoaded && (
|
||||
<QuickReplyBox
|
||||
email={newest}
|
||||
jmapAccountId={jmapAccountId}
|
||||
@@ -1307,6 +1357,9 @@ interface MoreMenuSheetProps {
|
||||
canViewSource: boolean;
|
||||
canExport: 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;
|
||||
onArchive: () => void;
|
||||
onToggleUnread: () => void;
|
||||
@@ -1324,7 +1377,7 @@ interface MoreMenuSheetProps {
|
||||
|
||||
function MoreMenuSheet({
|
||||
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,
|
||||
onForwardAsAttachment, onRules, onViewSource, onExport,
|
||||
}: MoreMenuSheetProps) {
|
||||
@@ -1430,6 +1483,13 @@ function MoreMenuSheet({
|
||||
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
|
||||
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')}
|
||||
@@ -1539,14 +1599,14 @@ function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }:
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
{keywords.length === 0 ? (
|
||||
<Text style={{ ...typography.body, color: c.textMuted, paddingVertical: spacing.lg, paddingHorizontal: spacing.lg }}>
|
||||
{t('email_viewer.tag_no_matches', 'No matching tags')}
|
||||
</Text>
|
||||
) : (
|
||||
// Scrolls like the More sheet: a long tag list (or a landscape
|
||||
// window) ran past the screen with no way to reach the last tags.
|
||||
<ScrollView style={styles.sheetScroll} bounces={false}>
|
||||
{/* Scrolls like the More sheet: a long tag list (or a landscape
|
||||
window) ran past the screen with no way to reach the last tags. */}
|
||||
<ScrollView style={styles.sheetScroll} bounces={false} keyboardShouldPersistTaps="handled">
|
||||
{keywords.length === 0 && (
|
||||
<Text style={{ ...typography.body, color: c.textMuted, paddingVertical: spacing.lg, paddingHorizontal: spacing.lg }}>
|
||||
{t('email_viewer.tag_no_matches', 'No matching tags')}
|
||||
</Text>
|
||||
)}
|
||||
{keywords.map((kw) => {
|
||||
const token = keywordToken(kw.id);
|
||||
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>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -23,9 +23,12 @@ import { useSettingsStore, type SwipeAction } from '../stores/settings-store';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { formatListDate } from '../lib/date-format';
|
||||
import { singleLine } from '../lib/single-line';
|
||||
import { cleanPreview } from '../lib/preview-text';
|
||||
import { prefetchMessage, rememberRows } from '../lib/email-detail-cache';
|
||||
import { isPermanentDelete, confirmPermanentDelete } from '../lib/delete-confirm';
|
||||
import { confirmMailRemoval } from '../lib/mail-removal-confirm';
|
||||
import { onStateChangeType } from '../lib/state-change-bus';
|
||||
import { describeSenderVerification, senderWarningOf } from '../lib/email-headers';
|
||||
import { createUnifiedReloadTracker } from '../lib/unified-reload';
|
||||
import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
@@ -243,9 +246,12 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
|
||||
patchRows(targets, { $flagged: starred ? true : null });
|
||||
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);
|
||||
moveUnifiedEmails(targets, 'archive').catch(fail);
|
||||
return true;
|
||||
};
|
||||
const spam = (targets: UnifiedEmail[]) => {
|
||||
removeRows(targets);
|
||||
@@ -253,24 +259,29 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
|
||||
markRead: role !== 'junk' && deleteAction === 'trash-and-read',
|
||||
}).catch(fail);
|
||||
};
|
||||
const remove = async (targets: UnifiedEmail[]) => {
|
||||
const remove = async (targets: UnifiedEmail[]): Promise<boolean> => {
|
||||
const permanent = targets.some((e) => isPermanentDelete({
|
||||
inTrash: role === 'trash' || isInUnifiedTrash(e),
|
||||
inJunk: role === 'junk',
|
||||
deleteAction,
|
||||
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);
|
||||
deleteUnifiedEmails(targets, {
|
||||
permanent: deleteAction === 'permanent' || (permanentlyDeleteJunk && role === 'junk'),
|
||||
markRead: deleteAction === 'trash-and-read',
|
||||
}).catch(fail);
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSwipe = (email: UnifiedEmail, action: SwipeAction) => {
|
||||
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 'spam': if (role !== 'sent' && role !== 'drafts') spam([email]); 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 starred = !!item.keywords?.$flagged;
|
||||
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 (
|
||||
<SwipeableRow
|
||||
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}
|
||||
size={componentSizes.avatarMd}
|
||||
disableImages={role === 'junk' && !showAvatarsInJunk}
|
||||
senderWarning={verification ? describeSenderVerification(verification, t).label : undefined}
|
||||
/>
|
||||
{acc && accountIds.length > 1 && (
|
||||
<View style={[styles.accountDot, { backgroundColor: acc.avatarColor }]} />
|
||||
@@ -383,7 +397,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
|
||||
</View>
|
||||
{/* Same settings as the folder list: preview on/off, density, text size. */}
|
||||
{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}
|
||||
</View>
|
||||
</Pressable>
|
||||
@@ -440,7 +454,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
|
||||
)}
|
||||
{role !== 'archive' && (
|
||||
<Pressable
|
||||
onPress={() => { archive(selectedEmails); clearSelection(); }}
|
||||
onPress={() => { void archive(selectedEmails).then((done) => { if (done) clearSelection(); }); }}
|
||||
style={styles.headerBtn}
|
||||
hitSlop={6}
|
||||
accessibilityRole="button"
|
||||
@@ -450,7 +464,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable
|
||||
onPress={() => { void remove(selectedEmails).then(clearSelection); }}
|
||||
onPress={() => { void remove(selectedEmails).then((done) => { if (done) clearSelection(); }); }}
|
||||
style={styles.headerBtn}
|
||||
hitSlop={6}
|
||||
accessibilityRole="button"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { Email } from '../../api/types';
|
||||
import type { Email, Mailbox } from '../../api/types';
|
||||
import {
|
||||
boundEmailCache,
|
||||
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', () => {
|
||||
const { partialize, merge } = useEmailStore.persist.getOptions();
|
||||
|
||||
@@ -149,6 +151,7 @@ describe('email-store persistence', () => {
|
||||
it('restores the open folder into the list on hydration', () => {
|
||||
const live = rows('live', 3);
|
||||
const persisted = JSON.parse(JSON.stringify(partialize!(viewState({
|
||||
mailboxes: [INBOX],
|
||||
emails: live,
|
||||
totalEmails: 42,
|
||||
queryState: 'q2',
|
||||
@@ -170,7 +173,7 @@ describe('email-store persistence', () => {
|
||||
const legacy = {
|
||||
accountSnapshots: {},
|
||||
activeAccountId: 'me',
|
||||
mailboxes: [],
|
||||
mailboxes: [INBOX],
|
||||
emailStates: {},
|
||||
currentMailboxId: 'inbox',
|
||||
mailboxSnapshots: { inbox: snap },
|
||||
@@ -190,4 +193,24 @@ describe('email-store persistence', () => {
|
||||
const initial = useEmailStore.getInitialState();
|
||||
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('o2', 'archive', 10, 'own red 2', { keywords: { [RED]: true, $seen: true } }),
|
||||
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: [
|
||||
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(server.current!.requests).toHaveLength(1);
|
||||
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([
|
||||
['c', { hasKeyword: RED }],
|
||||
['team', { hasKeyword: RED }],
|
||||
['c', { operator: 'AND', conditions: [{ hasKeyword: RED }, { inMailboxOtherThan: ['trash', 'junk'] }] }],
|
||||
['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');
|
||||
expect(last.map(([, a]) => a.filter)).toEqual([
|
||||
{ operator: 'AND', conditions: [{ text: 'red 2' }, { hasKeyword: RED }] },
|
||||
{ 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 }, { 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');
|
||||
expect(last[0][1].filter).toEqual({
|
||||
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));
|
||||
|
||||
@@ -1566,6 +1566,44 @@ describe('email-store', () => {
|
||||
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 () => {
|
||||
const mockUndoSpam = emailApi.undoSpam as ReturnType<typeof vi.fn>;
|
||||
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,
|
||||
openRole?: string | null,
|
||||
): boolean {
|
||||
if (filters.keyword) return false;
|
||||
if (filters.folder === SCOPE_EXCEPT_TRASH_JUNK) return true;
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -315,11 +318,34 @@ function openRoleOf(state: Partial<Pick<EmailState, 'mailboxes' | 'currentMailbo
|
||||
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.
|
||||
// We store the full email object so undo can re-insert it into the visible list
|
||||
// optimistically without waiting for a refetch.
|
||||
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"). */
|
||||
label: string;
|
||||
/** 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).
|
||||
function trashAndJunkIds(mailboxes: Mailbox[], accountId: string | undefined): string[] {
|
||||
export function trashAndJunkIds(mailboxes: Mailbox[], accountId: string | undefined): string[] {
|
||||
return accountMailboxes(mailboxes, accountId)
|
||||
.filter((m) => isTrashOrJunkRole(m.role))
|
||||
.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): {
|
||||
mailboxes: Mailbox[];
|
||||
mailboxState?: string;
|
||||
@@ -2106,6 +2142,26 @@ export const useEmailStore = create<EmailState>()(
|
||||
for (const { accountId, emails } of written) {
|
||||
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;
|
||||
},
|
||||
|
||||
@@ -2114,6 +2170,44 @@ export const useEmailStore = create<EmailState>()(
|
||||
if (!entry) return;
|
||||
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.
|
||||
const accountOf = (it: UndoEntry['items'][number]) => it.accountId ?? entry.accountId;
|
||||
const byAccount = new Map<string, { accountId?: string; items: UndoEntry['items'] }>();
|
||||
@@ -2332,7 +2426,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
merge: (persisted, current) => {
|
||||
if (!persisted) return current;
|
||||
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;
|
||||
// Push only for mail that lands in the Inbox (webmail #983, same key).
|
||||
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 = {
|
||||
@@ -332,7 +335,7 @@ const DEFAULT_PERSISTED: PersistedSettings = {
|
||||
externalContentPolicy: 'ask',
|
||||
trustedSenders: [],
|
||||
trustedSendersAddressBook: null,
|
||||
senderFavicons: true,
|
||||
senderFavicons: false,
|
||||
hideInlineImageAttachments: true,
|
||||
|
||||
groupContactsByLetter: true,
|
||||
@@ -482,6 +485,8 @@ const DEFAULT_PERSISTED: PersistedSettings = {
|
||||
// platform:
|
||||
toastDuration: -1,
|
||||
pushNotifyInboxOnly: false,
|
||||
// reader:
|
||||
quickReplyEnabled: true,
|
||||
};
|
||||
|
||||
export interface SettingsState extends PersistedSettings {
|
||||
|
||||
@@ -20,7 +20,13 @@ interface TagCountsState {
|
||||
/** `generation` the counts were fetched at. */
|
||||
fetchedAt: number;
|
||||
/** 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. */
|
||||
invalidate: () => void;
|
||||
}
|
||||
@@ -35,8 +41,8 @@ export const useTagCountsStore = create<TagCountsState>((set, get) => ({
|
||||
generation: 0,
|
||||
fetchedAt: -1,
|
||||
|
||||
ensure: (login, tagIds, accountIds) => {
|
||||
const key = `${login}|${accountIds.map((id) => id ?? '').join(',')}|${tagIds.join(',')}`;
|
||||
ensure: (login, tagIds, accountIds, excluded = {}) => {
|
||||
const key = `${login}|${accountIds.map((id) => id ?? '').join(',')}|${tagIds.join(',')}|${JSON.stringify(excluded)}`;
|
||||
if (get().login !== login) set({ counts: {}, login, key: null });
|
||||
const { generation } = get();
|
||||
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 promise = (async () => {
|
||||
try {
|
||||
const list = await fetchTagCounts(tagIds, accountIds);
|
||||
const list = await fetchTagCounts(tagIds, accountIds, excluded);
|
||||
// A later ensure (another login, other tags) supersedes this one.
|
||||
if (inflight?.seq !== seq || get().login !== login) return;
|
||||
set({ counts: Object.fromEntries(list.map((c) => [c.id, c])), key, fetchedAt: generation });
|
||||
|
||||
Reference in New Issue
Block a user