From 1cbe28b18c00808e2bd04476e3d3ad28f6ab0a53 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:24:00 +0100 Subject: [PATCH] feat: open the sort orders from the list header's sort button --- src/lib/__tests__/sort-choices.test.ts | 31 ++++++++++++ src/lib/sort-choices.ts | 58 ++++++++++++++++++++++ src/screens/EmailListScreen.tsx | 69 ++++++++++++++++++++++---- 3 files changed, 149 insertions(+), 9 deletions(-) create mode 100644 src/lib/__tests__/sort-choices.test.ts create mode 100644 src/lib/sort-choices.ts diff --git a/src/lib/__tests__/sort-choices.test.ts b/src/lib/__tests__/sort-choices.test.ts new file mode 100644 index 00000000..67ca83e7 --- /dev/null +++ b/src/lib/__tests__/sort-choices.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { sortChoiceChanges, sortChoiceFor } from '../sort-choices'; + +// The list header only flipped newest/oldest; the order presets were three +// screens away in Settings > Layout (webmail e3f1ccce7 has a Sort menu). + +describe('list header sort menu', () => { + it('names the order the open folder uses', () => { + expect(sortChoiceFor([], false)).toBe('newest'); + expect(sortChoiceFor([], true)).toBe('oldest'); + expect(sortChoiceFor([{ criterion: 'unread', direction: 'desc' }], false)).toBe('unread'); + expect(sortChoiceFor([{ criterion: 'from', direction: 'asc' }, { criterion: 'receivedAt', direction: 'desc' }], false)).toBe('sender'); + expect(sortChoiceFor([{ criterion: 'unread', direction: 'desc' }, { criterion: 'starred', direction: 'desc' }], false)).toBeNull(); + }); + + it('writes the order setting, and widens an Inbox-only order when picked elsewhere', () => { + expect(sortChoiceChanges('largest', 'inbox', 'inbox')).toEqual({ + messageListOrder: [{ criterion: 'size', direction: 'desc' }], mailSortAscending: false, + }); + expect(sortChoiceChanges('starred', 'inbox', 'archive')?.messageListOrderScope).toBe('all'); + expect(sortChoiceChanges('oldest', 'inbox', 'archive')).toEqual({ messageListOrder: [], mailSortAscending: true }); + }); + + it('opens from the header button', () => { + const list = fs.readFileSync(path.resolve(__dirname, '../../screens/EmailListScreen.tsx'), 'utf8'); + expect(list).toMatch(/onPress=\{\(\) => setSortMenuOpen\(true\)\}/); + expect(list).toMatch(/sortChoiceChanges\(choice\.key, messageListOrderScope, currentRole\)/); + }); +}); diff --git a/src/lib/sort-choices.ts b/src/lib/sort-choices.ts new file mode 100644 index 00000000..cfec0941 --- /dev/null +++ b/src/lib/sort-choices.ts @@ -0,0 +1,58 @@ +import { isDefaultOrder, sanitizeSortLevels, type SortLevel } from './message-list-order'; + +// The Sort menu of the list header (webmail components/email/mail-list-menus.tsx, +// e3f1ccce7). It writes the "Message list order" setting, so Settings agrees. +// The app keeps oldest/newest in its own `mailSortAscending` switch (the +// header button flipped it), so Newest and Oldest set that and clear the order. + +export type SortChoiceKey = 'newest' | 'oldest' | 'unread' | 'starred' | 'sender' | 'subject' | 'largest'; + +export const SORT_CHOICES: ReadonlyArray<{ key: SortChoiceKey; levels: SortLevel[]; ascending: boolean }> = [ + { key: 'newest', levels: [], ascending: false }, + { key: 'oldest', levels: [], ascending: true }, + { key: 'unread', levels: [{ criterion: 'unread', direction: 'desc' }], ascending: false }, + { key: 'starred', levels: [{ criterion: 'starred', direction: 'desc' }], ascending: false }, + { key: 'sender', levels: [{ criterion: 'from', direction: 'asc' }], ascending: false }, + { key: 'subject', levels: [{ criterion: 'subject', direction: 'asc' }], ascending: false }, + { key: 'largest', levels: [{ criterion: 'size', direction: 'desc' }], ascending: false }, +]; + +/** The choice the open folder's order is, or null for an order only Settings makes. */ +export function sortChoiceFor(levels: SortLevel[], ascending: boolean): SortChoiceKey | null { + const last = levels[levels.length - 1]; + const core = levels.length > 1 && last.criterion === 'receivedAt' && last.direction === 'desc' + ? levels.slice(0, -1) + : levels; + if (isDefaultOrder(core)) return ascending ? 'oldest' : 'newest'; + if (core.length !== 1 || ascending) return null; + const match = SORT_CHOICES.find((choice) => + choice.levels.length === 1 + && choice.levels[0].criterion === core[0].criterion + && choice.levels[0].direction === core[0].direction); + return match?.key ?? null; +} + +export interface SortChoiceChanges { + messageListOrder: SortLevel[]; + mailSortAscending: boolean; + /** Set when the order only applied to the Inbox and was picked in another folder. */ + messageListOrderScope?: 'all'; +} + +/** + * The settings a pick writes. Picked outside the Inbox while the order applies + * to the Inbox only, it applies to all folders, or the pick would do nothing. + */ +export function sortChoiceChanges( + key: SortChoiceKey, + scope: 'inbox' | 'all', + folderRole: string | null | undefined, +): SortChoiceChanges | null { + const choice = SORT_CHOICES.find((c) => c.key === key); + if (!choice) return null; + return { + messageListOrder: sanitizeSortLevels(choice.levels.map((level) => ({ ...level }))), + mailSortAscending: choice.ascending, + ...(scope === 'inbox' && folderRole !== 'inbox' && choice.levels.length > 0 ? { messageListOrderScope: 'all' as const } : {}), + }; +} diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index 9bdce254..3c94f29c 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -7,8 +7,11 @@ import { Search, SquarePen, Menu, Filter, Square, SquareCheck, Minus, X, Star, Paperclip, Mail as MailIcon, MailOpen, Trash2, RotateCcw, CalendarDays, Archive, FolderInput, Tag, Import, ArrowDownWideNarrow, ArrowUpNarrowWide, - Pin, Reply, Forward, ShieldAlert, ShieldCheck, Folder, + Pin, Reply, Forward, ShieldAlert, ShieldCheck, Folder, ArrowUpDown, Check, } from '../components/icons'; +import { ActionSheet } from '../components/email/ActionSheet'; +import { SORT_CHOICES, sortChoiceChanges, sortChoiceFor } from '../lib/sort-choices'; +import { orderForMailbox, sanitizeSortLevels } from '../lib/message-list-order'; import { useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { spacing, radius, typography, componentSizes, mix, type ThemePalette } from '../theme/tokens'; @@ -103,6 +106,16 @@ function isPinned(email: Email): boolean { return !!email.keywords?.$pinned; } +const SORT_FALLBACKS: Record = { + newest: 'Newest first', + oldest: 'Oldest first', + unread: 'Unread first', + starred: 'Starred first', + sender: 'Sender (A–Z)', + subject: 'Subject (A–Z)', + largest: 'Largest first', +}; + // Height of the sender line when the avatar is hidden (extra-compact density), // used to anchor the unread dot on that first line. const UNREAD_DOT_TEXT_LINE = 20; @@ -446,7 +459,7 @@ export default function EmailListScreen({ const moveEmailsToMailbox = useEmailStore((s) => s.moveEmailsToMailbox); const deleteEmailsBatch = useEmailStore((s) => s.deleteEmailsBatch); const setKeywordForEmails = useEmailStore((s) => s.setKeywordForEmails); - const setSortAscending = useEmailStore((s) => s.setSortAscending); + const invalidateListOrder = useEmailStore((s) => s.invalidateListOrder); const markSpam = useEmailStore((s) => s.markSpam); const unmarkSpam = useEmailStore((s) => s.unmarkSpam); const navigation = useNavigation>(); @@ -481,6 +494,15 @@ export default function EmailListScreen({ [mailboxes, currentMailboxId], ); const currentRole = currentMailbox?.role ?? null; + // The Sort menu of the header (webmail e3f1ccce7): the order presets of + // Settings > Layout, one tap away. + const [sortMenuOpen, setSortMenuOpen] = React.useState(false); + const messageListOrder = useSettingsStore((s) => s.messageListOrder); + const messageListOrderScope = useSettingsStore((s) => s.messageListOrderScope); + const currentSortChoice = sortChoiceFor( + orderForMailbox(sanitizeSortLevels(messageListOrder), messageListOrderScope, currentRole), + sortAscending, + ); // The JMAP account behind the open folder (undefined = the user's own). const currentOwnerAccountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined; @@ -1215,17 +1237,17 @@ export default function EmailListScreen({ setSortAscending(!sortAscending)} + onPress={() => setSortMenuOpen(true)} accessibilityRole="button" - accessibilityLabel={sortAscending - ? t('settings.appearance.message_list_order.direction.oldest_first', 'Oldest first') - : t('settings.appearance.message_list_order.direction.newest_first', 'Newest first')} - accessibilityHint={t('email_list.sort_toggle_hint', 'Reverses the mail sort order')} + accessibilityLabel={t('email_list.sort.title', 'Sort')} + accessibilityValue={currentSortChoice ? { text: t(`email_list.sort.${currentSortChoice}`, currentSortChoice) } : undefined} > - {sortAscending ? ( + {currentSortChoice === 'newest' ? ( + + ) : currentSortChoice === 'oldest' ? ( ) : ( - + )} + setSortMenuOpen(false)} + items={[ + ...SORT_CHOICES.map((choice) => ({ + key: choice.key, + label: t(`email_list.sort.${choice.key}`, SORT_FALLBACKS[choice.key]), + trailing: currentSortChoice === choice.key ? : undefined, + onPress: () => { + setSortMenuOpen(false); + const changes = sortChoiceChanges(choice.key, messageListOrderScope, currentRole); + if (!changes) return; + const { updateSetting } = useSettingsStore.getState(); + updateSetting('messageListOrder', changes.messageListOrder); + updateSetting('mailSortAscending', changes.mailSortAscending); + if (changes.messageListOrderScope) updateSetting('messageListOrderScope', changes.messageListOrderScope); + invalidateListOrder(); + }, + })), + ...(currentSortChoice === null ? [{ + key: 'custom', + label: t('email_list.sort.custom', 'Custom order (in Settings)'), + trailing: , + onPress: () => setSortMenuOpen(false), + }] : []), + ]} + /> + setBatchMoveOpen(false)}