feat: sort the mail list oldest-first or newest-first (#5)

This commit is contained in:
Linus Rath
2026-08-29 17:31:16 +02:00
parent fb4796e188
commit eb1dd6c844
4 changed files with 157 additions and 12 deletions
+16 -1
View File
@@ -6,7 +6,7 @@ import * as DocumentPicker from 'expo-document-picker';
import {
Search, SquarePen, Menu, Filter, Square, SquareCheck, Minus, X,
Star, Paperclip, Mail as MailIcon, MailOpen, Trash2, RotateCcw, CalendarDays,
Archive, FolderInput, Tag, Import,
Archive, FolderInput, Tag, Import, ArrowDownWideNarrow, ArrowUpNarrowWide,
} from 'lucide-react-native';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
@@ -206,6 +206,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
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 keywordDefs = useKeywordsStore((s) => s.keywords);
const keywordsHydrated = useKeywordsStore((s) => s.hydrated);
@@ -217,6 +218,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
const swipeMode = useSettingsStore((s) => s.swipeMode);
const showPreview = useSettingsStore((s) => s.showPreview);
const disableThreading = useSettingsStore((s) => s.disableThreading);
const sortAscending = useSettingsStore((s) => s.mailSortAscending);
const networkOnline = useNetworkStore((s) => s.online);
// When threading is on, collapse same-thread emails so the list shows the
@@ -694,6 +696,19 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
</Pressable>
)}
</View>
<Pressable
style={styles.filterButton}
onPress={() => setSortAscending(!sortAscending)}
accessibilityRole="button"
accessibilityLabel={sortAscending ? 'Sorted oldest first' : 'Sorted newest first'}
accessibilityHint="Reverses the mail sort order"
>
{sortAscending ? (
<ArrowUpNarrowWide size={18} color={c.primary} />
) : (
<ArrowDownWideNarrow size={18} color={c.textMuted} />
)}
</Pressable>
<Pressable
style={[styles.filterButton, activeFilterCount > 0 && styles.filterButtonActive]}
onPress={() => setFilterMenuOpen(true)}
+100 -4
View File
@@ -61,10 +61,24 @@ vi.mock('../outbox-store', async () => {
// settings-store transitively pulls in jmap-client / expo-secure-store, which
// trip on react-native's Flow-typed entrypoint under vitest. The store only
// reads archiveMode in archiveEmail, so a minimal stub is enough.
vi.mock('../settings-store', () => ({
useSettingsStore: { getState: () => ({ archiveMode: 'single', emailsPerPage: 25 }) },
}));
// reads a handful of scalar settings, so a minimal stateful stub is enough.
vi.mock('../settings-store', () => {
const settings: Record<string, unknown> = {
archiveMode: 'single',
emailsPerPage: 25,
mailSortAscending: false,
};
return {
useSettingsStore: {
getState: () => ({
...settings,
updateSetting: (key: string, value: unknown) => {
settings[key] = value;
},
}),
},
};
});
// offline-cache-store is touched by selectMailbox (cache-seed fallback),
// getEmailDetail (best-effort body refresh), and setActiveAccount (account
@@ -113,6 +127,7 @@ const TEST_ACCOUNT_ID = generateAccountId('test@example.com', 'https://mail.exam
import * as emailApi from '../../api/email';
import { useEmailStore } from '../email-store';
import { useSettingsStore } from '../settings-store';
const mockGetMailboxesWithState = emailApi.getMailboxesWithState as ReturnType<typeof vi.fn>;
const mockGetSharedMailboxes = emailApi.getSharedMailboxes as ReturnType<typeof vi.fn>;
@@ -133,6 +148,7 @@ beforeEach(() => {
// serving, so the guard inside fetchMailboxes / refreshEmails / etc.
// doesn't short-circuit the tests.
useEmailStore.setState({ activeAccountId: TEST_ACCOUNT_ID });
useSettingsStore.getState().updateSetting('mailSortAscending', false);
});
describe('email-store', () => {
@@ -352,6 +368,86 @@ describe('email-store', () => {
});
});
// Issue #5: the sort-order toggle. Flipping it must drop every cached
// snapshot / queryState (they belong to the old sort order) and re-query
// with the new direction; the incremental queryChanges path must carry the
// same sort as the query that produced its queryState.
describe('sort order (issue #5)', () => {
it('setSortAscending clears cached query state and re-queries ascending', async () => {
useEmailStore.setState({
currentMailboxId: 'mb-1',
emails: [{ id: 'e1' } as any],
totalEmails: 1,
queryState: 'q-desc',
mailboxSnapshots: {
'mb-1': { emails: [{ id: 'e1' } as any], total: 1, queryState: 'q-desc' },
},
accountSnapshots: {
'other-acc': {
mailboxes: [],
emailStates: {},
currentMailboxId: null,
mailboxSnapshots: { 'mb-9': { emails: [], total: 0, queryState: 'q-9' } },
} as any,
},
});
mockQueryEmails.mockResolvedValue({ ids: ['e1'], total: 1, queryState: 'q-asc' });
mockGetEmailsWithState.mockResolvedValue({ list: [{ id: 'e1' } as any], state: 'em-1' });
useEmailStore.getState().setSortAscending(true);
// Synchronous invalidation: old-order caches are gone everywhere,
// including accounts that are tucked away.
expect(useEmailStore.getState().queryState).toBeUndefined();
expect(useEmailStore.getState().mailboxSnapshots).toEqual({});
expect(useEmailStore.getState().accountSnapshots['other-acc'].mailboxSnapshots).toEqual({});
await vi.waitFor(() => expect(mockQueryEmails).toHaveBeenCalled());
const [, opts] = mockQueryEmails.mock.calls[0];
expect(opts.sort).toEqual([{ property: 'receivedAt', isAscending: true }]);
// With the snapshot's queryState gone the incremental path must not run.
expect(mockGetEmailQueryChanges).not.toHaveBeenCalled();
});
it('is a no-op when the direction is unchanged', () => {
useEmailStore.setState({
currentMailboxId: 'mb-1',
mailboxSnapshots: { 'mb-1': { emails: [], total: 0, queryState: 'q-base' } },
});
useEmailStore.getState().setSortAscending(false);
expect(useEmailStore.getState().mailboxSnapshots['mb-1']).toBeDefined();
expect(mockQueryEmails).not.toHaveBeenCalled();
});
it('incremental refresh passes the active sort to Email/queryChanges', async () => {
useSettingsStore.getState().updateSetting('mailSortAscending', true);
const base = [{ id: 'e1' } as any];
useEmailStore.setState({
currentMailboxId: 'mb-1',
emails: base,
totalEmails: 1,
searchQuery: '',
filters: {},
mailboxSnapshots: { 'mb-1': { emails: base, total: 1, queryState: 'q-asc' } },
});
mockGetEmailQueryChanges.mockResolvedValue({
oldQueryState: 'q-asc',
newQueryState: 'q-asc-2',
total: 1,
removed: [],
added: [],
});
await useEmailStore.getState().refreshEmails();
const [, , opts] = mockGetEmailQueryChanges.mock.calls[0];
expect(opts.sort).toEqual([{ property: 'receivedAt', isAscending: true }]);
expect(mockQueryEmails).not.toHaveBeenCalled();
});
});
// Issue #10: after searching, the inbox stayed stuck on the search results.
// Root cause: the incremental Email/queryChanges refresh diffed against the
// on-screen list (search results) instead of the cached base view, then
+37 -7
View File
@@ -242,6 +242,7 @@ export interface EmailState {
searchEmails: (query: string) => Promise<Email[]>;
setSearchQuery: (query: string) => void;
setFilters: (filters: EmailFilters) => void;
setSortAscending: (ascending: boolean) => void;
clearSearchAndFilters: () => void;
reset: () => void;
}
@@ -655,6 +656,8 @@ export const useEmailStore = create<EmailState>()(
rawMailboxId(state.mailboxes, mailboxId),
Math.max(limit, 50),
);
// The cache returns newest-first; flip for an ascending sort.
if (useSettingsStore.getState().mailSortAscending) seededEmails.reverse();
seededTotal = seededEmails.length;
} catch (err) {
console.warn('[email-store] cache seed failed:', err);
@@ -700,6 +703,10 @@ export const useEmailStore = create<EmailState>()(
const { ids } = await queryEmails(ref.id, {
position: emails.length,
limit,
sort: [{
property: 'receivedAt',
isAscending: useSettingsStore.getState().mailSortAscending,
}],
filter,
accountId: ref.accountId,
});
@@ -772,16 +779,19 @@ export const useEmailStore = create<EmailState>()(
const ref = refFor(state.mailboxes, currentMailboxId);
const emailState = state.emailStates[stateKey(ref.accountId)];
const filter = buildJmapFilter(ref.id, searchQuery, filters);
const limit = useSettingsStore.getState().emailsPerPage;
const { emailsPerPage: limit, mailSortAscending: sortAscending } =
useSettingsStore.getState();
const sort = [{ property: 'receivedAt', isAscending: sortAscending }];
const baseView = isBaseView(searchQuery, filters);
// A response that lands after the user switched account/mailbox or
// changed search/filters must not overwrite the newer view.
// changed search/filters/sort must not overwrite the newer view.
const viewChanged = () =>
get().activeAccountId !== activeAccountId ||
get().currentMailboxId !== currentMailboxId ||
get().searchQuery !== searchQuery ||
get().filters !== filters;
get().filters !== filters ||
useSettingsStore.getState().mailSortAscending !== sortAscending;
// The incremental path diffs against the *base-view* list, which lives in
// the per-mailbox snapshot — NOT `emails`, which may still hold search or
@@ -806,6 +816,7 @@ export const useEmailStore = create<EmailState>()(
) {
const baseEmails = snap.emails;
const queryChanges = await getEmailQueryChanges(ref.id, snap.queryState, {
sort,
filter: undefined,
accountId: ref.accountId,
});
@@ -906,7 +917,7 @@ export const useEmailStore = create<EmailState>()(
// Full re-query path. Used when there's no prior queryState, when the
// user has search/filters active (queryState only tracks the base
// query), or when the server returned cannotCalculateChanges above.
const queryRes = await queryEmails(ref.id, { limit, filter, accountId: ref.accountId });
const queryRes = await queryEmails(ref.id, { limit, sort, filter, accountId: ref.accountId });
const fetched = queryRes.ids.length > 0
? await getEmailsWithState(queryRes.ids, ref.accountId)
: { list: [], state: undefined as string | undefined };
@@ -946,6 +957,7 @@ export const useEmailStore = create<EmailState>()(
ref.id,
Math.max(limit, 50),
);
if (sortAscending) cached.reverse();
if (
get().activeAccountId === activeAccountId &&
get().currentMailboxId === currentMailboxId &&
@@ -1024,6 +1036,22 @@ export const useEmailStore = create<EmailState>()(
void get().refreshEmails();
},
setSortAscending: (ascending) => {
const settings = useSettingsStore.getState();
if (settings.mailSortAscending === ascending) return;
settings.updateSetting('mailSortAscending', ascending);
// Every cached queryState and snapshot window was built under the old
// sort order — drop them all (active view and tucked-away accounts) so
// the next refresh does a full re-query instead of running
// Email/queryChanges against a differently-sorted query.
const accountSnapshots: Record<string, AccountSnapshot> = {};
for (const [id, acc] of Object.entries(get().accountSnapshots)) {
accountSnapshots[id] = { ...acc, mailboxSnapshots: {} };
}
set({ queryState: undefined, mailboxSnapshots: {}, accountSnapshots });
void get().refreshEmails();
},
clearSearchAndFilters: () => {
const state = get();
if (!state.searchQuery && Object.keys(state.filters).length === 0) return;
@@ -1563,9 +1591,11 @@ export const useEmailStore = create<EmailState>()(
.filter((it) => it.originalMailboxIds[currentRawId])
.map((it) => ({ ...it.email, mailboxIds: it.originalMailboxIds }));
if (restored.length > 0) {
const merged = [...restored, ...emails].sort(
(a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime(),
);
const ascending = useSettingsStore.getState().mailSortAscending;
const merged = [...restored, ...emails].sort((a, b) => {
const byDate = new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime();
return ascending ? -byDate : byDate;
});
set({ emails: merged });
}
}
+4
View File
@@ -143,6 +143,9 @@ interface PersistedSettings {
showPreview: boolean;
mailLayout: MailLayout;
emailsPerPage: number;
// Mail list sort order: oldest-first when true. Applies to every mailbox
// (the JMAP Email/query sorts by receivedAt).
mailSortAscending: boolean;
disableThreading: boolean;
mailAttachmentAction: MailAttachmentAction;
attachmentPosition: AttachmentPosition;
@@ -259,6 +262,7 @@ const DEFAULT_PERSISTED: PersistedSettings = {
showPreview: true,
mailLayout: 'split',
emailsPerPage: 25,
mailSortAscending: false,
disableThreading: false,
mailAttachmentAction: 'preview',
attachmentPosition: 'beside-sender',