From eb1dd6c8440bebf0350a547cd0ab4939cc5ca199 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:31:16 +0200 Subject: [PATCH] feat: sort the mail list oldest-first or newest-first (#5) --- src/screens/EmailListScreen.tsx | 17 +++- src/stores/__tests__/email-store.test.ts | 104 ++++++++++++++++++++++- src/stores/email-store.ts | 44 ++++++++-- src/stores/settings-store.ts | 4 + 4 files changed, 157 insertions(+), 12 deletions(-) diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index 060c1e65..49c855f7 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -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 )} + setSortAscending(!sortAscending)} + accessibilityRole="button" + accessibilityLabel={sortAscending ? 'Sorted oldest first' : 'Sorted newest first'} + accessibilityHint="Reverses the mail sort order" + > + {sortAscending ? ( + + ) : ( + + )} + 0 && styles.filterButtonActive]} onPress={() => setFilterMenuOpen(true)} diff --git a/src/stores/__tests__/email-store.test.ts b/src/stores/__tests__/email-store.test.ts index 4d9b7489..d7a058dd 100644 --- a/src/stores/__tests__/email-store.test.ts +++ b/src/stores/__tests__/email-store.test.ts @@ -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 = { + 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; const mockGetSharedMailboxes = emailApi.getSharedMailboxes as ReturnType; @@ -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 diff --git a/src/stores/email-store.ts b/src/stores/email-store.ts index 8e5c0661..7b4f483b 100644 --- a/src/stores/email-store.ts +++ b/src/stores/email-store.ts @@ -242,6 +242,7 @@ export interface EmailState { searchEmails: (query: string) => Promise; setSearchQuery: (query: string) => void; setFilters: (filters: EmailFilters) => void; + setSortAscending: (ascending: boolean) => void; clearSearchAndFilters: () => void; reset: () => void; } @@ -655,6 +656,8 @@ export const useEmailStore = create()( 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()( 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()( 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()( ) { 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()( // 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()( 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()( 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 = {}; + 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()( .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 }); } } diff --git a/src/stores/settings-store.ts b/src/stores/settings-store.ts index a60911e9..fab3abf9 100644 --- a/src/stores/settings-store.ts +++ b/src/stores/settings-store.ts @@ -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',