mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
feat: sort the mail list oldest-first or newest-first (#5)
This commit is contained in:
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user