mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
feat: open the sort orders from the list header's sort button
This commit is contained in:
@@ -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\)/);
|
||||
});
|
||||
});
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user