mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +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,
|
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)}
|
||||||
|
|||||||
Reference in New Issue
Block a user