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, Search, SquarePen, Menu, Filter, Square, SquareCheck, Minus, X,
Star, Paperclip, Mail as MailIcon, MailOpen, Trash2, RotateCcw, CalendarDays, Star, Paperclip, Mail as MailIcon, MailOpen, Trash2, RotateCcw, CalendarDays,
Archive, FolderInput, Tag, Import, ArrowDownWideNarrow, ArrowUpNarrowWide, Archive, FolderInput, Tag, Import, ArrowDownWideNarrow, ArrowUpNarrowWide,
Pin, Reply, Forward, ShieldAlert, ShieldCheck, Folder, Pin, Reply, Forward, ShieldAlert, ShieldCheck, Folder, ArrowUpDown, Check,
} from '../components/icons'; } 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 { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { spacing, radius, typography, componentSizes, mix, type ThemePalette } from '../theme/tokens'; import { spacing, radius, typography, componentSizes, mix, type ThemePalette } from '../theme/tokens';
@@ -103,6 +106,16 @@ function isPinned(email: Email): boolean {
return !!email.keywords?.$pinned; 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), // Height of the sender line when the avatar is hidden (extra-compact density),
// used to anchor the unread dot on that first line. // used to anchor the unread dot on that first line.
const UNREAD_DOT_TEXT_LINE = 20; const UNREAD_DOT_TEXT_LINE = 20;
@@ -446,7 +459,7 @@ export default function EmailListScreen({
const moveEmailsToMailbox = useEmailStore((s) => s.moveEmailsToMailbox); const moveEmailsToMailbox = useEmailStore((s) => s.moveEmailsToMailbox);
const deleteEmailsBatch = useEmailStore((s) => s.deleteEmailsBatch); const deleteEmailsBatch = useEmailStore((s) => s.deleteEmailsBatch);
const setKeywordForEmails = useEmailStore((s) => s.setKeywordForEmails); 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 markSpam = useEmailStore((s) => s.markSpam);
const unmarkSpam = useEmailStore((s) => s.unmarkSpam); const unmarkSpam = useEmailStore((s) => s.unmarkSpam);
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>(); const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
@@ -481,6 +494,15 @@ export default function EmailListScreen({
[mailboxes, currentMailboxId], [mailboxes, currentMailboxId],
); );
const currentRole = currentMailbox?.role ?? null; 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). // The JMAP account behind the open folder (undefined = the user's own).
const currentOwnerAccountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined; const currentOwnerAccountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined;
@@ -1215,17 +1237,17 @@ export default function EmailListScreen({
</View> </View>
<Pressable <Pressable
style={styles.filterButton} style={styles.filterButton}
onPress={() => setSortAscending(!sortAscending)} onPress={() => setSortMenuOpen(true)}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={sortAscending accessibilityLabel={t('email_list.sort.title', 'Sort')}
? t('settings.appearance.message_list_order.direction.oldest_first', 'Oldest first') accessibilityValue={currentSortChoice ? { text: t(`email_list.sort.${currentSortChoice}`, currentSortChoice) } : undefined}
: t('settings.appearance.message_list_order.direction.newest_first', 'Newest first')}
accessibilityHint={t('email_list.sort_toggle_hint', 'Reverses the mail sort order')}
> >
{sortAscending ? ( {currentSortChoice === 'newest' ? (
<ArrowDownWideNarrow size={18} color={c.textMuted} />
) : currentSortChoice === 'oldest' ? (
<ArrowUpNarrowWide size={18} color={c.primary} /> <ArrowUpNarrowWide size={18} color={c.primary} />
) : ( ) : (
<ArrowDownWideNarrow size={18} color={c.textMuted} /> <ArrowUpDown size={18} color={c.primary} />
)} )}
</Pressable> </Pressable>
<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 <MoveSheet
visible={batchMoveOpen} visible={batchMoveOpen}
onClose={() => setBatchMoveOpen(false)} onClose={() => setBatchMoveOpen(false)}