mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
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:
@@ -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`.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user