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',