feat: open the sort orders from the list header's sort button

This commit is contained in:
Linus Rath
2026-10-31 04:24:00 +01:00
parent da1da0ec6b
commit 1cbe28b18c
3 changed files with 149 additions and 9 deletions
+31
View File
@@ -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\)/);
});
});
+58
View File
@@ -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 } : {}),
};
}
+60 -9
View File
@@ -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<string, string> = {
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<NativeStackNavigationProp<RootStackParamList>>();
@@ -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({
</View>
<Pressable
style={styles.filterButton}
onPress={() => 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' ? (
<ArrowDownWideNarrow size={18} color={c.textMuted} />
) : currentSortChoice === 'oldest' ? (
<ArrowUpNarrowWide size={18} color={c.primary} />
) : (
<ArrowDownWideNarrow size={18} color={c.textMuted} />
<ArrowUpDown size={18} color={c.primary} />
)}
</Pressable>
<Pressable
@@ -1942,6 +1964,35 @@ export default function EmailListScreen({
}}
/>
<ActionSheet
visible={sortMenuOpen}
title={t('email_list.sort.title', 'Sort')}
onClose={() => 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 ? <Check size={16} color={c.primary} /> : 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: <Check size={16} color={c.primary} />,
onPress: () => setSortMenuOpen(false),
}] : []),
]}
/>
<MoveSheet
visible={batchMoveOpen}
onClose={() => setBatchMoveOpen(false)}