feat: message-list order presets with the Stalwart keyword-sort polarity probe, avatars-in-junk and folder-count toggles (#718, #498)

This commit is contained in:
Linus Rath
2026-08-29 18:28:43 +02:00
parent 5b72c4d314
commit bea3fc4c42
6 changed files with 454 additions and 69 deletions
+19 -19
View File
@@ -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:<id>' }`; 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`.
+151 -42
View File
@@ -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<QuickAction, string> = {
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<QuickAction, string> => ({
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<Record<OrderPreset, string>> = {
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 (
<SettingsSection title="Layout" description="Tune the email list interactions for mobile.">
<SettingsSection title={t('settings.tabs.layout', 'Layout')} description={t('settings.layout.description', 'Tune the email list interactions for mobile.')}>
<View style={{ gap: spacing.sm }}>
<View style={styles.row}>
<ArrowDownWideNarrow size={14} color={c.mutedForeground} />
<Text style={styles.rowLabel}>{t('settings.message_list_order.title', 'Message list order')}</Text>
</View>
<Text style={styles.rowDescription}>
{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.')}
</Text>
<RadioGroup
value={preset === 'custom' ? 'chronological' : preset}
onChange={(v) => setPreset(v as OrderPreset)}
options={PRESET_OPTIONS}
/>
{preset === 'tagged_first' && keywordDefs.length > 0 && (
<View style={styles.slotRow}>
<Text style={styles.slotLabel}>{t('settings.message_list_order.tag', 'Tag')}</Text>
<Select
value={orderTagId ?? ''}
onChange={setOrderTag}
options={keywordDefs.map((k) => ({ value: k.id, label: k.label }))}
/>
</View>
)}
<Text style={styles.rowDescription}>
{t('settings.message_list_order.scope', 'Apply to')}
</Text>
<RadioGroup
value={messageListOrderScope}
onChange={(v) => setScope(v as MessageListOrderScope)}
options={SCOPE_OPTIONS}
/>
</View>
<View style={{ marginTop: spacing.lg }}>
<SettingItem
label={t('settings.layout.show_folder_total_count', 'Show total message count on folders')}
description={t('settings.layout.show_folder_total_count_description', 'Show the total next to the unread count in the folder list.')}
>
<ToggleSwitch checked={showFolderTotalCount} onChange={(v) => update('showFolderTotalCount', v)} />
</SettingItem>
<SettingItem
label={t('settings.layout.show_avatars_in_junk', 'Show sender avatars in Junk')}
description={t('settings.layout.show_avatars_in_junk_description', 'Load sender logos for messages in the Junk folder. Off keeps spam senders from being contacted.')}
>
<ToggleSwitch checked={showAvatarsInJunk} onChange={(v) => update('showAvatarsInJunk', v)} />
</SettingItem>
<SettingItem
label={t('settings.layout.unified_cross_account', 'Unified views across accounts')}
description={t('settings.layout.unified_cross_account_description', 'Combine every signed-in account in "All inboxes". Off keeps the unified views inside the active account and its shared folders.')}
noBorder
>
<ToggleSwitch checked={unifiedCrossAccount} onChange={(v) => update('unifiedCrossAccount', v)} />
</SettingItem>
</View>
<View style={{ gap: spacing.sm, marginTop: spacing.lg }}>
<Text style={styles.rowLabel}>Swipe behavior</Text>
<Text style={styles.rowDescription}>
Pick instant (swipe past the threshold to fire the action) or reveal
@@ -96,10 +204,10 @@ export function LayoutSettings() {
<View style={{ gap: spacing.sm, marginTop: spacing.lg }}>
<View style={styles.row}>
<ArrowRight size={14} color={c.mutedForeground} />
<Text style={styles.rowLabel}>Swipe right (left → right)</Text>
<Text style={styles.rowLabel}>{t('settings.email_behavior.swipe_right_action.label_mobile', "Swipe right (left → right)")}</Text>
</View>
<Text style={styles.rowDescription}>
Action when you drag a row from its left edge towards the right.
{t('settings.email_behavior.swipe_right_action.description_mobile', "Action when you drag a row from its left edge towards the right.")}
</Text>
<RadioGroup
value={swipeRightAction}
@@ -111,10 +219,10 @@ export function LayoutSettings() {
<View style={{ gap: spacing.sm, marginTop: spacing.lg }}>
<View style={styles.row}>
<ArrowLeft size={14} color={c.mutedForeground} />
<Text style={styles.rowLabel}>Swipe left (right → left)</Text>
<Text style={styles.rowLabel}>{t('settings.email_behavior.swipe_left_action.label_mobile', "Swipe left (right → left)")}</Text>
</View>
<Text style={styles.rowDescription}>
Action when you drag a row from its right edge towards the left.
{t('settings.email_behavior.swipe_left_action.description_mobile', "Action when you drag a row from its right edge towards the left.")}
</Text>
<RadioGroup
value={swipeLeftAction}
@@ -126,15 +234,14 @@ export function LayoutSettings() {
<View style={{ gap: spacing.sm, marginTop: spacing.lg }}>
<View style={styles.row}>
<MoveVertical size={14} color={c.mutedForeground} />
<Text style={styles.rowLabel}>Reader quick actions</Text>
<Text style={styles.rowLabel}>{t('settings.layout.quick_actions.label', "Reader quick actions")}</Text>
</View>
<Text style={styles.rowDescription}>
The three buttons shown between Prev/Next at the bottom of an open
email. Defaults to Reply, Reply All and Forward.
{t('settings.layout.quick_actions.description', "The three buttons shown between Prev/Next at the bottom of an open email. Defaults to Reply, Reply All and Forward.")}
</Text>
{bottomActions.map((action, index) => (
<View key={index} style={styles.slotRow}>
<Text style={styles.slotLabel}>Slot {index + 1}</Text>
<Text style={styles.slotLabel}>{t('settings.layout.quick_actions.slot', 'Slot {n}', { n: index + 1 })}</Text>
<Select
value={action}
onChange={(v) => setQuickActionSlot(index, v as QuickAction)}
@@ -144,9 +251,11 @@ export function LayoutSettings() {
))}
{relocated.length > 0 && (
<Text style={styles.rowDescription}>
{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(', ') },
)}
</Text>
)}
</View>
@@ -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();
});
});
+149
View File
@@ -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 `<serverUrl>|<jmap account id>` so two logged-in accounts on the
// same server, or the same id on two servers, never share a verdict.
const polarityCache = new Map<string, { value: KeywordSortPolarity | null; at: number }>();
const probes = new Map<string, Promise<KeywordSortPolarity>>();
// Accounts whose server refused a hasKeyword comparator with unsupportedSort.
const unsupported = new Set<string>();
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<string, { emailQuerySortOptions?: unknown }> | 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<KeywordSortPolarity | null> {
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<KeywordSortPolarity> {
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<JMAPEmailComparator[]> {
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;
}
+6
View File
@@ -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));
+45 -8
View File
@@ -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<EmailSort> {
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<EmailState>()(
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<void> {
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<void> {
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<void> {
// 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<ReturnType<typeof queryEmails>>;
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 };