feat: tags, pins, recipients, not-spam, delete confirm, folder-scoped search and draft editing in the mail list

This commit is contained in:
Linus Rath
2026-08-29 18:23:08 +02:00
parent f07e039ef0
commit 3194b5ece1
7 changed files with 858 additions and 191 deletions
+24 -24
View File
@@ -7,7 +7,7 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou
### Folder tree / mailboxes ### Folder tree / mailboxes
- [ ] **Folders whose name contains a system folder name disappear (#771)** — `P2` — `bugfix-parity` - [x] **Folders whose name contains a system folder name disappear (#771)** — `P2` — `bugfix-parity` — fixed in 516c9c9
- What WEB does: dedup only drops a root folder whose name is an *exact* (trimmed, case-insensitive) match of a role folder in the same account (`lib/utils.ts:270-333`, comment cites #771; changelog 1.9.0). - What WEB does: dedup only drops a root folder whose name is an *exact* (trimmed, case-insensitive) match of a role folder in the same account (`lib/utils.ts:270-333`, comment cites #771; changelog 1.9.0).
- What RN does: `deduplicate()` in `src/lib/mailbox-tree.ts:49-68` uses `lower.includes(rn) || rn.includes(lower)` — "Old Inbox", "2025 Archive", "Sent to Accounting" vanish from the drawer and the move sheet (also from `findTrashMailbox` etc. only indirectly). - What RN does: `deduplicate()` in `src/lib/mailbox-tree.ts:49-68` uses `lower.includes(rn) || rn.includes(lower)` — "Old Inbox", "2025 Archive", "Sent to Accounting" vanish from the drawer and the move sheet (also from `findTrashMailbox` etc. only indirectly).
- Fix hint: replace the substring test with `r.name.trim().toLowerCase() === lower`; keep the nested/parent guards. Add a case to `src/lib/__tests__/mailbox-tree.test.ts`. - Fix hint: replace the substring test with `r.name.trim().toLowerCase() === lower`; keep the nested/parent guards. Add a case to `src/lib/__tests__/mailbox-tree.test.ts`.
@@ -42,7 +42,7 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou
- What RN does: drawer always renders the virtual "Scheduled" quick row (`src/components/SidebarDrawer.tsx:447-453`) and would also list a Stalwart `role: scheduled` mailbox as a plain folder. - What RN does: drawer always renders the virtual "Scheduled" quick row (`src/components/SidebarDrawer.tsx:447-453`) and would also list a Stalwart `role: scheduled` mailbox as a plain folder.
- Fix hint: skip nodes with `role === 'scheduled'` in `buildMailboxTree`/drawer when `jmapClient.hasDelayedSend()`. - Fix hint: skip nodes with `role === 'scheduled'` in `buildMailboxTree`/drawer when `jmapClient.hasDelayedSend()`.
- [ ] **Empty folder (Trash/Junk) — with #711 pagination** — `P2` — `missing` - [x] **Empty folder (Trash/Junk) — with #711 pagination** — `P2` — `missing` — fixed in PENDING-B
- What WEB does: "Empty folder" banner/button in Trash & Junk and in the folder context menu (`components/email/email-list.tsx:457-479`, `components/layout/mailbox-context-menu.tsx:213-219`); `client.emptyMailbox` loops `Email/query` + back-referenced `Email/set destroy` in batches of `min(500, maxObjectsInSet)` and never gates on `total` (`lib/jmap/client.ts:2288-2325`, changelog 1.8.1 #711); store zeroes counters (`stores/email-store.ts:3958`). - What WEB does: "Empty folder" banner/button in Trash & Junk and in the folder context menu (`components/email/email-list.tsx:457-479`, `components/layout/mailbox-context-menu.tsx:213-219`); `client.emptyMailbox` loops `Email/query` + back-referenced `Email/set destroy` in batches of `min(500, maxObjectsInSet)` and never gates on `total` (`lib/jmap/client.ts:2288-2325`, changelog 1.8.1 #711); store zeroes counters (`stores/email-store.ts:3958`).
- What RN does: nothing — the only way is select-all-on-page + delete, page by page. - What RN does: nothing — the only way is select-all-on-page + delete, page by page.
- Fix hint: add `emptyMailbox(mailboxId, accountId?)` to `src/api/email.ts` mirroring the WEB loop (stop when `found.length === 0 || destroyed === 0 || found.length < batch`), a confirm `Alert`, a button in the list header when `currentMailbox.role` is trash/junk, then `refreshEmails()` + `fetchMailboxes()` and `dropFromCache`. - Fix hint: add `emptyMailbox(mailboxId, accountId?)` to `src/api/email.ts` mirroring the WEB loop (stop when `found.length === 0 || destroyed === 0 || found.length < batch`), a confirm `Alert`, a button in the list header when `currentMailbox.role` is trash/junk, then `refreshEmails()` + `fetchMailboxes()` and `dropFromCache`.
@@ -52,12 +52,12 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou
- What RN does: only per-message and batch-on-loaded-page mark read (`EmailListScreen.tsx:456-464`). - What RN does: only per-message and batch-on-loaded-page mark read (`EmailListScreen.tsx:456-464`).
- Fix hint: long-press on a drawer folder row → action sheet (Mark all read / Empty / Rename / Delete / New subfolder); api `markMailboxAsRead(rawId, accountId)` with the paged loop; then `fetchMailboxes()` and, if current, `refreshEmails()`. - Fix hint: long-press on a drawer folder row → action sheet (Mark all read / Empty / Rename / Delete / New subfolder); api `markMailboxAsRead(rawId, accountId)` with the paged loop; then `fetchMailboxes()` and, if current, `refreshEmails()`.
- [ ] **Retry button after a failed first load does not retry the mailbox fetch** — `P2` — `rn-only-bug` - [x] **Retry button after a failed first load does not retry the mailbox fetch** — `P2` — `rn-only-bug` — fixed in PENDING-B
- What WEB does: retries mailbox fetch on first login for lazy provisioning (#217) and keeps the tree on later failures (#780). - What WEB does: retries mailbox fetch on first login for lazy provisioning (#217) and keeps the tree on later failures (#780).
- What RN does: when `fetchMailboxes` fails with zero mailboxes it sets `error` (`src/stores/email-store.ts:610-612`); the list shows the error with a "Retry" that calls `refreshEmails()` (`src/screens/EmailListScreen.tsx:812`), which returns immediately because `currentMailboxId` is null (`email-store.ts:773`). The mount effect only re-runs when `mailboxes.length` changes, so the user is stuck until an app restart. - What RN does: when `fetchMailboxes` fails with zero mailboxes it sets `error` (`src/stores/email-store.ts:610-612`); the list shows the error with a "Retry" that calls `refreshEmails()` (`src/screens/EmailListScreen.tsx:812`), which returns immediately because `currentMailboxId` is null (`email-store.ts:773`). The mount effect only re-runs when `mailboxes.length` changes, so the user is stuck until an app restart.
- Fix hint: Retry should call `fetchMailboxes()` when `mailboxes.length === 0` (then the inbox-select effect kicks in); consider one automatic retry after ~2 s on first login. - Fix hint: Retry should call `fetchMailboxes()` when `mailboxes.length === 0` (then the inbox-select effect kicks in); consider one automatic retry after ~2 s on first login.
- [ ] **No coalescing of concurrent refreshes (maxConcurrentRequests / 429)** — `P3` — `partial` - [x] **No coalescing of concurrent refreshes (maxConcurrentRequests / 429)** — `P3` — `partial` — fixed in f07e039
- What WEB does: `coalesceRefresh` shares an in-flight `fetchMailboxes`/`refreshCurrentMailbox`/tag-count run and queues at most one re-run (`stores/email-store.ts:383-425`, #780). - What WEB does: `coalesceRefresh` shares an in-flight `fetchMailboxes`/`refreshCurrentMailbox`/tag-count run and queues at most one re-run (`stores/email-store.ts:383-425`, #780).
- What RN does: every push event awaits `fetchMailboxes()` then `refreshEmails()` (`src/stores/email-store.ts:981-1015`); overlapping pushes, the mount effects (`EmailListScreen.tsx:554-567`) and `archiveEmail`'s follow-up `fetchMailboxes` can all run in parallel; a `RateLimitError` (`src/api/jmap-client.ts:436-440`) is only logged. The tree itself is preserved on failure (`email-store.ts:604-612`), so this is perf/noise rather than data loss. - What RN does: every push event awaits `fetchMailboxes()` then `refreshEmails()` (`src/stores/email-store.ts:981-1015`); overlapping pushes, the mount effects (`EmailListScreen.tsx:554-567`) and `archiveEmail`'s follow-up `fetchMailboxes` can all run in parallel; a `RateLimitError` (`src/api/jmap-client.ts:436-440`) is only logged. The tree itself is preserved on failure (`email-store.ts:604-612`), so this is perf/noise rather than data loss.
- Fix hint: wrap `fetchMailboxes`/`refreshEmails` bodies in a small in-flight map keyed by `activeAccountId` with a single queued re-run, like WEB's `coalesceRefresh`. - Fix hint: wrap `fetchMailboxes`/`refreshEmails` bodies in a small in-flight map keyed by `activeAccountId` with a single queued re-run, like WEB's `coalesceRefresh`.
@@ -109,7 +109,7 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou
- What RN does: `UnifiedInboxScreen` loads once per mount/`includeGroup` change (`:57-59`); after opening (mark read) or deleting in the thread screen and going back, the unread dot/row is stale until pull-to-refresh. No push hookup. - What RN does: `UnifiedInboxScreen` loads once per mount/`includeGroup` change (`:57-59`); after opening (mark read) or deleting in the thread screen and going back, the unread dot/row is stale until pull-to-refresh. No push hookup.
- Fix hint: `useFocusEffect` → reload, or patch the local list from the thread screen result; when Unread/Starred views are added, keep the WEB retain semantics. - Fix hint: `useFocusEffect` → reload, or patch the local list from the thread screen result; when Unread/Starred views are added, keep the WEB retain semantics.
- [ ] **Opening a unified-inbox message that is not in the active folder page shows another message's body** — `P1` — `rn-only-bug` - [x] **Opening a unified-inbox message that is not in the active folder page shows another message's body** — `P1` — `rn-only-bug` — fixed in c8be383
- What WEB does: opens the clicked row object itself and fetches by source client/account (#847, `components/mail/mail-app.tsx:3111-3190`). - What WEB does: opens the clicked row object itself and fetches by source client/account (#847, `components/mail/mail-app.tsx:3111-3190`).
- What RN does: `UnifiedInboxScreen.onOpen` switches account then navigates to `EmailThread` (`src/screens/UnifiedInboxScreen.tsx:61-86`). `EmailThreadScreen` pages over the *active folder's* `emails` (`src/screens/EmailThreadScreen.tsx:131-133`, FlatList `data={emails}` `:538`, `initialScrollIndex={Math.max(0, findIndex)}`). A group-inbox message (or any message not in the first page of the user's own inbox snapshot) is not in `emails`, so index 0 is shown: the pane renders `emails[0]` while the toolbar/`activeEmailId` (and delete/archive/spam) refer to the message that was tapped; with an empty snapshot the body area is blank. Bare-id `findIndex` also collides across accounts (Stalwart reuses id ranges, #847). - What RN does: `UnifiedInboxScreen.onOpen` switches account then navigates to `EmailThread` (`src/screens/UnifiedInboxScreen.tsx:61-86`). `EmailThreadScreen` pages over the *active folder's* `emails` (`src/screens/EmailThreadScreen.tsx:131-133`, FlatList `data={emails}` `:538`, `initialScrollIndex={Math.max(0, findIndex)}`). A group-inbox message (or any message not in the first page of the user's own inbox snapshot) is not in `emails`, so index 0 is shown: the pane renders `emails[0]` while the toolbar/`activeEmailId` (and delete/archive/spam) refer to the message that was tapped; with an empty snapshot the body area is blank. Bare-id `findIndex` also collides across accounts (Stalwart reuses id ranges, #847).
- Fix hint: when `route.params.emailId` is not found in `emails` (or `jmapAccountId` is set), page over a one-element list `[{ id, threadId }]` instead of `emails`; compare ids together with the owning account. - Fix hint: when `route.params.emailId` is not found in `emails` (or `jmapAccountId` is set), page over a one-element list `[{ id, threadId }]` instead of `emails`; compare ids together with the owning account.
@@ -120,12 +120,12 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou
### List rendering and behaviour ### List rendering and behaviour
- [ ] **Sent/Drafts rows show the sender ("me") instead of the recipient (1.4.12)** — `P2` — `bugfix-parity` - [x] **Sent/Drafts rows show the sender ("me") instead of the recipient (1.4.12)** — `P2` — `bugfix-parity` — fixed in PENDING-B
- What WEB does: `showRecipient = role === 'sent' || role === 'drafts'` → uses `email.to[0]` for name/avatar (`components/email/thread-list-item.tsx:128-131`, `579-583`). - What WEB does: `showRecipient = role === 'sent' || role === 'drafts'` → uses `email.to[0]` for name/avatar (`components/email/thread-list-item.tsx:128-131`, `579-583`).
- What RN does: `getSenderName` always reads `from` (`src/screens/EmailListScreen.tsx:31-37`, row `:80-81`, avatar `:118`). - What RN does: `getSenderName` always reads `from` (`src/screens/EmailListScreen.tsx:31-37`, row `:80-81`, avatar `:118`).
- Fix hint: pass `currentMailbox?.role` into `EmailRow` and pick `to[0] ?? from[0]` for sent/drafts (also in `UnifiedInboxScreen` if role views are added). - Fix hint: pass `currentMailbox?.role` into `EmailRow` and pick `to[0] ?? from[0]` for sent/drafts (also in `UnifiedInboxScreen` if role views are added).
- [ ] **Tags are not rendered on list rows (and no tag row tint)** — `P2` — `missing` - [x] **Tags are not rendered on list rows (and no tag row tint)** — `P2` — `missing` — fixed in PENDING-B
- What WEB does: `TagBadge`s per row from `getEmailTagIds` (both `$label:` and legacy `$color:`), thread rows union all messages' tags, optional row tint by first tag (`components/email/thread-list-item.tsx:152-155`, `609-611`, `lib/thread-utils.ts:187-244`, `hooks/use-tag-display.ts`). - What WEB does: `TagBadge`s per row from `getEmailTagIds` (both `$label:` and legacy `$color:`), thread rows union all messages' tags, optional row tint by first tag (`components/email/thread-list-item.tsx:152-155`, `609-611`, `lib/thread-utils.ts:187-244`, `hooks/use-tag-display.ts`).
- What RN does: `EmailRow` shows no tags; `tagPill/tagDot/tagText` styles exist but are unused (`src/screens/EmailListScreen.tsx:1481-1495`). Tags are only visible inside the thread screen tag menu. - What RN does: `EmailRow` shows no tags; `tagPill/tagDot/tagText` styles exist but are unused (`src/screens/EmailListScreen.tsx:1481-1495`). Tags are only visible inside the thread screen tag menu.
- Fix hint: compute tag ids from `item.keywords` (`$label:*`, `$color:*`), look up `useKeywordsStore().keywords` for label/colour (unknown ids → grey with raw id), render pills on the subject row; add a `tintListRowsByTag` setting if desired. - Fix hint: compute tag ids from `item.keywords` (`$label:*`, `$color:*`), look up `useKeywordsStore().keywords` for label/colour (unknown ids → grey with raw id), render pills on the subject row; add a `tintListRowsByTag` setting if desired.
@@ -135,12 +135,12 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou
- What RN does: no tag section in `SidebarDrawer`; `EmailFilters` has no keyword field (`src/stores/email-store.ts:140-149`). - What RN does: no tag section in `SidebarDrawer`; `EmailFilters` has no keyword field (`src/stores/email-store.ts:140-149`).
- Fix hint: add `keyword?: string` to `EmailFilters` and a "Tags" section in the drawer; when set, `buildJmapFilter` should omit `inMailbox` (search across folders) and add `{ hasKeyword: '$label:<id>' }`; counts via one `Email/query` (calculateTotal) per tag, optional. - Fix hint: add `keyword?: string` to `EmailFilters` and a "Tags" section in the drawer; when set, `buildJmapFilter` should omit `inMailbox` (search across folders) and add `{ hasKeyword: '$label:<id>' }`; counts via one `Email/query` (calculateTotal) per tag, optional.
- [ ] **Answered / forwarded status icons (1.4.8)** — `P3` — `missing` - [x] **Answered / forwarded status icons (1.4.8)** — `P3` — `missing` — fixed in PENDING-B
- What WEB does: `Reply`/`Forward` icons from `$answered`/`$forwarded` (`components/email/thread-list-item.tsx:120-121`, `396-412`). - What WEB does: `Reply`/`Forward` icons from `$answered`/`$forwarded` (`components/email/thread-list-item.tsx:120-121`, `396-412`).
- What RN does: none (no reference to `$answered` anywhere in `src/`). - What RN does: none (no reference to `$answered` anywhere in `src/`).
- Fix hint: add the two icons next to the star/paperclip in `EmailRow`. - Fix hint: add the two icons next to the star/paperclip in `EmailRow`.
- [ ] **Pin uses `$important` instead of `$pinned`; no pinned-first order or pin icon** — `P2` — `bug` - [x] **Pin uses `$important` instead of `$pinned`; no pinned-first order or pin icon** — `P2` — `bug` — fixed in PENDING-B
- What WEB does: pin toggles the `$pinned` keyword (`components/mail/mail-app.tsx:2274-2300`), rows show a `Pin` icon (`thread-list-item.tsx:119`), and the query puts `$pinned` first (`lib/jmap/client.ts:1433-1444`, `buildEmailSort` `lib/message-list-order.ts:165-195`), thread groups too (`lib/thread-utils.ts:82-100`). - What WEB does: pin toggles the `$pinned` keyword (`components/mail/mail-app.tsx:2274-2300`), rows show a `Pin` icon (`thread-list-item.tsx:119`), and the query puts `$pinned` first (`lib/jmap/client.ts:1433-1444`, `buildEmailSort` `lib/message-list-order.ts:165-195`), thread groups too (`lib/thread-utils.ts:82-100`).
- What RN does: `togglePin` writes `$important` (`src/stores/email-store.ts:1151-1173`), `isPinned` reads `$important` (`src/screens/EmailListScreen.tsx:47-49`); no icon, no ordering. A pin set in RN is invisible in WEB and vice versa. - What RN does: `togglePin` writes `$important` (`src/stores/email-store.ts:1151-1173`), `isPinned` reads `$important` (`src/screens/EmailListScreen.tsx:47-49`); no icon, no ordering. A pin set in RN is invisible in WEB and vice versa.
- Fix hint: switch the keyword to `$pinned`; show a pin icon; optionally sort pinned to the top client-side (server sort needs the polarity probe, see next item). - Fix hint: switch the keyword to `$pinned`; show a pin icon; optionally sort pinned to the top client-side (server sort needs the polarity probe, see next item).
@@ -150,12 +150,12 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou
- What RN does: only `receivedAt` asc/desc via `mailSortAscending` (persisted, header toggle `src/screens/EmailListScreen.tsx:699-711`, store `setSortAscending` `src/stores/email-store.ts:1039-1053`). This satisfies the "per default" part of native issue #5 but nothing else. - What RN does: only `receivedAt` asc/desc via `mailSortAscending` (persisted, header toggle `src/screens/EmailListScreen.tsx:699-711`, store `setSortAscending` `src/stores/email-store.ts:1039-1053`). This satisfies the "per default" part of native issue #5 but nothing else.
- Fix hint: port `message-list-order.ts` (pure, no deps) and pass `buildEmailSort(levels, …)` as `sort` to `queryEmails`/`getEmailQueryChanges`; probe polarity once per account like `probeKeywordSortPolarity`; add a settings screen with the presets. Keep `setSortAscending`'s snapshot invalidation for any order change. - Fix hint: port `message-list-order.ts` (pure, no deps) and pass `buildEmailSort(levels, …)` as `sort` to `queryEmails`/`getEmailQueryChanges`; probe polarity once per account like `probeKeywordSortPolarity`; add a settings screen with the presets. Keep `setSortAscending`'s snapshot invalidation for any order change.
- [ ] **Threading: counts only within the loaded page, no Thread/get counts, no conversation open, representative message wrong in ascending sort** — `P2` — `partial` - [x] **Threading: counts only within the loaded page, no Thread/get counts, no conversation open, representative message wrong in ascending sort** — `P2` — `partial` — fixed in PENDING-B
- What WEB does: groups by `threadId`, `emailCount` from `Thread/get` across folders (`fetchThreadEmailCounts` `stores/email-store.ts:3731-3751`, `lib/thread-utils.ts:14-72`), representative = latest email, mobile tap opens the whole conversation via `getThreadEmails` routed to the owner (#814, `components/mail/mail-app.tsx:3240-3275`), "collapse all threads". - What WEB does: groups by `threadId`, `emailCount` from `Thread/get` across folders (`fetchThreadEmailCounts` `stores/email-store.ts:3731-3751`, `lib/thread-utils.ts:14-72`), representative = latest email, mobile tap opens the whole conversation via `getThreadEmails` routed to the owner (#814, `components/mail/mail-app.tsx:3240-3275`), "collapse all threads".
- What RN does: collapses same-`threadId` rows within the page and counts within the page (`src/screens/EmailListScreen.tsx:224-248`); keeps the *first* row encountered, i.e. the oldest when `mailSortAscending` is on; tapping opens only that single message (`EmailThreadScreen` pages over `emails`, no `Thread/get` anywhere). - What RN does: collapses same-`threadId` rows within the page and counts within the page (`src/screens/EmailListScreen.tsx:224-248`); keeps the *first* row encountered, i.e. the oldest when `mailSortAscending` is on; tapping opens only that single message (`EmailThreadScreen` pages over `emails`, no `Thread/get` anywhere).
- Fix hint: `Thread/get` for the visible thread ids (chunked) to get real counts; pick the newest message as representative; a conversation screen belongs to the viewer area but the list should hand over `threadId` + owner account. - Fix hint: `Thread/get` for the visible thread ids (chunked) to get real counts; pick the newest message as representative; a conversation screen belongs to the viewer area but the list should hand over `threadId` + owner account.
- [ ] **Drafts do not open in the composer / no "Edit draft"** — `P2` — `missing` - [x] **Drafts do not open in the composer / no "Edit draft"** — `P2` — `missing` — fixed in PENDING-B
- What WEB does: a `$draft` message opens the composer (`handleEditDraft` `components/mail/mail-app.tsx:1812`, list `onEditDraft` `components/email/email-list.tsx:47,616`, context menu `components/email/email-context-menu.tsx:225-233`); viewer shows a draft banner. - What WEB does: a `$draft` message opens the composer (`handleEditDraft` `components/mail/mail-app.tsx:1812`, list `onEditDraft` `components/email/email-list.tsx:47,616`, context menu `components/email/email-context-menu.tsx:225-233`); viewer shows a draft banner.
- What RN does: tapping a draft opens the read-only `EmailThreadScreen`; `Compose` params have no draft/edit mode (`src/navigation/types.ts:7-24`), locale keys `email_viewer.draft_banner`/`edit_draft` exist but are unused. - What RN does: tapping a draft opens the read-only `EmailThreadScreen`; `Compose` params have no draft/edit mode (`src/navigation/types.ts:7-24`), locale keys `email_viewer.draft_banner`/`edit_draft` exist but are unused.
- Fix hint: in `handleRowPress`, if `item.keywords.$draft` (or folder role is drafts) navigate to `Compose` with a `draft: { id, to, cc, bcc, subject, body, blobId }` param; on send, destroy the old draft (composer area). - Fix hint: in `handleRowPress`, if `item.keywords.$draft` (or folder role is drafts) navigate to `Compose` with a `draft: { id, to, cc, bcc, subject, body, blobId }` param; on send, destroy the old draft (composer area).
@@ -170,46 +170,46 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou
- What RN does: `if (markAsReadDelay > 0) { timer } else { markRead() }` (`src/screens/EmailThreadScreen.tsx:236-245`) so the "Never" option in `ReadingSettings.tsx:171` behaves like "Instant". - What RN does: `if (markAsReadDelay > 0) { timer } else { markRead() }` (`src/screens/EmailThreadScreen.tsx:236-245`) so the "Never" option in `ReadingSettings.tsx:171` behaves like "Instant".
- Fix hint: add `if (markAsReadDelay === -1) return;` before the branch. - Fix hint: add `if (markAsReadDelay === -1) return;` before the branch.
- [ ] **Load-more appends duplicates when new mail shifts positions** — `P3` — `rn-only-bug` - [x] **Load-more appends duplicates when new mail shifts positions** — `P3` — `rn-only-bug` — fixed in f07e039
- What WEB does: filters `newEmails` by existing ids before appending (`stores/email-store.ts:1620-1625`). - What WEB does: filters `newEmails` by existing ids before appending (`stores/email-store.ts:1620-1625`).
- What RN does: `merged = [...emails, ...newEmails]` with no dedup (`src/stores/email-store.ts:714-716`); a message that arrived between pages appears twice (duplicate FlatList keys, double rows). - What RN does: `merged = [...emails, ...newEmails]` with no dedup (`src/stores/email-store.ts:714-716`); a message that arrived between pages appears twice (duplicate FlatList keys, double rows).
- Fix hint: `newEmails.filter(e => !existingIds.has(e.id))`. - Fix hint: `newEmails.filter(e => !existingIds.has(e.id))`.
- [ ] **Incremental-refresh window "cap" is a no-op** — `P3` — `rn-only-bug` - [x] **Incremental-refresh window "cap" is a no-op** — `P3` — `rn-only-bug` — fixed in f07e039
- What RN does: `out.slice(0, Math.max(limit, out.length))` (`src/stores/email-store.ts:885`) never trims although the comment says it caps the window to the page size; harmless but misleading (the snapshot can grow past `emailsPerPage`). - What RN does: `out.slice(0, Math.max(limit, out.length))` (`src/stores/email-store.ts:885`) never trims although the comment says it caps the window to the page size; harmless but misleading (the snapshot can grow past `emailsPerPage`).
- Fix hint: either drop the line and comment, or use `Math.max(limit, baseEmails.length)` if the intent is "at least the previous window". - Fix hint: either drop the line and comment, or use `Math.max(limit, baseEmails.length)` if the intent is "at least the previous window".
- [ ] **Swipe "spam" available in Sent/Drafts and no "not spam" in Junk** — `P3` — `rn-only-bug` - [x] **Swipe "spam" available in Sent/Drafts and no "not spam" in Junk** — `P3` — `rn-only-bug` — fixed in PENDING-B
- What WEB does: spam action hidden for sent/drafts/scheduled and flips to "not spam" inside Junk (`components/email/thread-list-item.tsx:523`, `components/email/email-hover-actions.tsx:117-140`, changelog 1.7.7). - What WEB does: spam action hidden for sent/drafts/scheduled and flips to "not spam" inside Junk (`components/email/thread-list-item.tsx:523`, `components/email/email-hover-actions.tsx:117-140`, changelog 1.7.7).
- What RN does: `handleSwipeAction 'spam'` only checks `currentMailboxId !== junkMailboxId` (`src/screens/EmailListScreen.tsx:379-388`), so a swipe in Sent moves your own mail to Junk; in Junk the swipe silently does nothing. - What RN does: `handleSwipeAction 'spam'` only checks `currentMailboxId !== junkMailboxId` (`src/screens/EmailListScreen.tsx:379-388`), so a swipe in Sent moves your own mail to Junk; in Junk the swipe silently does nothing.
- Fix hint: skip when `currentMailbox.role` is sent/drafts; in junk call an `undoSpam` (see next item) and change the band label to "Not spam". - Fix hint: skip when `currentMailbox.role` is sent/drafts; in junk call an `undoSpam` (see next item) and change the band label to "Not spam".
- [ ] **Spam / not-spam do not flip `$junk`/`$notjunk` (#850) nor honour "trash-and-read"** — `P2` — `bugfix-parity` - [x] **Spam / not-spam do not flip `$junk`/`$notjunk` (#850) nor honour "trash-and-read"** — `P2` — `bugfix-parity` — fixed in PENDING-B
- What WEB does: `markAsSpam` patches `mailboxIds` + `keywords/$junk: true` + `keywords/$notjunk: null` (+ `$seen` when `deleteAction === 'trash-and-read'`); `undoSpam` restores the original mailbox with `$junk: null`, `$notjunk: true` (`lib/jmap/client.ts:2422-2475`, store `stores/email-store.ts:2947-3100`, undo toast in `components/mail/mail-app.tsx:2228-2270`). - What WEB does: `markAsSpam` patches `mailboxIds` + `keywords/$junk: true` + `keywords/$notjunk: null` (+ `$seen` when `deleteAction === 'trash-and-read'`); `undoSpam` restores the original mailbox with `$junk: null`, `$notjunk: true` (`lib/jmap/client.ts:2422-2475`, store `stores/email-store.ts:2947-3100`, undo toast in `components/mail/mail-app.tsx:2228-2270`).
- What RN does: swipe and thread-screen spam are plain `moveToMailbox` calls (`src/screens/EmailListScreen.tsx:381`, `src/screens/EmailThreadScreen.tsx:344-356`); the keywords stay untouched, so other clients/Stalwart's classifier never learn; undo label reads "Email moved to Junk". - What RN does: swipe and thread-screen spam are plain `moveToMailbox` calls (`src/screens/EmailListScreen.tsx:381`, `src/screens/EmailThreadScreen.tsx:344-356`); the keywords stay untouched, so other clients/Stalwart's classifier never learn; undo label reads "Email moved to Junk".
- Fix hint: add `markAsSpam(ids, junkRawId, accountId, alsoMarkRead)` / `undoSpam(ids, targetRawId, accountId)` to `src/api/email.ts` writing the keyword pointers; store actions with `pendingUndo.kind = 'spam'` (type already exists, `src/stores/email-store.ts:155`) whose `undoLast` also restores the keywords; queue-safe via an outbox `keywords` op. - Fix hint: add `markAsSpam(ids, junkRawId, accountId, alsoMarkRead)` / `undoSpam(ids, targetRawId, accountId)` to `src/api/email.ts` writing the keyword pointers; store actions with `pendingUndo.kind = 'spam'` (type already exists, `src/stores/email-store.ts:155`) whose `undoLast` also restores the keywords; queue-safe via an outbox `keywords` op.
- [ ] **Batch spam / batch not-spam** — `P3` — `missing` - [x] **Batch spam / batch not-spam** — `P3` — `missing` — fixed in PENDING-B
- What WEB does: `batchMarkAsSpam`/`batchUndoSpam` (`stores/email-store.ts:3104-3236`), "Not spam" button in the Junk selection toolbar (`components/email/email-list.tsx:396-411`, changelog 1.7.8). - What WEB does: `batchMarkAsSpam`/`batchUndoSpam` (`stores/email-store.ts:3104-3236`), "Not spam" button in the Junk selection toolbar (`components/email/email-list.tsx:396-411`, changelog 1.7.8).
- What RN does: selection header has star/read/tag/move/archive/delete only (`src/screens/EmailListScreen.tsx:572-630`). - What RN does: selection header has star/read/tag/move/archive/delete only (`src/screens/EmailListScreen.tsx:572-630`).
- Fix hint: add a shield button that calls the batch spam/undo-spam from the previous item depending on `currentMailbox.role === 'junk'`. - Fix hint: add a shield button that calls the batch spam/undo-spam from the previous item depending on `currentMailbox.role === 'junk'`.
- [ ] **Selecting a collapsed thread row selects/acts on only its newest message** — `P2` — `bug` - [x] **Selecting a collapsed thread row selects/acts on only its newest message** — `P2` — `bug` — fixed in PENDING-B
- What WEB does: the thread checkbox toggles every message of the thread (`toggleThreadSelection`, `components/email/thread-list-item.tsx:640-660`); single delete/archive on a thread row uses `moveThreadToMailbox` for archive (`components/mail/mail-app.tsx:2141-2180`). - What WEB does: the thread checkbox toggles every message of the thread (`toggleThreadSelection`, `components/email/thread-list-item.tsx:640-660`); single delete/archive on a thread row uses `moveThreadToMailbox` for archive (`components/mail/mail-app.tsx:2141-2180`).
- What RN does: `selectedIds` holds the representative id only (`src/screens/EmailListScreen.tsx:336-352`, `436-442`), so "3 selected" may be 3 conversations but batch delete/move/archive/tag touch one message each (`deleteEmailsBatch` filters `emails` by id, `email-store.ts:1443`); swipe actions on a thread row likewise act on one message. - What RN does: `selectedIds` holds the representative id only (`src/screens/EmailListScreen.tsx:336-352`, `436-442`), so "3 selected" may be 3 conversations but batch delete/move/archive/tag touch one message each (`deleteEmailsBatch` filters `emails` by id, `email-store.ts:1443`); swipe actions on a thread row likewise act on one message.
- Fix hint: when threading is on, expand each selected representative to all `emails` with the same `threadId` before calling the batch store actions (and for swipe archive/delete/move on rows with `threadCount > 1`). - Fix hint: when threading is on, expand each selected representative to all `emails` with the same `threadId` before calling the batch store actions (and for swipe archive/delete/move on rows with `threadCount > 1`).
- [ ] **Keep search/filters when switching folders (#553)** — `P3` — `bugfix-parity` - [x] **Keep search/filters when switching folders (#553)** — `P3` — `bugfix-parity` — fixed in f07e039
- What WEB does: re-runs the active text/advanced search in the newly selected folder (`components/mail/mail-app.tsx:2501-2512`). - What WEB does: re-runs the active text/advanced search in the newly selected folder (`components/mail/mail-app.tsx:2501-2512`).
- What RN does: `selectMailbox` resets `searchQuery` and `filters` (`src/stores/email-store.ts:676-677`) and the list input follows (`EmailListScreen.tsx:504-511`). - What RN does: `selectMailbox` resets `searchQuery` and `filters` (`src/stores/email-store.ts:676-677`) and the list input follows (`EmailListScreen.tsx:504-511`).
- Fix hint: keep `searchQuery`/`filters` in `selectMailbox` (seed the base snapshot only when they are empty) so the search re-runs in the new folder; offer "Clear" as today. - Fix hint: keep `searchQuery`/`filters` in `selectMailbox` (seed the base snapshot only when they are empty) so the search re-runs in the new folder; offer "Clear" as today.
- [ ] **Just-read mail dropped from an open Unread-filtered list on the next push refresh** — `P3` — `bugfix-parity` - [x] **Just-read mail dropped from an open Unread-filtered list on the next push refresh** — `P3` — `bugfix-parity` — fixed in f07e039
- What WEB does: retains rows the user just read/unstarred in the Unread/Starred views until the view is re-opened (`stores/email-store.ts:1007-1050`, `mergeRetainedRows`; changelog 1.9.0). - What WEB does: retains rows the user just read/unstarred in the Unread/Starred views until the view is re-opened (`stores/email-store.ts:1007-1050`, `mergeRetainedRows`; changelog 1.9.0).
- What RN does: `markRead` patches the row in place, but the next `refreshEmails` (push or pull) re-runs the `notKeyword $seen` query (`src/stores/email-store.ts:279`, `917-944`) and the row vanishes while the user is looking at it. - What RN does: `markRead` patches the row in place, but the next `refreshEmails` (push or pull) re-runs the `notKeyword $seen` query (`src/stores/email-store.ts:279`, `917-944`) and the row vanishes while the user is looking at it.
- Fix hint: keep a `retainedIds` set per filter session; after a full re-query, splice retained rows (from the previous `emails`) back at their old index. - Fix hint: keep a `retainedIds` set per filter session; after a full re-query, splice retained rows (from the previous `emails`) back at their old index.
- [ ] **Search runs on every keystroke** — `P3` — `rn-only-bug` - [x] **Search runs on every keystroke** — `P3` — `rn-only-bug` — fixed in PENDING-B
- What WEB does: searches on submit / suggestion pick (`components/search/search-box.tsx:108-115`). - What WEB does: searches on submit / suggestion pick (`components/search/search-box.tsx:108-115`).
- What RN does: 300 ms debounce into `setSearchQuery` → full `Email/query` + `Email/get` per pause (`src/screens/EmailListScreen.tsx:512-516`); with the `*` wildcard a single letter matches the whole mailbox. - What RN does: 300 ms debounce into `setSearchQuery` → full `Email/query` + `Email/get` per pause (`src/screens/EmailListScreen.tsx:512-516`); with the `*` wildcard a single letter matches the whole mailbox.
- Fix hint: search on `onSubmitEditing` (keep the clear button live), or debounce ≥ 600 ms with a minimum of 2 characters. - Fix hint: search on `onSubmitEditing` (keep the clear button live), or debounce ≥ 600 ms with a minimum of 2 characters.
@@ -234,24 +234,24 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou
- What RN does: refuses with "Messages can only be moved within the same account" (`src/stores/email-store.ts:1183-1186`, `1396-1399`); `MoveSheet` is scoped to the same account so the message is only reachable by a future picker. `MoveSheet` also allows Drafts as a target (`MoveSheet.tsx:102-103`) which WEB excludes (`email-context-menu.tsx:163`). - What RN does: refuses with "Messages can only be moved within the same account" (`src/stores/email-store.ts:1183-1186`, `1396-1399`); `MoveSheet` is scoped to the same account so the message is only reachable by a future picker. `MoveSheet` also allows Drafts as a target (`MoveSheet.tsx:102-103`) which WEB excludes (`email-context-menu.tsx:163`).
- Fix hint: exclude `role === 'drafts'` targets; cross-account move = `Email/get` blob → `Blob/upload` to the owner → `Email/import` → destroy (import helper already exists in `src/api/email.ts:423`). - Fix hint: exclude `role === 'drafts'` targets; cross-account move = `Email/get` blob → `Blob/upload` to the owner → `Email/import` → destroy (import helper already exists in `src/api/email.ts:423`).
- [ ] **Undo snackbar / toast strings not localized; move toast lacks folder path** — `P3` — `partial` - [x] **Undo snackbar / toast strings not localized; move toast lacks folder path** — `P3` — `partial` — fixed in PENDING-B
- What WEB does: toasts localized, full folder path in move toast (changelog 1.5.0). - What WEB does: toasts localized, full folder path in move toast (changelog 1.5.0).
- What RN does: hard-coded English labels in the store (`src/stores/email-store.ts:1202, 1252, 1328, 1379, 1425, 1520`), "UNDO" (`UndoSnackbar.tsx:73`), list empty/loading strings (`EmailListScreen.tsx:805-830`), filter modal labels, `MoveSheet`/`TagSheet` titles. - What RN does: hard-coded English labels in the store (`src/stores/email-store.ts:1202, 1252, 1328, 1379, 1425, 1520`), "UNDO" (`UndoSnackbar.tsx:73`), list empty/loading strings (`EmailListScreen.tsx:805-830`), filter modal labels, `MoveSheet`/`TagSheet` titles.
- Fix hint: route through `useLocaleStore().t` (keys already exist for many: `email_list.*`, `notifications.*`). - Fix hint: route through `useLocaleStore().t` (keys already exist for many: `email_list.*`, `notifications.*`).
### Search ### Search
- [ ] **Search folder scope: always the current folder; no all-folders default (#788) and no folder picker** — `P2` — `bugfix-parity` - [x] **Search folder scope: always the current folder; no all-folders default (#788) and no folder picker** — `P2` — `bugfix-parity` — fixed in PENDING-B
- What WEB does: `searchMailboxId` defaults to `""` = all folders, is a separate, persisted choice in the filter panel (`stores/email-store.ts:95-101`, `2344-2378`, changelog 1.9.0 #788); cross-mailbox queries. - What WEB does: `searchMailboxId` defaults to `""` = all folders, is a separate, persisted choice in the filter panel (`stores/email-store.ts:95-101`, `2344-2378`, changelog 1.9.0 #788); cross-mailbox queries.
- What RN does: `buildJmapFilter` always starts with `{ inMailbox: current }` (`src/stores/email-store.ts:255`); the filter modal has no folder field. - What RN does: `buildJmapFilter` always starts with `{ inMailbox: current }` (`src/stores/email-store.ts:255`); the filter modal has no folder field.
- Fix hint: add `folder?: string | 'all'` to `EmailFilters` (default `'all'` while a text query or filter is active, i.e. omit `inMailbox`); expose a folder select in the modal; the base-view snapshot logic already keys off `isBaseView`. - Fix hint: add `folder?: string | 'all'` to `EmailFilters` (default `'all'` while a text query or filter is active, i.e. omit `inMailbox`); expose a folder select in the modal; the base-view snapshot logic already keys off `isBaseView`.
- [ ] **Body filter, per-chip removal** — `P3` — `partial` - [x] **Body filter, per-chip removal** — `P3` — `partial` — fixed in PENDING-B
- What WEB does: `body` condition (`lib/jmap/search-utils.ts:62-64`); each chip has an X (`components/search/search-chips.tsx:260-266`). - What WEB does: `body` condition (`lib/jmap/search-utils.ts:62-64`); each chip has an X (`components/search/search-chips.tsx:260-266`).
- What RN does: no `body` field; chips are read-only, only "Clear" all (`src/screens/EmailListScreen.tsx:725-796`). - What RN does: no `body` field; chips are read-only, only "Clear" all (`src/screens/EmailListScreen.tsx:725-796`).
- Fix hint: add `body` to `EmailFilters`/`buildJmapFilter`; make each chip's X call `setFilterField(key, undefined)`. - Fix hint: add `body` to `EmailFilters`/`buildJmapFilter`; make each chip's X call `setFilterField(key, undefined)`.
- [ ] **Search suggestions and recent-search history (#845)** — `P3` — `missing` - [x] **Search suggestions and recent-search history (#845)** — `P3` — `missing` — fixed in PENDING-B
- What WEB does: recent searches (max 10, persisted) + contact/sender matches under the search box, keyboard navigable, pick a contact → `from:`/`to:` filter (`stores/search-history-store.ts`, `lib/search-suggestions.ts:183`, `components/search/search-box.tsx:34`, `components/mail/mail-app.tsx:2807-2826`). - What WEB does: recent searches (max 10, persisted) + contact/sender matches under the search box, keyboard navigable, pick a contact → `from:`/`to:` filter (`stores/search-history-store.ts`, `lib/search-suggestions.ts:183`, `components/search/search-box.tsx:34`, `components/mail/mail-app.tsx:2807-2826`).
- What RN does: plain `TextInput` (`EmailListScreen.tsx:677-698`). - What RN does: plain `TextInput` (`EmailListScreen.tsx:677-698`).
- Fix hint: small zustand store persisted in AsyncStorage; render a dropdown under the input when focused (recent + `useContactsStore` matches); tapping a contact sets `filters.from`. - Fix hint: small zustand store persisted in AsyncStorage; render a dropdown under the input when focused (recent + `useContactsStore` matches); tapping a contact sets `filters.from`.
+9 -4
View File
@@ -11,6 +11,8 @@ import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'
import { useColors } from '../theme/colors'; import { useColors } from '../theme/colors';
import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree'; import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree';
import { useSheetDrag } from '../lib/use-sheet-drag'; import { useSheetDrag } from '../lib/use-sheet-drag';
import { useLocaleStore } from '../stores/locale-store';
import { localizeMailboxName } from '../lib/mailbox-label';
import type { Mailbox } from '../api/types'; import type { Mailbox } from '../api/types';
function moveTargetIcon(role: string | null | undefined, name: string): LucideIcon { function moveTargetIcon(role: string | null | undefined, name: string): LucideIcon {
@@ -38,6 +40,7 @@ export function MoveSheet({
}: MoveSheetProps) { }: MoveSheetProps) {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(500)).current; const slideY = React.useRef(new Animated.Value(500)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current; const overlayOpacity = React.useRef(new Animated.Value(0)).current;
@@ -88,7 +91,7 @@ export function MoveSheet({
<View style={styles.sheetHandle} /> <View style={styles.sheetHandle} />
</View> </View>
<View style={styles.sheetHeader}> <View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>Move to folder</Text> <Text style={styles.sheetTitle}>{t('context_menu.move_to', 'Move to folder')}</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}> <Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
<X size={18} color={c.textSecondary} /> <X size={18} color={c.textSecondary} />
</Pressable> </Pressable>
@@ -98,9 +101,11 @@ export function MoveSheet({
{visibleNodes.map((node) => { {visibleNodes.map((node) => {
const Icon = moveTargetIcon(node.role, node.name); const Icon = moveTargetIcon(node.role, node.name);
const isCurrent = node.id === currentMailboxId; const isCurrent = node.id === currentMailboxId;
// A shared account's header is a grouping row, not a folder. // A shared account's header is a grouping row, not a folder, and
// Drafts is never a move target (the webmail excludes it too).
const canTarget = const canTarget =
!node.isAccountNode && node.myRights?.mayAddItems !== false && !isCurrent; !node.isAccountNode && node.myRights?.mayAddItems !== false && !isCurrent
&& node.role !== 'drafts';
return ( return (
<Pressable <Pressable
key={node.id} key={node.id}
@@ -117,7 +122,7 @@ export function MoveSheet({
style={[styles.moveRowLabel, !canTarget && styles.moveRowLabelDisabled]} style={[styles.moveRowLabel, !canTarget && styles.moveRowLabelDisabled]}
numberOfLines={1} numberOfLines={1}
> >
{node.name} {node.isAccountNode ? node.name : localizeMailboxName(node.role, node.name, t)}
</Text> </Text>
{isCurrent && <Check size={14} color={c.textMuted} />} {isCurrent && <Check size={14} color={c.textMuted} />}
</Pressable> </Pressable>
+8 -2
View File
@@ -15,13 +15,19 @@ interface SenderAvatarProps {
email?: string | null; email?: string | null;
size?: number; size?: number;
style?: StyleProp<ViewStyle>; style?: StyleProp<ViewStyle>;
/**
* Never load a remote image (favicon) — initials only. The list passes this
* inside Junk unless `showAvatarsInJunk` is on (webmail 1.5.1), so a spam
* sender's domain is not contacted by merely listing the folder.
*/
disableImages?: boolean;
} }
// Mirrors the webmail `Avatar` priority for the subset available on native: // Mirrors the webmail `Avatar` priority for the subset available on native:
// company favicon (if sender domain is non-personal and `senderFavicons` is on) // company favicon (if sender domain is non-personal and `senderFavicons` is on)
// → initials over HSL-hashed color. // → initials over HSL-hashed color.
export default function SenderAvatar({ name, email, size = 40, style }: SenderAvatarProps) { export default function SenderAvatar({ name, email, size = 40, style, disableImages }: SenderAvatarProps) {
const senderFavicons = useSettingsStore((s) => s.senderFavicons); const senderFavicons = useSettingsStore((s) => s.senderFavicons) && !disableImages;
const [imgError, setImgError] = React.useState(false); const [imgError, setImgError] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
+36 -12
View File
@@ -3,11 +3,12 @@ import {
View, Text, StyleSheet, Animated, PanResponder, Pressable, Dimensions, View, Text, StyleSheet, Animated, PanResponder, Pressable, Dimensions,
} from 'react-native'; } from 'react-native';
import { import {
Archive, Trash2, ShieldAlert, MailOpen, Star, Pin, FolderInput, Archive, Trash2, ShieldAlert, ShieldCheck, MailOpen, Mail, Star, Pin, PinOff, FolderInput,
type LucideIcon, type LucideIcon,
} from 'lucide-react-native'; } from 'lucide-react-native';
import { typography, type ThemePalette } from '../theme/tokens'; import { typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors'; import { useColors } from '../theme/colors';
import { useLocaleStore } from '../stores/locale-store';
import type { SwipeAction, SwipeMode } from '../stores/settings-store'; import type { SwipeAction, SwipeMode } from '../stores/settings-store';
import { import {
shouldClaimGesture, dragOffset, resolveRelease, exitsRow, shouldClaimGesture, dragOffset, resolveRelease, exitsRow,
@@ -22,7 +23,7 @@ interface SwipeableRowProps {
/** Left-to-right swipe action (revealed under a rightward drag, sits at left edge). */ /** Left-to-right swipe action (revealed under a rightward drag, sits at left edge). */
rightAction: SwipeAction; rightAction: SwipeAction;
/** Pass the row state used to compute action labels (e.g. unread/starred toggling). */ /** Pass the row state used to compute action labels (e.g. unread/starred toggling). */
context: { unread: boolean; starred: boolean; pinned: boolean }; context: SwipeContext;
onAction: (action: SwipeAction) => void; onAction: (action: SwipeAction) => void;
/** /**
* 'instant' (default): swipe past COMMIT_THRESHOLD and release fires the action. * 'instant' (default): swipe past COMMIT_THRESHOLD and release fires the action.
@@ -31,6 +32,14 @@ interface SwipeableRowProps {
mode?: SwipeMode; mode?: SwipeMode;
} }
export interface SwipeContext {
unread: boolean;
starred: boolean;
pinned: boolean;
/** Inside Junk the spam band flips to "Not spam" (webmail 1.7.7). */
inJunk?: boolean;
}
// Distance the row flies off-screen by before the action callback fires. // Distance the row flies off-screen by before the action callback fires.
const EXIT_DISTANCE = 600; const EXIT_DISTANCE = 600;
@@ -44,12 +53,26 @@ const ACTION_META: Record<Exclude<SwipeAction, 'none'>, { icon: LucideIcon; bg:
move: { icon: FolderInput, bg: '#475569', defaultLabel: 'Move' }, move: { icon: FolderInput, bg: '#475569', defaultLabel: 'Move' },
}; };
function actionLabel(action: SwipeAction, context: { unread: boolean; starred: boolean; pinned: boolean }): string { type Translate = (key: string, fallback?: string) => string;
if (action === 'read') return context.unread ? 'Read' : 'Unread';
if (action === 'star') return context.starred ? 'Unstar' : 'Star'; function actionLabel(action: SwipeAction, context: SwipeContext, t: Translate): string {
if (action === 'pin') return context.pinned ? 'Unpin' : 'Pin'; switch (action) {
if (action === 'none') return ''; case 'none': return '';
return ACTION_META[action].defaultLabel; case 'read': return context.unread ? t('context_menu.mark_read', 'Read') : t('context_menu.mark_unread', 'Unread');
case 'star': return context.starred ? t('context_menu.unstar', 'Unstar') : t('context_menu.star', 'Star');
case 'pin': return context.pinned ? t('context_menu.unpin', 'Unpin') : t('context_menu.pin', 'Pin');
case 'spam': return context.inJunk ? t('context_menu.not_spam', 'Not spam') : t('email_list.swipe_spam', 'Spam');
case 'archive': return t('context_menu.archive', 'Archive');
case 'delete': return t('context_menu.delete', 'Delete');
case 'move': return t('email_list.swipe_move', 'Move');
}
}
function actionIcon(action: Exclude<SwipeAction, 'none'>, context: SwipeContext): LucideIcon {
if (action === 'spam' && context.inJunk) return ShieldCheck;
if (action === 'read' && !context.unread) return Mail;
if (action === 'pin' && context.pinned) return PinOff;
return ACTION_META[action].icon;
} }
export function SwipeableRow({ export function SwipeableRow({
@@ -57,6 +80,7 @@ export function SwipeableRow({
}: SwipeableRowProps) { }: SwipeableRowProps) {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const dx = useRef(new Animated.Value(0)).current; const dx = useRef(new Animated.Value(0)).current;
const claimed = useRef(false); const claimed = useRef(false);
const widthRef = useRef(Dimensions.get('window').width); const widthRef = useRef(Dimensions.get('window').width);
@@ -186,8 +210,8 @@ export function SwipeableRow({
const renderRevealBand = (action: SwipeAction, side: 'left' | 'right') => { const renderRevealBand = (action: SwipeAction, side: 'left' | 'right') => {
if (action === 'none') return null; if (action === 'none') return null;
const meta = ACTION_META[action]; const meta = ACTION_META[action];
const Icon = meta.icon; const Icon = actionIcon(action, context);
const label = actionLabel(action, context); const label = actionLabel(action, context, t);
return ( return (
<Pressable <Pressable
onPress={() => fireFromBandTap(action)} onPress={() => fireFromBandTap(action)}
@@ -228,8 +252,8 @@ export function SwipeableRow({
const renderInstantBand = (action: SwipeAction, side: 'left' | 'right') => { const renderInstantBand = (action: SwipeAction, side: 'left' | 'right') => {
if (action === 'none') return null; if (action === 'none') return null;
const meta = ACTION_META[action]; const meta = ACTION_META[action];
const Icon = meta.icon; const Icon = actionIcon(action, context);
const label = actionLabel(action, context); const label = actionLabel(action, context, t);
const inputRange = side === 'left' ? [0, COMMIT_THRESHOLD] : [-COMMIT_THRESHOLD, 0]; const inputRange = side === 'left' ? [0, COMMIT_THRESHOLD] : [-COMMIT_THRESHOLD, 0];
const iconScale = dx.interpolate({ const iconScale = dx.interpolate({
inputRange, inputRange,
+36 -3
View File
@@ -8,6 +8,8 @@ import { spacing, radius, typography, colors as tokenColors, type ThemePalette }
import { useColors } from '../theme/colors'; import { useColors } from '../theme/colors';
import { useSheetDrag } from '../lib/use-sheet-drag'; import { useSheetDrag } from '../lib/use-sheet-drag';
import { keywordToken, type KeywordDef } from '../stores/keywords-store'; import { keywordToken, type KeywordDef } from '../stores/keywords-store';
import { useLocaleStore } from '../stores/locale-store';
import { getEmailTagIds } from '../lib/thread-utils';
import type { Email } from '../api/types'; import type { Email } from '../api/types';
interface TagSheetProps { interface TagSheetProps {
@@ -23,6 +25,7 @@ interface TagSheetProps {
export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle }: TagSheetProps) { export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle }: TagSheetProps) {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(500)).current; const slideY = React.useRef(new Animated.Value(500)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current; const overlayOpacity = React.useRef(new Animated.Value(0)).current;
@@ -50,6 +53,17 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
[selectedEmails], [selectedEmails],
); );
// Tags set on the selection that no local definition explains (set by the
// webmail or another device): listed so they can at least be removed.
const unknownIds = React.useMemo(() => {
const known = new Set(keywords.map((k) => k.id));
const out = new Set<string>();
for (const e of selectedEmails) {
for (const id of getEmailTagIds(e.keywords)) if (!known.has(id)) out.add(id);
}
return [...out];
}, [keywords, selectedEmails]);
return ( return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent> <Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}> <Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
@@ -66,15 +80,17 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
<View style={styles.handle} /> <View style={styles.handle} />
</View> </View>
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.title}>Tag {selectedEmails.length} selected</Text> <Text style={styles.title}>
{t('context_menu.items_selected', `${selectedEmails.length} emails selected`, { count: selectedEmails.length })}
</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.close}> <Pressable onPress={onClose} hitSlop={8} style={styles.close}>
<X size={18} color={c.textSecondary} /> <X size={18} color={c.textSecondary} />
</Pressable> </Pressable>
</View> </View>
</View> </View>
<ScrollView> <ScrollView>
{keywords.length === 0 ? ( {keywords.length === 0 && unknownIds.length === 0 ? (
<Text style={styles.empty}>No tags defined. Add tags in Settings.</Text> <Text style={styles.empty}>{t('email_list.no_tags_defined', 'No tags defined. Add tags in Settings.')}</Text>
) : ( ) : (
keywords.map((kw) => { keywords.map((kw) => {
const token = keywordToken(kw.id); const token = keywordToken(kw.id);
@@ -93,6 +109,22 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
); );
}) })
)} )}
{unknownIds.map((id) => {
const token = keywordToken(id);
const applied = allHaveToken(token);
return (
<Pressable
key={`unknown:${id}`}
onPress={() => onToggle(token, !applied)}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
<Tag size={16} color={tokenColors.tags.gray.dot} fill={tokenColors.tags.gray.dot} />
<Text style={styles.rowLabel} numberOfLines={1}>{id}</Text>
<Text style={styles.rowHint}>{t('email_list.unknown_tag', 'not defined here')}</Text>
{applied && <Check size={16} color={c.primary} />}
</Pressable>
);
})}
</ScrollView> </ScrollView>
</Animated.View> </Animated.View>
</Modal> </Modal>
@@ -138,5 +170,6 @@ function makeStyles(c: ThemePalette) {
}, },
rowPressed: { backgroundColor: c.surfaceHover }, rowPressed: { backgroundColor: c.surfaceHover },
rowLabel: { ...typography.body, color: c.text, flex: 1 }, rowLabel: { ...typography.body, color: c.text, flex: 1 },
rowHint: { ...typography.caption, color: c.textMuted },
}); });
} }
+53
View File
@@ -0,0 +1,53 @@
// Turn a full JMAP Email (Drafts folder row, cancelled scheduled send, undone
// delayed send) into the composer's `draft` route param.
import type { Email } from '../api/types';
import type { ComposeDraftContext } from '../navigation/types';
/**
* Pick the draft's bodies by MIME type. A plain-text-only message lists its
* text/plain part under `htmlBody` too (RFC 8621 §4.1.4), so only treat the
* html part as HTML when it really is (webmail #649).
*/
export function draftBodies(email: Pick<Email, 'textBody' | 'htmlBody' | 'bodyValues'>): { htmlBody?: string; textBody?: string } {
const htmlPart = email.htmlBody?.[0];
const textPart = email.textBody?.[0];
const htmlIsHtml = !!htmlPart?.partId && (!htmlPart.type || htmlPart.type.toLowerCase() === 'text/html');
const textIsText = !!textPart?.partId && (!textPart.type || textPart.type.toLowerCase() === 'text/plain');
const htmlBody = htmlIsHtml && htmlPart?.partId ? email.bodyValues?.[htmlPart.partId]?.value : undefined;
const textBody = textIsText && textPart?.partId ? email.bodyValues?.[textPart.partId]?.value : undefined;
return { htmlBody: htmlBody || undefined, textBody: textBody || undefined };
}
export function draftContextFromEmail(email: Email, jmapAccountId?: string): ComposeDraftContext {
const { htmlBody, textBody } = draftBodies(email);
return {
id: email.id,
jmapAccountId,
from: email.from ?? undefined,
to: email.to ?? undefined,
cc: email.cc ?? undefined,
bcc: email.bcc ?? undefined,
subject: email.subject ?? '',
htmlBody,
textBody,
// Inline images referenced from the body are re-hydrated by the composer;
// everything else rides along as a blob reference.
attachments: email.attachments ?? undefined,
messageId: email.messageId ?? undefined,
inReplyTo: email.inReplyTo ?? undefined,
references: email.references ?? undefined,
};
}
/**
* A row that should open the composer instead of the reader: anything in the
* Drafts folder, or a message flagged `$draft` elsewhere.
*/
export function isDraftEmail(
email: Pick<Email, 'keywords'> | null | undefined,
mailboxRole?: string | null,
): boolean {
if (mailboxRole === 'drafts') return true;
return !!email?.keywords?.$draft;
}
File diff suppressed because it is too large Load Diff