diff --git a/docs/parity/02-mail-list-folders.md b/docs/parity/02-mail-list-folders.md index d5f498fe..905211f7 100644 --- a/docs/parity/02-mail-list-folders.md +++ b/docs/parity/02-mail-list-folders.md @@ -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()`. -- [x] **Empty folder (Trash/Junk) — with #711 pagination** — `P2` — `missing` — fixed in PENDING-B +- [x] **Empty folder (Trash/Junk) — with #711 pagination** — `P2` — `missing` — fixed in 3194b5e - 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,7 +52,7 @@ 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()`. -- [x] **Retry button after a failed first load does not retry the mailbox fetch** — `P2` — `rn-only-bug` — fixed in PENDING-B +- [x] **Retry button after a failed first load does not retry the mailbox fetch** — `P2` — `rn-only-bug` — fixed in 3194b5e - 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. @@ -120,12 +120,12 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou ### List rendering and behaviour -- [x] **Sent/Drafts rows show the sender ("me") instead of the recipient (1.4.12)** — `P2` — `bugfix-parity` — fixed in PENDING-B +- [x] **Sent/Drafts rows show the sender ("me") instead of the recipient (1.4.12)** — `P2` — `bugfix-parity` — fixed in 3194b5e - 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). -- [x] **Tags are not rendered on list rows (and no tag row tint)** — `P2` — `missing` — fixed in PENDING-B +- [x] **Tags are not rendered on list rows (and no tag row tint)** — `P2` — `missing` — fixed in 3194b5e - 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,27 +135,27 @@ 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. -- [x] **Answered / forwarded status icons (1.4.8)** — `P3` — `missing` — fixed in PENDING-B +- [x] **Answered / forwarded status icons (1.4.8)** — `P3` — `missing` — fixed in 3194b5e - 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`. -- [x] **Pin uses `$important` instead of `$pinned`; no pinned-first order or pin icon** — `P2` — `bug` — fixed in PENDING-B +- [x] **Pin uses `$important` instead of `$pinned`; no pinned-first order or pin icon** — `P2` — `bug` — fixed in 3194b5e - 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). -- [ ] **Configurable message-list order presets/custom levels (#718) incl. Stalwart `hasKeyword` polarity probe; native issue #5** — `P2` — `partial` +- [x] **Configurable message-list order presets/custom levels (#718) incl. Stalwart `hasKeyword` polarity probe; native issue #5** — `P2` — `partial` — fixed in PENDING-C - What WEB does: presets (unread/starred/tagged first) or up to 3 levels mapped to the JMAP sort, Inbox-only or all folders (`lib/message-list-order.ts`, `components/settings/message-list-order-settings.tsx:52-62`, `getMessageListOrderFor` `stores/settings-store.ts:1396`); polarity probe per account because Stalwart inverts `isAscending` on keyword comparators (`lib/jmap/client.ts:1376-1431`), `unsupportedSort` fallback (`:1490-1500`); thread grouping mirrors the order (`lib/thread-utils.ts:82`). - 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. -- [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 +- [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 3194b5e - 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. -- [x] **Drafts do not open in the composer / no "Edit draft"** — `P2` — `missing` — fixed in PENDING-B +- [x] **Drafts do not open in the composer / no "Edit draft"** — `P2` — `missing` — fixed in 3194b5e - 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). @@ -179,22 +179,22 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou - 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". -- [x] **Swipe "spam" available in Sent/Drafts and no "not spam" in Junk** — `P3` — `rn-only-bug` — fixed in PENDING-B +- [x] **Swipe "spam" available in Sent/Drafts and no "not spam" in Junk** — `P3` — `rn-only-bug` — fixed in 3194b5e - 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". -- [x] **Spam / not-spam do not flip `$junk`/`$notjunk` (#850) nor honour "trash-and-read"** — `P2` — `bugfix-parity` — fixed in PENDING-B +- [x] **Spam / not-spam do not flip `$junk`/`$notjunk` (#850) nor honour "trash-and-read"** — `P2` — `bugfix-parity` — fixed in 3194b5e - 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. -- [x] **Batch spam / batch not-spam** — `P3` — `missing` — fixed in PENDING-B +- [x] **Batch spam / batch not-spam** — `P3` — `missing` — fixed in 3194b5e - 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'`. -- [x] **Selecting a collapsed thread row selects/acts on only its newest message** — `P2` — `bug` — fixed in PENDING-B +- [x] **Selecting a collapsed thread row selects/acts on only its newest message** — `P2` — `bug` — fixed in 3194b5e - 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`). @@ -209,7 +209,7 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou - 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. -- [x] **Search runs on every keystroke** — `P3` — `rn-only-bug` — fixed in PENDING-B +- [x] **Search runs on every keystroke** — `P3` — `rn-only-bug` — fixed in 3194b5e - 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. @@ -219,7 +219,7 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou - What RN does: `formatListDate` follows the app locale only (`src/lib/date-format.ts:17-31`), "Just now"/"m ago" are English (`:38-41`). - Fix hint: localize the relative strings via `t()`; add a `dateLocale` select if parity is wanted. -- [ ] **Show avatars in Junk (off by default, 1.5.1); avatar priority** — `P3` — `partial` +- [x] **Show avatars in Junk (off by default, 1.5.1); avatar priority** — `P3` — `partial` — fixed in PENDING-C - What WEB does: `showAvatarsInJunk` hides favicon/photo images in Junk (`components/email/thread-list-item.tsx:137`); avatar priority contact photo > plugin (Gravatar) > favicon > initials (`components/ui/avatar.tsx:236`). - What RN does: favicons always load, including in Junk (`src/components/SenderAvatar.tsx:37`); no contact-photo lookup. - Fix hint: pass `disableImages={currentMailbox.role === 'junk' && !showAvatarsInJunk}` into `SenderAvatar`; contact photos optional. @@ -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`). -- [x] **Undo snackbar / toast strings not localized; move toast lacks folder path** — `P3` — `partial` — fixed in PENDING-B +- [x] **Undo snackbar / toast strings not localized; move toast lacks folder path** — `P3` — `partial` — fixed in 3194b5e - 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 -- [x] **Search folder scope: always the current folder; no all-folders default (#788) and no folder picker** — `P2` — `bugfix-parity` — fixed in PENDING-B +- [x] **Search folder scope: always the current folder; no all-folders default (#788) and no folder picker** — `P2` — `bugfix-parity` — fixed in 3194b5e - 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`. -- [x] **Body filter, per-chip removal** — `P3` — `partial` — fixed in PENDING-B +- [x] **Body filter, per-chip removal** — `P3` — `partial` — fixed in 3194b5e - 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)`. -- [x] **Search suggestions and recent-search history (#845)** — `P3` — `missing` — fixed in PENDING-B +- [x] **Search suggestions and recent-search history (#845)** — `P3` — `missing` — fixed in 3194b5e - 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/settings/LayoutSettings.tsx b/src/components/settings/LayoutSettings.tsx index 463f1dd8..a5c6fcef 100644 --- a/src/components/settings/LayoutSettings.tsx +++ b/src/components/settings/LayoutSettings.tsx @@ -1,9 +1,10 @@ import React, { useEffect } from 'react'; import { View, Text, StyleSheet } from 'react-native'; -import { ArrowRight, ArrowLeft, MoveVertical } from 'lucide-react-native'; -import { SettingsSection, RadioGroup, Select } from './settings-section'; +import { ArrowRight, ArrowLeft, MoveVertical, ArrowDownWideNarrow } from 'lucide-react-native'; +import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup, Select } from './settings-section'; import { spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; +import { useLocaleStore } from '../../stores/locale-store'; import { useSettingsStore, normalizeBottomQuickActions, @@ -13,41 +14,51 @@ import { type SwipeMode, type QuickAction, } from '../../stores/settings-store'; +import { useEmailStore } from '../../stores/email-store'; +import { useKeywordsStore } from '../../stores/keywords-store'; +import { + ORDER_PRESETS, detectPreset, presetLevels, sanitizeSortLevels, + type OrderPreset, type MessageListOrderScope, +} from '../../lib/message-list-order'; -const SWIPE_OPTIONS: { value: SwipeAction; label: string }[] = [ - { value: 'none', label: 'None' }, - { value: 'archive', label: 'Archive' }, - { value: 'delete', label: 'Delete' }, - { value: 'spam', label: 'Spam' }, - { value: 'read', label: 'Read/Unread' }, - { value: 'star', label: 'Star' }, - { value: 'pin', label: 'Pin' }, - { value: 'move', label: 'Move to folder' }, +type Translate = (key: string, fallback?: string) => string; + +const swipeOptions = (t: Translate): { value: SwipeAction; label: string }[] => [ + { value: 'none', label: t('settings.email_behavior.swipe_actions.none', 'None') }, + { value: 'archive', label: t('settings.email_behavior.swipe_actions.archive', 'Archive') }, + { value: 'delete', label: t('settings.email_behavior.swipe_actions.delete', 'Delete') }, + { value: 'spam', label: t('settings.email_behavior.swipe_actions.spam', 'Mark as spam') }, + { value: 'read', label: t('settings.email_behavior.swipe_actions.mark_read', 'Toggle read') }, + { value: 'star', label: t('settings.email_behavior.swipe_actions.star', 'Toggle star') }, + { value: 'pin', label: t('settings.email_behavior.swipe_actions.pin', 'Pin') }, + { value: 'move', label: t('settings.email_behavior.swipe_actions.move', 'Move to folder') }, ]; -const SWIPE_MODE_OPTIONS: { value: SwipeMode; label: string }[] = [ - { value: 'instant', label: 'Instant (swipe = action)' }, - { value: 'reveal', label: 'Reveal (swipe, then tap)' }, +const swipeModeOptions = (t: Translate): { value: SwipeMode; label: string }[] => [ + { value: 'instant', label: t('settings.layout.swipe_mode.instant', 'Instant (swipe = action)') }, + { value: 'reveal', label: t('settings.layout.swipe_mode.reveal', 'Reveal (swipe, then tap)') }, ]; -const QUICK_ACTION_LABELS: Record = { - reply: 'Reply', - replyAll: 'Reply All', - forward: 'Forward', - delete: 'Delete', - archive: 'Archive', - markUnread: 'Mark Read/Unread', - star: 'Star', - move: 'Move to folder', - spam: 'Spam', - tag: 'Tag', +const quickActionLabels = (t: Translate): Record => ({ + reply: t('email_viewer.reply', 'Reply'), + replyAll: t('email_viewer.reply_all', 'Reply All'), + forward: t('email_viewer.forward', 'Forward'), + delete: t('email_viewer.delete', 'Delete'), + archive: t('email_viewer.archive', 'Archive'), + markUnread: t('settings.layout.quick_actions.mark_unread', 'Mark Read/Unread'), + star: t('settings.layout.quick_actions.star', 'Star'), + move: t('settings.email_behavior.swipe_actions.move', 'Move to folder'), + spam: t('settings.layout.quick_actions.spam', 'Spam'), + tag: t('settings.layout.quick_actions.tag', 'Tag'), +}); + +const PRESET_FALLBACKS: Partial> = { + chronological: 'Newest first', + unread_first: 'Unread first', + starred_first: 'Starred first', + tagged_first: 'Tagged first', }; -const QUICK_ACTION_OPTIONS = ALL_QUICK_ACTIONS.map((value) => ({ - value, - label: QUICK_ACTION_LABELS[value], -})); - export function LayoutSettings() { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); @@ -61,9 +72,51 @@ export function LayoutSettings() { const update = useSettingsStore((s) => s.updateSetting); const hydrated = useSettingsStore((s) => s.hydrated); const hydrate = useSettingsStore((s) => s.hydrate); + const t = useLocaleStore((s) => s.t); + const SWIPE_OPTIONS = React.useMemo(() => swipeOptions(t), [t]); + const SWIPE_MODE_OPTIONS = React.useMemo(() => swipeModeOptions(t), [t]); + const QUICK_ACTION_LABELS = React.useMemo(() => quickActionLabels(t), [t]); + const QUICK_ACTION_OPTIONS = React.useMemo( + () => ALL_QUICK_ACTIONS.map((value) => ({ value, label: QUICK_ACTION_LABELS[value] })), + [QUICK_ACTION_LABELS], + ); useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]); + // Message-list order presets (#718). Any change drops the cached query + // windows so the list is re-queried under the new order. + const messageListOrderRaw = useSettingsStore((s) => s.messageListOrder); + const messageListOrderScope = useSettingsStore((s) => s.messageListOrderScope); + const showAvatarsInJunk = useSettingsStore((s) => s.showAvatarsInJunk); + const showFolderTotalCount = useSettingsStore((s) => s.showFolderTotalCount); + const unifiedCrossAccount = useSettingsStore((s) => s.unifiedCrossAccount); + const keywordDefs = useKeywordsStore((s) => s.keywords); + const invalidateListOrder = useEmailStore((s) => s.invalidateListOrder); + const order = React.useMemo(() => sanitizeSortLevels(messageListOrderRaw), [messageListOrderRaw]); + const preset = detectPreset(order); + const orderTagId = order.find((l) => l.criterion === 'tag')?.tagId ?? keywordDefs[0]?.id; + const setPreset = (next: OrderPreset) => { + if (next === 'custom') return; + update('messageListOrder', presetLevels(next, orderTagId)); + invalidateListOrder(); + }; + const setOrderTag = (tagId: string) => { + update('messageListOrder', presetLevels('tagged_first', tagId)); + invalidateListOrder(); + }; + const setScope = (scope: MessageListOrderScope) => { + update('messageListOrderScope', scope); + invalidateListOrder(); + }; + const PRESET_OPTIONS = ORDER_PRESETS.map((value) => ({ + value, + label: t(`settings.message_list_order.preset_${value}`, PRESET_FALLBACKS[value] ?? value), + })); + const SCOPE_OPTIONS: { value: MessageListOrderScope; label: string }[] = [ + { value: 'inbox', label: t('settings.message_list_order.scope_inbox', 'Inbox only') }, + { value: 'all', label: t('settings.message_list_order.scope_all', 'All folders') }, + ]; + const bottomActions = normalizeBottomQuickActions(bottomQuickActionsRaw); // Pick an action for a bar slot. If the chosen action already occupies // another slot, swap the two so the three slots stay unique. @@ -79,8 +132,63 @@ export function LayoutSettings() { const relocated = REPLY_QUICK_ACTIONS.filter((a) => !bottomActions.includes(a)); return ( - + + + + {t('settings.message_list_order.title', 'Message list order')} + + + {t('settings.message_list_order.description', 'Which messages come first in the list. Newest first is the tie-breaker within each group; the header toggle still flips oldest/newest.')} + + setPreset(v as OrderPreset)} + options={PRESET_OPTIONS} + /> + {preset === 'tagged_first' && keywordDefs.length > 0 && ( + + {t('settings.message_list_order.tag', 'Tag')} + setQuickActionSlot(index, v as QuickAction)} @@ -144,9 +251,11 @@ export function LayoutSettings() { ))} {relocated.length > 0 && ( - {relocated.map((a) => QUICK_ACTION_LABELS[a]).join(', ')} - {relocated.length === 1 ? ' is' : ' are'} moved to the top toolbar so - you can still use {relocated.length === 1 ? 'it' : 'them'}. + {t( + 'settings.layout.quick_actions.relocated', + '{count, plural, one {{actions} is moved to the top toolbar so you can still use it.} other {{actions} are moved to the top toolbar so you can still use them.}}', + { count: relocated.length, actions: relocated.map((a) => QUICK_ACTION_LABELS[a]).join(', ') }, + )} )} diff --git a/src/lib/__tests__/keyword-sort-polarity.test.ts b/src/lib/__tests__/keyword-sort-polarity.test.ts new file mode 100644 index 00000000..d99a61de --- /dev/null +++ b/src/lib/__tests__/keyword-sort-polarity.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const request = vi.fn(); +vi.mock('../../api/jmap-client', () => ({ + jmapClient: { + accountId: 'acc-1', + serverUrl: 'https://mail.example.com', + currentSession: { accounts: {} }, + request: (...args: unknown[]) => request(...args), + }, +})); + +import { + buildListSort, + keywordSortSupported, + resetKeywordSortState, + resolveKeywordSortPolarity, +} from '../keyword-sort-polarity'; + +function probeResponse(ascId: string, descId: string, seen: boolean) { + return { + methodResponses: [ + ['Email/query', { ids: [ascId] }, 'asc'], + ['Email/query', { ids: [descId] }, 'desc'], + ['Email/get', { list: [{ id: ascId, keywords: seen ? { $seen: true } : {} }] }, 'get'], + ], + }; +} + +beforeEach(() => { + request.mockReset(); + resetKeywordSortState(); +}); + +describe('keyword sort polarity probe', () => { + it('detects an inverting server (Stalwart) and caches the verdict per account', async () => { + request.mockResolvedValue(probeResponse('a', 'b', true)); + expect(await resolveKeywordSortPolarity('acc-1')).toBe('inverted'); + expect(await resolveKeywordSortPolarity('acc-1')).toBe('inverted'); + expect(request).toHaveBeenCalledTimes(1); + }); + + it('reads an RFC server and shares one in-flight probe', async () => { + request.mockResolvedValue(probeResponse('a', 'b', false)); + const [p1, p2] = await Promise.all([resolveKeywordSortPolarity('acc-1'), resolveKeywordSortPolarity('acc-1')]); + expect(p1).toBe('rfc'); + expect(p2).toBe('rfc'); + expect(request).toHaveBeenCalledTimes(1); + }); + + it('falls back to rfc without caching when the mailbox is homogeneous or the probe fails', async () => { + request.mockResolvedValueOnce(probeResponse('a', 'a', true)); + expect(await resolveKeywordSortPolarity('acc-1')).toBe('rfc'); + request.mockRejectedValueOnce(new Error('boom')); + resetKeywordSortState(); + expect(await resolveKeywordSortPolarity('acc-1')).toBe('rfc'); + }); + + it('remembers unsupportedSort and drops keyword comparators from the list sort', async () => { + request.mockResolvedValue({ methodResponses: [['error', { type: 'unsupportedSort' }, 'asc']] }); + await resolveKeywordSortPolarity('acc-1'); + expect(keywordSortSupported('acc-1')).toBe(false); + const sort = await buildListSort('acc-1', [{ criterion: 'unread', direction: 'desc' }]); + expect(sort).toEqual([{ property: 'receivedAt', isAscending: false }]); + }); +}); + +describe('buildListSort', () => { + it('puts $pinned first with the probed polarity and honours the oldest-first toggle', async () => { + request.mockResolvedValue(probeResponse('a', 'b', true)); + const sort = await buildListSort('acc-1', [{ criterion: 'unread', direction: 'desc' }], { dateAscending: true }); + expect(sort).toEqual([ + { property: 'hasKeyword', keyword: '$pinned', isAscending: true }, + { property: 'hasKeyword', keyword: '$seen', isAscending: false }, + { property: 'receivedAt', isAscending: true }, + ]); + }); + + it('skips the probe and the pinned comparator when nothing keyword-based is wanted', async () => { + const sort = await buildListSort('acc-1', [], { pinnedFirst: false }); + expect(sort).toEqual([{ property: 'receivedAt', isAscending: false }]); + expect(request).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/keyword-sort-polarity.ts b/src/lib/keyword-sort-polarity.ts new file mode 100644 index 00000000..b541abbf --- /dev/null +++ b/src/lib/keyword-sort-polarity.ts @@ -0,0 +1,149 @@ +import { jmapClient } from '../api/jmap-client'; +import { CAPABILITIES } from '../api/types'; +import type { Email } from '../api/types'; +import { + buildEmailSort, + hasKeywordLevels, + type JMAPEmailComparator, + type KeywordSortPolarity, + type SortLevel, +} from './message-list-order'; + +// Port of the webmail's JMAPClient.resolveKeywordSortPolarity / +// probeKeywordSortPolarity / buildListSort (lib/jmap/client.ts). Stalwart +// inverts `isAscending` on hasKeyword comparators (#718), so the server is +// probed once per account and session and the verdict feeds buildEmailSort. + +const KEYWORD_SORT_PROBE_RETRY_MS = 5 * 60 * 1000; + +// Keyed by `|` so two logged-in accounts on the +// same server, or the same id on two servers, never share a verdict. +const polarityCache = new Map(); +const probes = new Map>(); +// Accounts whose server refused a hasKeyword comparator with unsupportedSort. +const unsupported = new Set(); + +function cacheKey(accountId: string): string { + return `${jmapClient.serverUrl ?? ''}|${accountId}`; +} + +/** The server's advertised Email/query sort options, or null when unknown. */ +function emailQuerySortOptions(accountId: string): string[] | null { + const caps = jmapClient.currentSession?.accounts?.[accountId]?.accountCapabilities as + | Record | undefined; + const options = caps?.[CAPABILITIES.MAIL]?.emailQuerySortOptions; + return Array.isArray(options) ? options.filter((o): o is string => typeof o === 'string') : null; +} + +/** + * Whether keyword comparators can be sent to this account's server: not when + * it advertises a sort-option list without them, and not after it refused one + * with `unsupportedSort`. Unknown counts as yes. + */ +export function keywordSortSupported(accountId: string): boolean { + if (unsupported.has(cacheKey(accountId))) return false; + const options = emailQuerySortOptions(accountId); + return options === null || options.includes('hasKeyword'); +} + +/** Remember that the server answered a keyword comparator with unsupportedSort. */ +export function markKeywordSortUnsupported(accountId: string): void { + unsupported.add(cacheKey(accountId)); +} + +/** Test hook. */ +export function resetKeywordSortState(): void { + polarityCache.clear(); + probes.clear(); + unsupported.clear(); +} + +async function probeKeywordSortPolarity(accountId: string): Promise { + const sortFor = (isAscending: boolean) => [ + { property: 'hasKeyword', keyword: '$seen', isAscending }, + { property: 'receivedAt', isAscending: false }, + ]; + const response = await jmapClient.request([ + ['Email/query', { accountId, sort: sortFor(true), limit: 1 }, 'asc'], + ['Email/query', { accountId, sort: sortFor(false), limit: 1 }, 'desc'], + ['Email/get', { + accountId, + '#ids': { resultOf: 'asc', name: 'Email/query', path: '/ids' }, + properties: ['id', 'keywords'], + }, 'get'], + ]); + const [ascMethod, ascResult] = response.methodResponses?.[0] ?? []; + if (ascMethod === 'error') { + if ((ascResult as { type?: string })?.type === 'unsupportedSort') { + markKeywordSortUnsupported(accountId); + return 'rfc'; + } + throw new Error((ascResult as { description?: string })?.description || 'Email/query failed'); + } + const ascIds = ((ascResult as { ids?: string[] })?.ids) ?? []; + const descIds = ((response.methodResponses?.[1]?.[1] as { ids?: string[] })?.ids) ?? []; + // Same message both ways: the mailbox is homogeneous, probe inconclusive. + if (ascIds.length === 0 || descIds.length === 0 || ascIds[0] === descIds[0]) return null; + const first = ((response.methodResponses?.[2]?.[1] as { list?: Email[] })?.list ?? [])[0]; + if (!first || first.id !== ascIds[0]) return null; + // Ascending put a $seen message first: the server sorts has-keyword first + // on isAscending: true, the inverse of the RFC's false < true reading. + return first.keywords?.$seen ? 'inverted' : 'rfc'; +} + +/** + * Which way the server reads `isAscending` on hasKeyword comparators, probed + * once per account (an inconclusive probe is retried after a few minutes; a + * failed one falls back to the RFC reading without caching a verdict). + */ +export function resolveKeywordSortPolarity(accountId: string): Promise { + const key = cacheKey(accountId); + const cached = polarityCache.get(key); + if (cached && (cached.value !== null || Date.now() - cached.at < KEYWORD_SORT_PROBE_RETRY_MS)) { + return Promise.resolve(cached.value ?? 'rfc'); + } + const pending = probes.get(key); + if (pending) return pending; + const probe = probeKeywordSortPolarity(accountId) + .then((value) => { + polarityCache.set(key, { value, at: Date.now() }); + return value ?? 'rfc'; + }) + .catch((error) => { + console.warn('[keyword-sort] polarity probe failed:', error); + polarityCache.set(key, { value: null, at: Date.now() }); + return 'rfc' as const; + }) + .finally(() => { + probes.delete(key); + }); + probes.set(key, probe); + return probe; +} + +/** + * The Email/query sort for a folder view with the server's polarity applied + * (webmail `buildListSort`). `pinnedFirst` prepends the $pinned comparator; + * `dateAscending` flips the trailing receivedAt tie-breaker (RN's + * oldest-first toggle, native #5) when the order has no explicit date level. + */ +export async function buildListSort( + accountId: string, + order: SortLevel[], + opts: { pinnedFirst?: boolean; dateAscending?: boolean } = {}, +): Promise { + const pinnedFirst = opts.pinnedFirst ?? true; + let sort: JMAPEmailComparator[]; + if (!hasKeywordLevels(order, pinnedFirst)) { + sort = buildEmailSort(order); + } else if (!keywordSortSupported(accountId)) { + sort = buildEmailSort(order, { pinnedFirst, keywordSortSupported: false }); + } else { + const polarity = await resolveKeywordSortPolarity(accountId); + sort = buildEmailSort(order, { pinnedFirst, polarity, keywordSortSupported: keywordSortSupported(accountId) }); + } + if (opts.dateAscending && !order.some((l) => l.criterion === 'receivedAt')) { + sort = sort.map((cmp) => (cmp.property === 'receivedAt' ? { ...cmp, isAscending: true } : cmp)); + } + return sort; +} diff --git a/src/stores/__tests__/email-store.test.ts b/src/stores/__tests__/email-store.test.ts index 722c5383..2763bec0 100644 --- a/src/stores/__tests__/email-store.test.ts +++ b/src/stores/__tests__/email-store.test.ts @@ -128,6 +128,10 @@ vi.mock('../../api/jmap-client', () => ({ getMaxObjectsInGet: () => 500, getMaxCallsInRequest: () => 16, getSharedMailAccounts: () => [{ id: 'grp-1', name: 'Support' }], + // The keyword-sort polarity probe runs through this; answering + // unsupportedSort keeps the sort at the plain receivedAt comparator the + // assertions below expect. + request: vi.fn(async () => ({ methodResponses: [['error', { type: 'unsupportedSort' }, 'asc']] })), }, })); @@ -716,6 +720,8 @@ describe('email-store', () => { const third = useEmailStore.getState().refreshEmails(); expect(second).toBe(first); expect(third).toBe(first); + // The sort is resolved asynchronously before Email/query runs. + await new Promise((r) => setTimeout(r, 0)); resolveQuery({ ids: [], total: 0, queryState: 'q' }); await first; await new Promise((r) => setTimeout(r, 0)); diff --git a/src/stores/email-store.ts b/src/stores/email-store.ts index 6de81d6b..7e9db7a1 100644 --- a/src/stores/email-store.ts +++ b/src/stores/email-store.ts @@ -29,8 +29,11 @@ import { undoSpam as apiUndoSpam, unprefixMailboxId, } from '../api/email'; +import { JMAPMethodError } from '../api/jmap-result'; import { mailboxesForSiblingOf, findJunkMailbox } from '../lib/mailbox-tree'; import { toWildcardQuery } from '../lib/search-utils'; +import { orderForMailbox, sanitizeSortLevels, type SortLevel } from '../lib/message-list-order'; +import { buildListSort, markKeywordSortUnsupported } from '../lib/keyword-sort-polarity'; import { generateAccountId } from '../lib/account-utils'; import { t } from './locale-store'; import { useSettingsStore } from './settings-store'; @@ -399,10 +402,32 @@ function isBaseView(searchQuery: string, filters: EmailFilters): boolean { return !searchQuery.trim() && activeFilterKeys(filters).length === 0; } -// The Email/query sort for the current settings. Extended by the message-list -// order presets; today it's the receivedAt direction only. -function currentSort(): Array<{ property: string; isAscending: boolean; keyword?: string }> { - return [{ property: 'receivedAt', isAscending: useSettingsStore.getState().mailSortAscending }]; +// The Email/query sort for the folder on screen: the configured order presets +// / levels (#718, Inbox-only or every folder) with the server's keyword +// comparator polarity applied, `$pinned` first, and RN's oldest-first toggle +// on the trailing date comparator. +type EmailSort = Array<{ property: string; isAscending: boolean; keyword?: string }>; +function orderFor(state: EmailState): SortLevel[] { + const { messageListOrder, messageListOrderScope } = useSettingsStore.getState(); + const role = state.currentMailboxId + ? state.mailboxes.find((m) => m.id === state.currentMailboxId)?.role + : undefined; + return orderForMailbox(sanitizeSortLevels(messageListOrder), messageListOrderScope, role); +} +function resolveSort(state: EmailState, accountId: string | undefined): Promise { + return buildListSort(accountId ?? jmapClient.accountId, orderFor(state), { + pinnedFirst: true, + dateAscending: useSettingsStore.getState().mailSortAscending, + }); +} +// A stable fingerprint of everything that influences the sort, so a response +// built under a previous order is dropped instead of overwriting the view. +function orderFingerprint(): string { + const s = useSettingsStore.getState(); + return JSON.stringify([s.mailSortAscending, s.messageListOrderScope, s.messageListOrder]); +} +function isUnsupportedSort(err: unknown): boolean { + return err instanceof JMAPMethodError && err.type === 'unsupportedSort'; } // Splice rows the user just read/unstarred back into a freshly re-queried @@ -743,7 +768,7 @@ export const useEmailStore = create()( const { ids } = await queryEmails(scope.mailboxId, { position: emails.length, limit, - sort: currentSort(), + sort: await resolveSort(state, scope.accountId), filter, accountId: scope.accountId, }); @@ -1825,7 +1850,8 @@ async function refreshEmailsImpl(): Promise { const filter = buildJmapFilter(searchQuery, filters); const { emailsPerPage: limit, mailSortAscending: sortAscending } = useSettingsStore.getState(); - const sort = currentSort(); + const orderKey = orderFingerprint(); + let sort = await resolveSort(state, scope.accountId); const baseView = isBaseView(searchQuery, filters); // A response that lands after the user switched account/mailbox or @@ -1835,7 +1861,8 @@ async function refreshEmailsImpl(): Promise { get().currentMailboxId !== currentMailboxId || get().searchQuery !== searchQuery || get().filters !== filters || - useSettingsStore.getState().mailSortAscending !== sortAscending; + orderFingerprint() !== orderKey; + if (viewChanged()) return; // The incremental path diffs against the *base-view* list, which lives in // the per-mailbox snapshot — NOT `emails`, which may still hold search or @@ -1969,7 +1996,17 @@ async function refreshEmailsImpl(): Promise { // Full re-query path. Used when there's no prior queryState, when the // user has search/filters active (queryState only tracks the base // query), or when the server returned cannotCalculateChanges above. - const queryRes = await queryEmails(scope.mailboxId, { limit, sort, filter, accountId: scope.accountId }); + let queryRes: Awaited>; + try { + queryRes = await queryEmails(scope.mailboxId, { limit, sort, filter, accountId: scope.accountId }); + } catch (err) { + // The server refused a hasKeyword comparator (unsupportedSort): drop + // the keyword levels for this account and re-run with the rest. + if (!isUnsupportedSort(err)) throw err; + markKeywordSortUnsupported(scope.accountId ?? jmapClient.accountId); + sort = await resolveSort(state, scope.accountId); + queryRes = await queryEmails(scope.mailboxId, { limit, sort, filter, accountId: scope.accountId }); + } const fetched = queryRes.ids.length > 0 ? await getEmailsWithState(queryRes.ids, scope.accountId) : { list: [], state: undefined as string | undefined };