From 3194b5ece158996c3c89ce7d218666bfeaa5decf Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:23:08 +0200 Subject: [PATCH] feat: tags, pins, recipients, not-spam, delete confirm, folder-scoped search and draft editing in the mail list --- docs/parity/02-mail-list-folders.md | 48 +- src/components/MoveSheet.tsx | 13 +- src/components/SenderAvatar.tsx | 10 +- src/components/SwipeableRow.tsx | 48 +- src/components/TagSheet.tsx | 39 +- src/lib/draft-context.ts | 53 ++ src/screens/EmailListScreen.tsx | 838 +++++++++++++++++++++++----- 7 files changed, 858 insertions(+), 191 deletions(-) create mode 100644 src/lib/draft-context.ts diff --git a/docs/parity/02-mail-list-folders.md b/docs/parity/02-mail-list-folders.md index 2ade989a..d5f498fe 100644 --- a/docs/parity/02-mail-list-folders.md +++ b/docs/parity/02-mail-list-folders.md @@ -7,7 +7,7 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou ### 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 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`. @@ -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. - 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 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`. @@ -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`). - 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 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. -- [ ] **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 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`. @@ -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. - 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 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. @@ -120,12 +120,12 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou ### 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 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). -- [ ] **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 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. @@ -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`). - 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:' }`; 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 RN does: none (no reference to `$answered` anywhere in `src/`). - 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 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). @@ -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. - 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 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. -- [ ] **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 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). @@ -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". - 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 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))`. -- [ ] **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`). - 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 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". -- [ ] **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 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. -- [ ] **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 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'`. -- [ ] **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 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`). -- [ ] **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 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. -- [ ] **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 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. -- [ ] **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 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. @@ -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`). - 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 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.*`). ### 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 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`. -- [ ] **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 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)`. -- [ ] **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 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`. diff --git a/src/components/MoveSheet.tsx b/src/components/MoveSheet.tsx index 3c685093..cb9869e4 100644 --- a/src/components/MoveSheet.tsx +++ b/src/components/MoveSheet.tsx @@ -11,6 +11,8 @@ import { spacing, radius, typography, type ThemePalette } from '../theme/tokens' import { useColors } from '../theme/colors'; import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree'; 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'; function moveTargetIcon(role: string | null | undefined, name: string): LucideIcon { @@ -38,6 +40,7 @@ export function MoveSheet({ }: MoveSheetProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); const insets = useSafeAreaInsets(); const slideY = React.useRef(new Animated.Value(500)).current; const overlayOpacity = React.useRef(new Animated.Value(0)).current; @@ -88,7 +91,7 @@ export function MoveSheet({ - Move to folder + {t('context_menu.move_to', 'Move to folder')} @@ -98,9 +101,11 @@ export function MoveSheet({ {visibleNodes.map((node) => { const Icon = moveTargetIcon(node.role, node.name); 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 = - !node.isAccountNode && node.myRights?.mayAddItems !== false && !isCurrent; + !node.isAccountNode && node.myRights?.mayAddItems !== false && !isCurrent + && node.role !== 'drafts'; return ( - {node.name} + {node.isAccountNode ? node.name : localizeMailboxName(node.role, node.name, t)} {isCurrent && } diff --git a/src/components/SenderAvatar.tsx b/src/components/SenderAvatar.tsx index 2838579c..a6efa775 100644 --- a/src/components/SenderAvatar.tsx +++ b/src/components/SenderAvatar.tsx @@ -15,13 +15,19 @@ interface SenderAvatarProps { email?: string | null; size?: number; style?: StyleProp; + /** + * 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: // 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 }: SenderAvatarProps) { - const senderFavicons = useSettingsStore((s) => s.senderFavicons); +export default function SenderAvatar({ name, email, size = 40, style, disableImages }: SenderAvatarProps) { + const senderFavicons = useSettingsStore((s) => s.senderFavicons) && !disableImages; const [imgError, setImgError] = React.useState(false); React.useEffect(() => { diff --git a/src/components/SwipeableRow.tsx b/src/components/SwipeableRow.tsx index b594f2c2..3924cea2 100644 --- a/src/components/SwipeableRow.tsx +++ b/src/components/SwipeableRow.tsx @@ -3,11 +3,12 @@ import { View, Text, StyleSheet, Animated, PanResponder, Pressable, Dimensions, } from 'react-native'; import { - Archive, Trash2, ShieldAlert, MailOpen, Star, Pin, FolderInput, + Archive, Trash2, ShieldAlert, ShieldCheck, MailOpen, Mail, Star, Pin, PinOff, FolderInput, type LucideIcon, } from 'lucide-react-native'; import { typography, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; +import { useLocaleStore } from '../stores/locale-store'; import type { SwipeAction, SwipeMode } from '../stores/settings-store'; import { shouldClaimGesture, dragOffset, resolveRelease, exitsRow, @@ -22,7 +23,7 @@ interface SwipeableRowProps { /** Left-to-right swipe action (revealed under a rightward drag, sits at left edge). */ rightAction: SwipeAction; /** 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; /** * 'instant' (default): swipe past COMMIT_THRESHOLD and release fires the action. @@ -31,6 +32,14 @@ interface SwipeableRowProps { 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. const EXIT_DISTANCE = 600; @@ -44,12 +53,26 @@ const ACTION_META: Record, { icon: LucideIcon; bg: move: { icon: FolderInput, bg: '#475569', defaultLabel: 'Move' }, }; -function actionLabel(action: SwipeAction, context: { unread: boolean; starred: boolean; pinned: boolean }): string { - if (action === 'read') return context.unread ? 'Read' : 'Unread'; - if (action === 'star') return context.starred ? 'Unstar' : 'Star'; - if (action === 'pin') return context.pinned ? 'Unpin' : 'Pin'; - if (action === 'none') return ''; - return ACTION_META[action].defaultLabel; +type Translate = (key: string, fallback?: string) => string; + +function actionLabel(action: SwipeAction, context: SwipeContext, t: Translate): string { + switch (action) { + case 'none': return ''; + 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, 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({ @@ -57,6 +80,7 @@ export function SwipeableRow({ }: SwipeableRowProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); const dx = useRef(new Animated.Value(0)).current; const claimed = useRef(false); const widthRef = useRef(Dimensions.get('window').width); @@ -186,8 +210,8 @@ export function SwipeableRow({ const renderRevealBand = (action: SwipeAction, side: 'left' | 'right') => { if (action === 'none') return null; const meta = ACTION_META[action]; - const Icon = meta.icon; - const label = actionLabel(action, context); + const Icon = actionIcon(action, context); + const label = actionLabel(action, context, t); return ( fireFromBandTap(action)} @@ -228,8 +252,8 @@ export function SwipeableRow({ const renderInstantBand = (action: SwipeAction, side: 'left' | 'right') => { if (action === 'none') return null; const meta = ACTION_META[action]; - const Icon = meta.icon; - const label = actionLabel(action, context); + const Icon = actionIcon(action, context); + const label = actionLabel(action, context, t); const inputRange = side === 'left' ? [0, COMMIT_THRESHOLD] : [-COMMIT_THRESHOLD, 0]; const iconScale = dx.interpolate({ inputRange, diff --git a/src/components/TagSheet.tsx b/src/components/TagSheet.tsx index 079c9caf..0a9734ed 100644 --- a/src/components/TagSheet.tsx +++ b/src/components/TagSheet.tsx @@ -8,6 +8,8 @@ import { spacing, radius, typography, colors as tokenColors, type ThemePalette } import { useColors } from '../theme/colors'; import { useSheetDrag } from '../lib/use-sheet-drag'; 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'; interface TagSheetProps { @@ -23,6 +25,7 @@ interface TagSheetProps { export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle }: TagSheetProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); const insets = useSafeAreaInsets(); const slideY = React.useRef(new Animated.Value(500)).current; const overlayOpacity = React.useRef(new Animated.Value(0)).current; @@ -50,6 +53,17 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle [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(); + for (const e of selectedEmails) { + for (const id of getEmailTagIds(e.keywords)) if (!known.has(id)) out.add(id); + } + return [...out]; + }, [keywords, selectedEmails]); + return ( @@ -66,15 +80,17 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle - Tag {selectedEmails.length} selected + + {t('context_menu.items_selected', `${selectedEmails.length} emails selected`, { count: selectedEmails.length })} + - {keywords.length === 0 ? ( - No tags defined. Add tags in Settings. + {keywords.length === 0 && unknownIds.length === 0 ? ( + {t('email_list.no_tags_defined', 'No tags defined. Add tags in Settings.')} ) : ( keywords.map((kw) => { 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 ( + onToggle(token, !applied)} + style={({ pressed }) => [styles.row, pressed && styles.rowPressed]} + > + + {id} + {t('email_list.unknown_tag', 'not defined here')} + {applied && } + + ); + })} @@ -138,5 +170,6 @@ function makeStyles(c: ThemePalette) { }, rowPressed: { backgroundColor: c.surfaceHover }, rowLabel: { ...typography.body, color: c.text, flex: 1 }, + rowHint: { ...typography.caption, color: c.textMuted }, }); } diff --git a/src/lib/draft-context.ts b/src/lib/draft-context.ts new file mode 100644 index 00000000..370436a4 --- /dev/null +++ b/src/lib/draft-context.ts @@ -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): { 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 | null | undefined, + mailboxRole?: string | null, +): boolean { + if (mailboxRole === 'drafts') return true; + return !!email?.keywords?.$draft; +} diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index 49c855f7..ef556ca9 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -7,7 +7,10 @@ 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, } from 'lucide-react-native'; +import { useNavigation } from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; import { useTypography, useDensity } from '../theme/dynamic'; @@ -19,15 +22,27 @@ import { TagSheet } from '../components/TagSheet'; import { UndoSnackbar } from '../components/UndoSnackbar'; import { OfflineBanner } from '../components/OfflineBanner'; import { useNetworkStore } from '../stores/network-store'; -import { useEmailStore, type EmailFilters } from '../stores/email-store'; +import { useEmailStore, effectiveFolderScope, type EmailFilters } from '../stores/email-store'; import { useSettingsStore, type SwipeAction } from '../stores/settings-store'; -import { useKeywordsStore } from '../stores/keywords-store'; +import { useKeywordsStore, type KeywordDef } from '../stores/keywords-store'; import { useLocaleStore } from '../stores/locale-store'; +import { useSearchHistoryStore } from '../stores/search-history-store'; +import { useContactsStore } from '../stores/contacts-store'; +import { getContactDisplayName } from '../lib/contact-utils'; import { formatListDate } from '../lib/date-format'; import { findTrashMailbox, findArchiveMailbox, findJunkMailbox, mailboxesForSiblingOf, ownMailboxes, } from '../lib/mailbox-tree'; +import { localizeMailboxName } from '../lib/mailbox-label'; +import { + collapseThreads, groupByThread, expandThreadSelection, getThreadTagIds, threadKeyOf, +} from '../lib/thread-utils'; +import { isPermanentDelete, confirmPermanentDelete } from '../lib/delete-confirm'; +import { draftContextFromEmail, isDraftEmail } from '../lib/draft-context'; +import { getThreads, getFullEmail, emptyMailbox as apiEmptyMailbox } from '../api/email'; +import type { RootStackParamList } from '../navigation/types'; import type { Email } from '../api/types'; + function getSenderName(email: Email): string { return email.from?.[0]?.name || email.from?.[0]?.email || 'Unknown'; } @@ -36,6 +51,15 @@ function getSenderEmail(email: Email): string | undefined { return email.from?.[0]?.email; } +// Sent/Drafts rows name the recipient, not "me" (webmail 1.4.12). +function getCounterpart(email: Email, showRecipient: boolean): { name: string; email?: string } { + if (showRecipient) { + const to = email.to?.[0] ?? email.cc?.[0] ?? email.bcc?.[0]; + if (to) return { name: to.name || to.email, email: to.email }; + } + return { name: getSenderName(email), email: getSenderEmail(email) }; +} + function isUnread(email: Email): boolean { return !email.keywords?.$seen; } @@ -45,7 +69,7 @@ function isStarred(email: Email): boolean { } function isPinned(email: Email): boolean { - return !!email.keywords?.$important; + return !!email.keywords?.$pinned; } // Height of the sender line when the avatar is hidden (extra-compact density), @@ -56,6 +80,12 @@ const EmailRow = React.memo(function EmailRow({ item, threadCount, showPreview, + showRecipient, + tagIds, + keywordDefs, + disableAvatarImages, + answered, + forwarded, onPress, onLongPress, selected, @@ -64,6 +94,13 @@ const EmailRow = React.memo(function EmailRow({ item: Email; threadCount: number; showPreview: boolean; + showRecipient: boolean; + /** Comma-joined tag ids of the row (thread union) — a string so memo holds. */ + tagIds: string; + keywordDefs: KeywordDef[]; + disableAvatarImages: boolean; + answered: boolean; + forwarded: boolean; onPress: (id: string) => void; onLongPress: (id: string) => void; selected: boolean; @@ -77,10 +114,21 @@ const EmailRow = React.memo(function EmailRow({ const dateFormat = useSettingsStore((s) => s.dateFormat); const timeFormat = useSettingsStore((s) => s.timeFormat); const locale = useLocaleStore((s) => s.locale); - const senderName = getSenderName(item); - const senderEmail = getSenderEmail(item); + const { name: senderName, email: senderEmail } = getCounterpart(item, showRecipient); const unread = isUnread(item); const starred = isStarred(item); + const pinned = isPinned(item); + const tags = React.useMemo(() => { + if (!tagIds) return []; + return tagIds.split(',').map((id) => { + const def = keywordDefs.find((k) => k.id === id); + return def + ? { id, label: def.label, dot: c.tags[def.color]?.dot ?? c.textMuted, text: c.tags[def.color]?.text ?? c.textSecondary, bg: c.tags[def.color]?.bg ?? c.muted } + // A tag no local definition explains (set by another client): grey, + // with the raw id so it is at least visible and removable. + : { id, label: id, dot: c.tags.gray.dot, text: c.tags.gray.text, bg: c.tags.gray.bg }; + }); + }, [tagIds, keywordDefs, c]); const handlePress = React.useCallback(() => onPress(item.id), [onPress, item.id]); const handleLongPress = React.useCallback(() => onLongPress(item.id), [onLongPress, item.id]); @@ -115,7 +163,12 @@ const EmailRow = React.memo(function EmailRow({ )} {density.showAvatar && ( - + )} {/* Content */} @@ -126,9 +179,18 @@ const EmailRow = React.memo(function EmailRow({ {senderName} + {pinned && ( + + )} {starred && ( )} + {answered && ( + + )} + {forwarded && ( + + )} {item.hasAttachment && ( )} @@ -143,11 +205,20 @@ const EmailRow = React.memo(function EmailRow({ - {/* Row 2: Subject */} + {/* Row 2: Subject + tag pills */} {item.subject || '(no subject)'} + {tags.slice(0, 3).map((tag) => ( + + + {tag.label} + + ))} + {tags.length > 3 && ( + +{tags.length - 3} + )} {/* Row 3: Preview - hidden in compact density modes regardless of toggle */} @@ -207,11 +278,18 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL const deleteEmailsBatch = useEmailStore((s) => s.deleteEmailsBatch); const setKeywordForEmails = useEmailStore((s) => s.setKeywordForEmails); const setSortAscending = useEmailStore((s) => s.setSortAscending); + const markSpam = useEmailStore((s) => s.markSpam); + const unmarkSpam = useEmailStore((s) => s.unmarkSpam); + const navigation = useNavigation>(); const keywordDefs = useKeywordsStore((s) => s.keywords); const keywordsHydrated = useKeywordsStore((s) => s.hydrated); const hydrateKeywords = useKeywordsStore((s) => s.hydrate); React.useEffect(() => { if (!keywordsHydrated) void hydrateKeywords(); }, [keywordsHydrated, hydrateKeywords]); + const addRecentSearch = useSearchHistoryStore((s) => s.addRecentSearch); + const recentSearches = useSearchHistoryStore((s) => s.recentSearches); + const removeRecentSearch = useSearchHistoryStore((s) => s.removeRecentSearch); + const contacts = useContactsStore((s) => s.contacts); const swipeLeftAction = useSettingsStore((s) => s.swipeLeftAction); const swipeRightAction = useSettingsStore((s) => s.swipeRightAction); @@ -219,33 +297,84 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL const showPreview = useSettingsStore((s) => s.showPreview); const disableThreading = useSettingsStore((s) => s.disableThreading); const sortAscending = useSettingsStore((s) => s.mailSortAscending); + const showAvatarsInJunk = useSettingsStore((s) => s.showAvatarsInJunk); + const deleteAction = useSettingsStore((s) => s.deleteAction); + const permanentlyDeleteJunk = useSettingsStore((s) => s.permanentlyDeleteJunk); const networkOnline = useNetworkStore((s) => s.online); + const currentMailbox = React.useMemo( + () => mailboxes.find((m) => m.id === currentMailboxId), + [mailboxes, currentMailboxId], + ); + const currentRole = currentMailbox?.role ?? null; + // The JMAP account behind the open folder (undefined = the user's own). + const currentOwnerAccountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined; + const inJunk = currentRole === 'junk' || currentRole === 'spam'; + const showRecipient = currentRole === 'sent' || currentRole === 'drafts'; + // When threading is on, collapse same-thread emails so the list shows the - // most recent message per thread with a count badge. Disabling threading - // falls back to the flat list (every message is its own row). - const visibleEmails = React.useMemo(() => { - if (disableThreading) return emails; - const seen = new Set(); - const out: Email[] = []; - for (const e of emails) { - const key = e.threadId || e.id; - if (seen.has(key)) continue; - seen.add(key); - out.push(e); + // newest message per thread with a count badge (pinned threads first). + // Disabling threading falls back to the flat list (every message is its + // own row). + const visibleEmails = React.useMemo( + () => collapseThreads(emails, disableThreading), + [emails, disableThreading], + ); + const threadGroups = React.useMemo( + () => groupByThread(emails, disableThreading), + [emails, disableThreading], + ); + + // Real conversation sizes from Thread/get (a thread's other messages may + // live in other folders); the loaded-page count is the fallback until the + // response lands and for messages whose thread the server no longer knows. + const [serverThreadCounts, setServerThreadCounts] = React.useState>(new Map()); + React.useEffect(() => { + if (disableThreading || visibleEmails.length === 0) return; + const ids = Array.from(new Set(visibleEmails.map((e) => e.threadId).filter(Boolean))); + const missing = ids.filter((id) => !serverThreadCounts.has(id)); + if (missing.length === 0) return; + let cancelled = false; + getThreads(missing, currentOwnerAccountId) + .then((threads) => { + if (cancelled) return; + setServerThreadCounts((prev) => { + const next = new Map(prev); + for (const th of threads) next.set(th.id, th.emailIds.length); + return next; + }); + }) + .catch(() => { /* fall back to the in-page count */ }); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visibleEmails, disableThreading, currentOwnerAccountId]); + // Counts are per account and per snapshot; drop them on folder switch. + React.useEffect(() => { setServerThreadCounts(new Map()); }, [currentMailboxId, disableThreading]); + + const threadCountFor = React.useCallback((e: Email): number => { + if (disableThreading) return 1; + const local = threadGroups.get(threadKeyOf(e, false))?.length ?? 1; + const server = e.threadId ? serverThreadCounts.get(e.threadId) : undefined; + return Math.max(local, server ?? 1); + }, [disableThreading, threadGroups, serverThreadCounts]); + + // Tags per row: the union of every loaded message of the thread (the row + // stands in for all of them), joined so the memoized row keeps its identity. + const rowTagIds = React.useMemo(() => { + const out = new Map(); + for (const [key, list] of threadGroups) out.set(key, getThreadTagIds(list).join(',')); + return out; + }, [threadGroups]); + const rowFlags = React.useMemo(() => { + const out = new Map(); + for (const [key, list] of threadGroups) { + out.set(key, { + answered: list.some((e) => !!e.keywords?.$answered), + forwarded: list.some((e) => !!e.keywords?.$forwarded), + }); } return out; - }, [emails, disableThreading]); - - const threadCounts = React.useMemo(() => { - if (disableThreading) return new Map(); - const counts = new Map(); - for (const e of emails) { - const key = e.threadId || e.id; - counts.set(key, (counts.get(key) ?? 0) + 1); - } - return counts; - }, [emails, disableThreading]); + }, [threadGroups]); // Role folders must come from the account the open folder belongs to: a // shared (group account) message can only be filed into that account's @@ -342,15 +471,69 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL }); }, []); + // A draft opens in the composer, not the read-only viewer (webmail + // `handleEditDraft`). Needs the full message for its body and attachments. + const [openingDraftId, setOpeningDraftId] = React.useState(null); + const openDraft = React.useCallback(async (email: Email) => { + if (openingDraftId) return; + setOpeningDraftId(email.id); + try { + const full = await getFullEmail(email.id, currentOwnerAccountId); + navigation.navigate('Compose', { draft: draftContextFromEmail(full, currentOwnerAccountId) }); + } catch (err) { + Alert.alert( + t('email_list.error', 'Error'), + err instanceof Error ? err.message : t('email_list.open_draft_failed', 'Could not open the draft.'), + ); + } finally { + setOpeningDraftId(null); + } + }, [openingDraftId, currentOwnerAccountId, navigation, t]); + const openDraftRef = React.useRef(openDraft); + React.useEffect(() => { openDraftRef.current = openDraft; }, [openDraft]); + const currentRoleRef = React.useRef(currentRole); + React.useEffect(() => { currentRoleRef.current = currentRole; }, [currentRole]); + const handleRowPress = React.useCallback((id: string) => { if (selectionModeRef.current) { toggleSelect(id); } else { const email = emailsRef.current.find((e) => e.id === id); - if (email) onEmailPressRef.current?.(email); + if (!email) return; + if (isDraftEmail(email, currentRoleRef.current)) void openDraftRef.current(email); + else onEmailPressRef.current?.(email); } }, [toggleSelect]); + // Every loaded message of the row's thread: swipe/batch actions on a + // collapsed conversation act on all of them, not just the representative. + const idsForRow = React.useCallback( + (id: string) => expandThreadSelection([id], emailsRef.current, disableThreading), + [disableThreading], + ); + + // Trash / permanent delete with the confirm the webmail shows before any + // destroy (Trash folder, "permanent" delete action, junk auto-permanent). + const deleteIds = React.useCallback(async (ids: string[]) => { + if (!currentMailboxId) return; + if (!trashMailboxId) { + Alert.alert( + t('email_list.error', 'Error'), + t('email_list.no_trash_folder', 'Could not find a Trash folder on the server. Please check your mailbox configuration.'), + ); + return; + } + const permanent = isPermanentDelete({ + inTrash: currentMailboxId === trashMailboxId, + inJunk, + deleteAction, + permanentlyDeleteJunk, + }); + if (permanent && !(await confirmPermanentDelete(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]); + const handleSwipeAction = React.useCallback((id: string, action: SwipeAction) => { if (action === 'none') return; const email = emailsRef.current.find((e) => e.id === id); @@ -358,7 +541,9 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL switch (action) { case 'archive': if (archiveMailboxId && currentMailboxId !== archiveMailboxId) { - void archiveEmailAction(id); + const ids = idsForRow(id); + if (ids.length > 1) void archiveEmailsBatch(ids); + else void archiveEmailAction(id); } else if (!archiveMailboxId) { Alert.alert( t('email_list.error', 'Error'), @@ -367,19 +552,17 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL } break; case 'delete': - if (trashMailboxId) { - void deleteEmailAction(id, trashMailboxId, currentMailboxId); - } else { - Alert.alert( - t('email_list.error', 'Error'), - t('email_list.no_trash_folder', 'Could not find a Trash folder on the server. Please check your mailbox configuration.'), - ); - } + void deleteIds(idsForRow(id)); break; case 'spam': - if (junkMailboxId && currentMailboxId !== junkMailboxId) { - void moveToMailboxAction(id, currentMailboxId, junkMailboxId); - } else if (!junkMailboxId) { + // Your own outgoing mail is never spam (webmail hides the action in + // Sent/Drafts); inside Junk the same swipe means "not spam". + if (currentRole === 'sent' || currentRole === 'drafts') break; + if (inJunk) { + void unmarkSpam(idsForRow(id)); + } else if (junkMailboxId) { + void markSpam(idsForRow(id)); + } else { Alert.alert( t('email_list.error', 'Error'), t('email_list.no_junk_folder', 'Could not find a Spam/Junk folder on the server.'), @@ -401,32 +584,46 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL break; } }, [ - currentMailboxId, archiveMailboxId, trashMailboxId, junkMailboxId, - moveToMailboxAction, archiveEmailAction, deleteEmailAction, + currentMailboxId, archiveMailboxId, junkMailboxId, currentRole, inJunk, + archiveEmailAction, archiveEmailsBatch, deleteIds, markSpam, unmarkSpam, idsForRow, markRead, markUnread, toggleStar, togglePin, t, ]); const renderEmailRow = React.useCallback( - ({ item }: { item: Email }) => ( - handleSwipeAction(item.id, action)} - > - - - ), - [selectedIds, selectionMode, handleRowPress, toggleSelect, swipeLeftAction, swipeRightAction, swipeMode, handleSwipeAction], + ({ item }: { item: Email }) => { + const key = threadKeyOf(item, disableThreading); + const flags = rowFlags.get(key); + return ( + handleSwipeAction(item.id, action)} + > + + + ); + }, + [ + selectedIds, selectionMode, handleRowPress, toggleSelect, swipeLeftAction, swipeRightAction, + swipeMode, handleSwipeAction, disableThreading, rowFlags, rowTagIds, threadCountFor, + showPreview, showRecipient, keywordDefs, inJunk, showAvatarsInJunk, + ], ); const clearSelection = React.useCallback(() => { @@ -446,15 +643,23 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL setSelectedIds(new Set()); }, [currentMailboxId]); - const selectedEmails = React.useMemo( - () => visibleEmails.filter((e) => selectedIds.has(e.id)), - [visibleEmails, selectedIds], + // `selectedIds` holds the representative row ids; every action expands to + // all loaded messages of the selected conversations (webmail + // `toggleThreadSelection`), so "3 selected" conversations never means + // "3 messages touched". + const selectedMessageIds = React.useMemo( + () => expandThreadSelection(selectedIds, emails, disableThreading), + [selectedIds, emails, disableThreading], ); + const selectedEmails = React.useMemo(() => { + const wanted = new Set(selectedMessageIds); + return emails.filter((e) => wanted.has(e.id)); + }, [emails, selectedMessageIds]); const allSelectedAreRead = selectedEmails.length > 0 && selectedEmails.every((e) => !isUnread(e)); const allSelectedAreStarred = selectedEmails.length > 0 && selectedEmails.every((e) => isStarred(e)); const handleBulkMarkReadToggle = async () => { - const ids = Array.from(selectedIds); + const ids = selectedMessageIds; if (allSelectedAreRead) { await Promise.all(ids.map((id) => markUnread(id))); } else { @@ -464,7 +669,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL }; const handleBulkStar = async () => { - const ids = Array.from(selectedIds); + const ids = selectedMessageIds; const next = !allSelectedAreStarred; await Promise.all(ids.map((id) => toggleStar(id, next))); clearSelection(); @@ -476,34 +681,54 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL clearSelection(); return; } - await deleteEmailsBatch(Array.from(selectedIds), trash.id, currentMailboxId); + const ids = selectedMessageIds; + const permanent = isPermanentDelete({ + inTrash: currentMailboxId === trash.id, + inJunk, + deleteAction, + permanentlyDeleteJunk, + }); + if (permanent && !(await confirmPermanentDelete(ids.length, t))) return; + await deleteEmailsBatch(ids, trash.id, currentMailboxId); clearSelection(); }; const handleBulkArchive = async () => { - await archiveEmailsBatch(Array.from(selectedIds)); + await archiveEmailsBatch(selectedMessageIds); + clearSelection(); + }; + + const handleBulkSpam = async () => { + const ids = selectedMessageIds; + if (inJunk) await unmarkSpam(ids); + else await markSpam(ids); clearSelection(); }; const canArchiveSelection = archiveMailboxId != null && currentMailboxId !== archiveMailboxId; + const canSpamSelection = + currentRole !== 'sent' && currentRole !== 'drafts' && (inJunk || junkMailboxId != null); const handleBatchMovePick = (toId: string) => { - const ids = Array.from(selectedIds); + const ids = selectedMessageIds; setBatchMoveOpen(false); clearSelection(); void moveEmailsToMailbox(ids, toId); }; const handleBatchTagToggle = (token: string, on: boolean) => { - void setKeywordForEmails(Array.from(selectedIds), token, on); + void setKeywordForEmails(selectedMessageIds, token, on); }; - // Local input state for uninterrupted typing; debounce into the store. + // Local input state for uninterrupted typing. The search runs on submit, + // or after a 600 ms pause once at least two characters are typed — with + // the `*` wildcard a single letter matches the whole mailbox. const [searchInput, setSearchInput] = React.useState(storeSearchQuery); + const [searchFocused, setSearchFocused] = React.useState(false); React.useEffect(() => { // Keep local input in sync when the store is cleared externally - // (e.g. mailbox switch resets searchQuery to ''). + // (e.g. clearing the search from the chips row). if (storeSearchQuery !== searchInput && storeSearchQuery === '') { setSearchInput(''); } @@ -511,30 +736,80 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL }, [storeSearchQuery]); React.useEffect(() => { if (searchInput === storeSearchQuery) return; - const id = setTimeout(() => setSearchQuery(searchInput), 300); + const trimmed = searchInput.trim(); + if (trimmed.length > 0 && trimmed.length < 2) return; + const id = setTimeout(() => setSearchQuery(searchInput), 600); return () => clearTimeout(id); }, [searchInput, storeSearchQuery, setSearchQuery]); + const submitSearch = React.useCallback((value: string) => { + setSearchInput(value); + setSearchQuery(value); + if (value.trim()) addRecentSearch(value); + setSearchFocused(false); + }, [setSearchQuery, addRecentSearch]); + // People whose name/address matches what is being typed (#845); picking + // one turns the search into a `from:` filter like the webmail. + const contactSuggestions = React.useMemo(() => { + const q = searchInput.trim().toLowerCase(); + if (q.length < 2) return []; + const out: Array<{ name: string; email: string }> = []; + for (const card of contacts) { + const name = getContactDisplayName(card); + for (const em of Object.values(card.emails ?? {})) { + const address = em.address; + if (!address) continue; + if (name.toLowerCase().includes(q) || address.toLowerCase().includes(q)) { + out.push({ name, email: address }); + break; + } + } + if (out.length >= 4) break; + } + return out; + }, [contacts, searchInput]); const activeFilterCount = (filters.from ? 1 : 0) + (filters.to ? 1 : 0) + (filters.subject ? 1 : 0) + + (filters.body ? 1 : 0) + (filters.dateAfter ? 1 : 0) + (filters.dateBefore ? 1 : 0) + (filters.hasAttachment !== undefined ? 1 : 0) + (filters.isStarred !== undefined ? 1 : 0) + - (filters.isUnread !== undefined ? 1 : 0); + (filters.isUnread !== undefined ? 1 : 0) + + (filters.keyword ? 1 : 0) + + (filters.folder && filters.folder !== 'current' ? 1 : 0); const hasActiveSearchOrFilter = Boolean(storeSearchQuery) || activeFilterCount > 0; + const folderScope = effectiveFolderScope(storeSearchQuery, filters); + const scopeFolderName = React.useMemo(() => { + if (folderScope === 'all' || folderScope === 'current') return null; + const m = mailboxes.find((mb) => mb.id === folderScope); + return m ? localizeMailboxName(m.role, m.name, t) : folderScope; + }, [folderScope, mailboxes, t]); + const keywordFilterLabel = React.useMemo(() => { + if (!filters.keyword) return null; + const id = filters.keyword.replace(/^\$label:/, '').replace(/^\$color:/, ''); + return keywordDefs.find((k) => k.id === id)?.label ?? id; + }, [filters.keyword, keywordDefs]); + const setFolderScope = (scope: string) => { + const updated: EmailFilters = { ...filters }; + if (scope === 'current') delete updated.folder; + else updated.folder = scope; + setFilters(updated); + }; - const cycleTriState = (key: 'hasAttachment' | 'isStarred' | 'isUnread') => { - const current = filters[key]; - // unset → true → false → unset - const next = current === undefined ? true : current === true ? false : undefined; + const cycleTriStateTo = (key: 'hasAttachment' | 'isStarred' | 'isUnread', next: boolean | undefined) => { const updated: EmailFilters = { ...filters }; if (next === undefined) delete updated[key]; else updated[key] = next; setFilters(updated); }; + const cycleTriState = (key: 'hasAttachment' | 'isStarred' | 'isUnread') => { + const current = filters[key]; + // unset → true → false → unset + cycleTriStateTo(key, current === undefined ? true : current === true ? false : undefined); + }; const setFilterField = (key: keyof EmailFilters, value: string | undefined) => { const updated: EmailFilters = { ...filters }; @@ -545,10 +820,50 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL const [datePickerField, setDatePickerField] = React.useState<'dateAfter' | 'dateBefore' | null>(null); - const currentMailbox = React.useMemo( - () => mailboxes.find((m) => m.id === currentMailboxId), - [mailboxes, currentMailboxId], - ); + const headerTitle = React.useMemo(() => { + if (!currentMailbox) return t('sidebar.mailboxes.inbox', 'Inbox'); + const name = localizeMailboxName(currentMailbox.role, currentMailbox.name, t); + // Name the owning group account — a bare "Inbox" would look like the + // user's own. + return currentMailbox.isShared && currentMailbox.accountName + ? `${currentMailbox.accountName} · ${name}` + : name; + }, [currentMailbox, t]); + + // "Empty folder" for Trash and Junk (webmail banner; #711 pagination lives + // in the api helper). + const [emptying, setEmptying] = React.useState(false); + const canEmptyFolder = + (currentRole === 'trash' || inJunk) && !!currentMailbox && (currentMailbox.totalEmails > 0 || emails.length > 0); + const handleEmptyFolder = () => { + if (!currentMailbox || emptying) return; + Alert.alert( + t('email_list.empty_folder.confirm_title', 'Empty folder'), + t('email_list.empty_folder.confirm_message', 'All emails in this folder will be permanently deleted. This action cannot be undone.'), + [ + { text: t('common.cancel', 'Cancel'), style: 'cancel' }, + { + text: t('email_list.empty_folder.confirm_button', 'Empty folder'), + style: 'destructive', + onPress: () => { + setEmptying(true); + void apiEmptyMailbox(currentMailbox.originalId ?? currentMailbox.id, currentOwnerAccountId) + .then(async () => { + clearSelection(); + await Promise.all([refreshEmails(), fetchMailboxes()]); + }) + .catch((err: unknown) => { + Alert.alert( + t('email_list.error', 'Error'), + err instanceof Error ? err.message : t('mailbox_context_menu.toast_error_empty', 'Failed to empty folder'), + ); + }) + .finally(() => setEmptying(false)); + }, + }, + ], + ); + }; // Load mailboxes and select inbox on mount React.useEffect(() => { @@ -611,6 +926,20 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL > + {canSpamSelection && ( + { void handleBulkSpam(); }} + style={styles.headerButton} + hitSlop={6} + accessibilityLabel={inJunk ? t('context_menu.not_spam', 'Not spam') : t('context_menu.mark_as_spam', 'Report spam')} + > + {inJunk ? ( + + ) : ( + + )} + + )} {canArchiveSelection && ( { void handleBulkArchive(); }} @@ -634,11 +963,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL - {currentMailbox?.isShared && currentMailbox.accountName - // Name the owning group account — a bare "Inbox" would look like - // the user's own. - ? `${currentMailbox.accountName} · ${currentMailbox.name}` - : currentMailbox?.name ?? 'Inbox'} + {headerTitle} setSearchFocused(true)} + // Delay so a tap on a recent-search row lands before the list hides. + onBlur={() => { setTimeout(() => setSearchFocused(false), 150); }} + onSubmitEditing={() => submitSearch(searchInput)} autoCapitalize="none" autoCorrect={false} returnKeyType="search" /> {searchInput.length > 0 && ( setSearchInput('')} + onPress={() => { setSearchInput(''); setSearchQuery(''); }} hitSlop={8} style={styles.searchClearButton} > @@ -722,66 +1051,127 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL + {searchFocused && ((!searchInput.trim() && recentSearches.length > 0) || contactSuggestions.length > 0) && ( + + {!searchInput.trim() && recentSearches.length > 0 && ( + <> + {t('advanced_search.suggestions_recent', 'Recent searches')} + {recentSearches.slice(0, 5).map((q) => ( + + submitSearch(q)}> + + {q} + + removeRecentSearch(q)} hitSlop={8}> + + + + ))} + + )} + {contactSuggestions.length > 0 && ( + <> + {t('advanced_search.suggestions_people', 'People')} + {contactSuggestions.map((p) => ( + { + setSearchInput(''); + setSearchFocused(false); + setFilters({ ...filters, from: p.email }); + }} + > + + + {p.name && p.name !== p.email ? `${p.name} · ${p.email}` : p.email} + + + ))} + + )} + + )} + {hasActiveSearchOrFilter && ( {storeSearchQuery ? ( - - - - {storeSearchQuery} - - + } + label={storeSearchQuery} + onRemove={() => { setSearchInput(''); setSearchQuery(''); }} + /> + ) : null} + {(storeSearchQuery || activeFilterCount > 0) && ( + } + label={ + folderScope === 'all' + ? t('email_list.scope_all_folders', 'All folders') + : folderScope === 'current' + ? t('email_list.scope_this_folder', 'This folder') + : scopeFolderName ?? '' + } + onPress={() => setFilterMenuOpen(true)} + /> + )} + {keywordFilterLabel ? ( + } + label={keywordFilterLabel} + onRemove={() => setFilterField('keyword', undefined)} + /> ) : null} {filters.from ? ( - - From: {filters.from} - + setFilterField('from', undefined)} /> ) : null} {filters.to ? ( - - To: {filters.to} - + setFilterField('to', undefined)} /> ) : null} {filters.subject ? ( - - Subject: {filters.subject} - + setFilterField('subject', undefined)} /> + ) : null} + {filters.body ? ( + setFilterField('body', undefined)} /> ) : null} {filters.dateAfter ? ( - - - After {filters.dateAfter} - + } + label={`${t('advanced_search.after', 'After')} ${filters.dateAfter}`} + onRemove={() => setFilterField('dateAfter', undefined)} + /> ) : null} {filters.dateBefore ? ( - - - Before {filters.dateBefore} - + } + label={`${t('advanced_search.before', 'Before')} ${filters.dateBefore}`} + onRemove={() => setFilterField('dateBefore', undefined)} + /> ) : null} {filters.isUnread !== undefined && ( - - {filters.isUnread ? ( - - ) : ( - - )} - {filters.isUnread ? 'Unread' : 'Read'} - + + : } + label={filters.isUnread ? t('email_list.unread', 'Unread') : t('advanced_search.read', 'Read')} + onRemove={() => cycleTriStateTo('isUnread', undefined)} + /> )} {filters.isStarred !== undefined && ( - - - {filters.isStarred ? 'Starred' : 'Not starred'} - + } + label={filters.isStarred ? t('email_list.starred', 'Starred') : t('advanced_search.not_starred', 'Not starred')} + onRemove={() => cycleTriStateTo('isStarred', undefined)} + /> )} {filters.hasAttachment !== undefined && ( - - - - {filters.hasAttachment ? 'Has attachment' : 'No attachment'} - - + } + label={filters.hasAttachment + ? t('advanced_search.has_attachment', 'Has attachment') + : t('advanced_search.no_attachment', 'No attachment')} + onRemove={() => cycleTriStateTo('hasAttachment', undefined)} + /> )} { @@ -791,43 +1181,75 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL style={styles.clearAllButton} hitSlop={6} > - Clear + {t('advanced_search.clear', 'Clear')} )} - 0 ? 'Showing cached mail' : undefined} /> + {canEmptyFolder && !selectionMode && ( + + + {inJunk + ? t('email_list.empty_folder.junk_hint', 'You can empty the Junk folder to permanently remove all messages.') + : t('email_list.empty_folder.trash_hint', 'You can empty the Trash folder to permanently remove all messages.')} + + + {emptying ? ( + + ) : ( + {t('email_list.empty_folder.button', 'Empty folder')} + )} + + + )} + + 0 ? t('email_list.showing_cached', 'Showing cached mail') : undefined} /> {/* Email list */} {loading && emails.length === 0 ? ( - Loading emails... + {t('email_list.loading', 'Loading emails...')} ) : error && emails.length === 0 ? ( - {networkOnline ? error : 'No connection. Showing nothing because no mail has been cached yet.'} + {networkOnline + ? error + : t('email_list.offline_nothing_cached', 'No connection. Showing nothing because no mail has been cached yet.')} - { void refreshEmails(); }}> - Retry + { + // A failed first load left no folders behind (lazy provisioning, + // #217): retry the mailbox fetch — refreshEmails() would return + // immediately with no folder selected. + if (mailboxes.length === 0 || !currentMailboxId) void fetchMailboxes(); + else void refreshEmails(); + }} + > + {t('common.retry', 'Retry')} ) : mailboxes.length === 0 ? ( - No mailboxes found + {t('email_list.no_mailboxes', 'No mailboxes found')} - Check that your JMAP account has mail capability. + {t('email_list.no_mailboxes_hint', 'Check that your JMAP account has mail capability.')} + { void fetchMailboxes(); }}> + {t('common.retry', 'Retry')} + ) : emails.length === 0 ? ( - No emails in {currentMailbox?.name ?? 'this folder'} + {hasActiveSearchOrFilter + ? t('email_list.no_search_results', 'No emails found') + : t('email_list.no_emails_in', `No emails in ${headerTitle}`, { folder: headerTitle })} - {currentMailbox ? ( + {currentMailbox && !hasActiveSearchOrFilter ? ( - {currentMailbox.totalEmails} total · {currentMailbox.unreadEmails} unread + {currentMailbox.totalEmails} {t('email_list.total', 'total')} · {currentMailbox.unreadEmails} {t('email_list.unread', 'unread')} ) : null} @@ -915,16 +1337,56 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL - Subject + {t('advanced_search.subject', 'Subject')} setFilterField('subject', v)} - placeholder="Subject contains..." + placeholder={t('advanced_search.subject_placeholder', 'Subject contains...')} placeholderTextColor={c.textMuted} style={styles.filterFieldInput} /> + + {t('advanced_search.body', 'Body')} + setFilterField('body', v)} + placeholder={t('advanced_search.body_placeholder', 'Body contains...')} + placeholderTextColor={c.textMuted} + style={styles.filterFieldInput} + /> + + + {/* Folder scope (#788): all folders by default for a search, + the open folder, or one picked from the account's tree. */} + + {t('advanced_search.folder', 'Folder')} + + setFolderScope('all')} + /> + setFolderScope('current')} + /> + {scopedMailboxes + .filter((m) => m.id !== currentMailboxId) + .slice(0, 12) + .map((m) => ( + setFolderScope(m.id)} + /> + ))} + + + After @@ -1077,6 +1539,43 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL ); } +function FilterChip({ + icon, + label, + onRemove, + onPress, +}: { + icon?: React.ReactNode; + label: string; + /** Per-chip removal (webmail search-chips X). */ + onRemove?: () => void; + onPress?: () => void; +}) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + return ( + + {icon} + {label} + {onRemove ? ( + + + + ) : null} + + ); +} + +function ScopeChip({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + return ( + + {label} + + ); +} + function TriToggle({ icon, label, @@ -1223,7 +1722,54 @@ function makeStyles(c: ThemePalette) { chipText: { ...typography.caption, color: c.textSecondary, + flexShrink: 1, }, + chipRemove: { + width: 16, + height: 16, + alignItems: 'center', + justifyContent: 'center', + }, + recentSearches: { + marginHorizontal: spacing.lg, + marginBottom: spacing.sm, + backgroundColor: c.popover, + borderWidth: 1, + borderColor: c.border, + borderRadius: radius.sm, + paddingVertical: spacing.xs, + }, + recentSearchesTitle: { + ...typography.caption, + color: c.textMuted, + paddingHorizontal: spacing.md, + paddingVertical: 4, + }, + recentSearchRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.md, + minHeight: 36, + gap: spacing.sm, + }, + recentSearchMain: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: spacing.sm }, + recentSearchText: { ...typography.body, color: c.text, flexShrink: 1 }, + emptyFolderBanner: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + marginHorizontal: spacing.lg, + marginBottom: spacing.sm, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radius.sm, + borderWidth: 1, + borderColor: c.border, + backgroundColor: c.surface, + }, + emptyFolderHint: { ...typography.caption, color: c.textSecondary, flex: 1 }, + emptyFolderButton: { paddingHorizontal: spacing.sm, paddingVertical: 4 }, + emptyFolderButtonText: { ...typography.captionMedium, color: c.error }, clearAllButton: { paddingHorizontal: spacing.sm, paddingVertical: 4,