mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
fix: load each list page and search in one request
Opening a folder, searching or loading more took three sequential round trips: `Email/query`, then `Email/get` of its ids, then a `Thread/get` the list screen fired after rendering to size the conversation badges. - `queryEmailPage` sends all three in one request, chaining `Email/get` to the query's `/ids` and `Thread/get` to the fetched messages' `/list/*/threadId` (left out while threading is off). The page is capped at `maxObjectsInGet` so the chained get can't be refused, and a failed `Thread/get` keeps the page. - The full refresh, search and load-more use it; load-more no longer needs its own chunked `Email/get`. - The thread sizes now live in the email store (`threadCounts`, cleared on folder or account change) and the list screen reads them instead of fetching them itself. Rows restored from a snapshot or added by an incremental refresh get theirs with one `Thread/get` after the refresh. - The chained `Email/get` also reports the Email state for an empty folder, so that list gets its own `Email/changes` baseline.
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
|||||||
getMailboxes,
|
getMailboxes,
|
||||||
getSharedMailboxes,
|
getSharedMailboxes,
|
||||||
queryEmails,
|
queryEmails,
|
||||||
|
queryEmailPage,
|
||||||
getEmails,
|
getEmails,
|
||||||
getFullEmail,
|
getFullEmail,
|
||||||
getThread,
|
getThread,
|
||||||
@@ -211,6 +212,101 @@ describe('email operations', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('queryEmailPage', () => {
|
||||||
|
it('chains Email/query, Email/get and Thread/get in one request', async () => {
|
||||||
|
mockRequest.mockResolvedValue({
|
||||||
|
methodResponses: [
|
||||||
|
['Email/query', { ids: ['e2', 'e1'], total: 7, queryState: 'q-1' }, '0'],
|
||||||
|
// Email/get may answer in another order than the query's.
|
||||||
|
['Email/get', { list: [{ id: 'e1', threadId: 't1' }, { id: 'e2', threadId: 't1' }], state: 's-1' }, '1'],
|
||||||
|
['Thread/get', { list: [{ id: 't1', emailIds: ['e1', 'e2', 'e3'] }] }, '2'],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await queryEmailPage('mb-1', { limit: 25, position: 50, threads: true });
|
||||||
|
|
||||||
|
expect(mockRequest).toHaveBeenCalledTimes(1);
|
||||||
|
const calls = mockRequest.mock.calls[0][0];
|
||||||
|
expect(calls).toEqual([
|
||||||
|
['Email/query', expect.objectContaining({
|
||||||
|
accountId: 'acc-1', filter: { inMailbox: 'mb-1' }, position: 50, limit: 25, calculateTotal: true,
|
||||||
|
}), '0'],
|
||||||
|
['Email/get', expect.objectContaining({
|
||||||
|
accountId: 'acc-1',
|
||||||
|
'#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
|
||||||
|
}), '1'],
|
||||||
|
['Thread/get', {
|
||||||
|
accountId: 'acc-1',
|
||||||
|
'#ids': { resultOf: '1', name: 'Email/get', path: '/list/*/threadId' },
|
||||||
|
}, '2'],
|
||||||
|
]);
|
||||||
|
expect(calls[1][1].properties).toContain('preview');
|
||||||
|
expect(calls[1][1].properties).not.toContain('bodyStructure');
|
||||||
|
expect(result).toEqual({
|
||||||
|
ids: ['e2', 'e1'],
|
||||||
|
total: 7,
|
||||||
|
queryState: 'q-1',
|
||||||
|
list: [{ id: 'e2', threadId: 't1' }, { id: 'e1', threadId: 't1' }],
|
||||||
|
state: 's-1',
|
||||||
|
threads: [{ id: 't1', emailIds: ['e1', 'e2', 'e3'] }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves Thread/get out unless asked, and targets a shared account', async () => {
|
||||||
|
mockRequest.mockResolvedValue({
|
||||||
|
methodResponses: [
|
||||||
|
['Email/query', { ids: [], total: 0, queryState: 'q-1' }, '0'],
|
||||||
|
['Email/get', { list: [], state: 's-1' }, '1'],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await queryEmailPage('mb-1', { accountId: 'grp-1' });
|
||||||
|
|
||||||
|
const calls = mockRequest.mock.calls[0][0];
|
||||||
|
expect(calls.map((c: unknown[]) => c[0])).toEqual(['Email/query', 'Email/get']);
|
||||||
|
expect(calls[0][1].accountId).toBe('grp-1');
|
||||||
|
expect(calls[1][1].accountId).toBe('grp-1');
|
||||||
|
// An empty page still reports the Email state.
|
||||||
|
expect(result).toMatchObject({ list: [], state: 's-1', threads: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the page when Thread/get fails, and fails with the query', async () => {
|
||||||
|
mockRequest.mockResolvedValueOnce({
|
||||||
|
methodResponses: [
|
||||||
|
['Email/query', { ids: ['e1'], total: 1 }, '0'],
|
||||||
|
['Email/get', { list: [{ id: 'e1', threadId: 't1' }], state: 's-1' }, '1'],
|
||||||
|
['error', { type: 'serverFail' }, '2'],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = await queryEmailPage('mb-1', { threads: true });
|
||||||
|
expect(result.list).toEqual([{ id: 'e1', threadId: 't1' }]);
|
||||||
|
expect(result.threads).toEqual([]);
|
||||||
|
|
||||||
|
mockRequest.mockResolvedValueOnce({
|
||||||
|
methodResponses: [
|
||||||
|
['error', { type: 'unsupportedSort' }, '0'],
|
||||||
|
['error', { type: 'invalidResultReference' }, '1'],
|
||||||
|
['error', { type: 'invalidResultReference' }, '2'],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await expect(queryEmailPage('mb-1', { threads: true })).rejects.toMatchObject({ type: 'unsupportedSort' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps the page at the server\'s maxObjectsInGet', async () => {
|
||||||
|
(jmapClient.getMaxObjectsInGet as ReturnType<typeof vi.fn>).mockReturnValueOnce(100);
|
||||||
|
mockRequest.mockResolvedValue({
|
||||||
|
methodResponses: [
|
||||||
|
['Email/query', { ids: [], total: 0 }, '0'],
|
||||||
|
['Email/get', { list: [], state: 's-1' }, '1'],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await queryEmailPage('mb-1', { limit: 250 });
|
||||||
|
|
||||||
|
expect(mockRequest.mock.calls[0][0][0][1].limit).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('getEmails', () => {
|
describe('getEmails', () => {
|
||||||
it('should fetch emails by id with list properties', async () => {
|
it('should fetch emails by id with list properties', async () => {
|
||||||
const emails = [{ id: 'e1', subject: 'Test' }];
|
const emails = [{ id: 'e1', subject: 'Test' }];
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { JMAPClient } from '../jmap-client';
|
|||||||
import {
|
import {
|
||||||
getMailboxes,
|
getMailboxes,
|
||||||
queryEmails,
|
queryEmails,
|
||||||
|
queryEmailPage,
|
||||||
getFullEmails,
|
getFullEmails,
|
||||||
getThreadEmails,
|
getThreadEmails,
|
||||||
createDraft,
|
createDraft,
|
||||||
@@ -157,6 +158,19 @@ live('live Stalwart', () => {
|
|||||||
expect([...times].sort((a, b) => a - b)).toEqual(times);
|
expect([...times].sort((a, b) => a - b)).toEqual(times);
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
|
it('loads a list page with its threads in one chained request (PF7)', async () => {
|
||||||
|
const page = await queryEmailPage(inbox, { limit: 20, threads: true });
|
||||||
|
const { ids, total } = await queryEmails(inbox, { limit: 20 });
|
||||||
|
expect(page.ids).toEqual(ids);
|
||||||
|
expect(page.total).toBe(total);
|
||||||
|
expect(page.list.map((e) => e.id)).toEqual(ids);
|
||||||
|
expect(page.state).toBeTruthy();
|
||||||
|
const threadIds = new Set(page.list.map((e) => e.threadId));
|
||||||
|
expect(new Set(page.threads.map((t) => t.id))).toEqual(threadIds);
|
||||||
|
const reply = page.list.find((e) => (e.subject ?? '').startsWith('Re: '))!;
|
||||||
|
expect(page.threads.find((t) => t.id === reply.threadId)!.emailIds.length).toBeGreaterThanOrEqual(2);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
it('saves a draft into Drafts with $draft', async () => {
|
it('saves a draft into Drafts with $draft', async () => {
|
||||||
const id = await createDraft(
|
const id = await createDraft(
|
||||||
{
|
{
|
||||||
|
|||||||
+81
-13
@@ -345,18 +345,17 @@ function buildMailboxQueryFilter(
|
|||||||
return { ...inMailbox, ...userFilter };
|
return { ...inMailbox, ...userFilter };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function queryEmails(
|
interface EmailQueryOptions {
|
||||||
mailboxId: string | undefined,
|
position?: number;
|
||||||
options?: {
|
limit?: number;
|
||||||
position?: number;
|
sort?: Array<{ property: string; isAscending: boolean; keyword?: string }>;
|
||||||
limit?: number;
|
filter?: Record<string, unknown>;
|
||||||
sort?: Array<{ property: string; isAscending: boolean; keyword?: string }>;
|
/** Owning JMAP account when the mailbox belongs to a shared account. */
|
||||||
filter?: Record<string, unknown>;
|
accountId?: string;
|
||||||
/** Owning JMAP account when the mailbox belongs to a shared account. */
|
collapseThreads?: boolean;
|
||||||
accountId?: string;
|
}
|
||||||
collapseThreads?: boolean;
|
|
||||||
},
|
function emailQueryArgs(mailboxId: string | undefined, options?: EmailQueryOptions): Record<string, unknown> {
|
||||||
): Promise<{ ids: string[]; total: number; queryState?: string }> {
|
|
||||||
const accountId = options?.accountId ?? jmapClient.accountId;
|
const accountId = options?.accountId ?? jmapClient.accountId;
|
||||||
const filter = buildMailboxQueryFilter(mailboxId, options?.filter);
|
const filter = buildMailboxQueryFilter(mailboxId, options?.filter);
|
||||||
const args: Record<string, unknown> = {
|
const args: Record<string, unknown> = {
|
||||||
@@ -368,7 +367,14 @@ export async function queryEmails(
|
|||||||
calculateTotal: true,
|
calculateTotal: true,
|
||||||
};
|
};
|
||||||
if (options?.collapseThreads) args.collapseThreads = true;
|
if (options?.collapseThreads) args.collapseThreads = true;
|
||||||
const res = await jmapClient.request([['Email/query', args, '0']]);
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryEmails(
|
||||||
|
mailboxId: string | undefined,
|
||||||
|
options?: EmailQueryOptions,
|
||||||
|
): Promise<{ ids: string[]; total: number; queryState?: string }> {
|
||||||
|
const res = await jmapClient.request([['Email/query', emailQueryArgs(mailboxId, options), '0']]);
|
||||||
const body = requireMethodResult(res, '0', 'Email/query');
|
const body = requireMethodResult(res, '0', 'Email/query');
|
||||||
return {
|
return {
|
||||||
ids: (body.ids as string[]) ?? [],
|
ids: (body.ids as string[]) ?? [],
|
||||||
@@ -377,6 +383,68 @@ export async function queryEmails(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One page of a message list in a single request: Email/query, Email/get of
|
||||||
|
* the ids it returns and, with `threads`, Thread/get of those messages'
|
||||||
|
* threads (for the conversation-size badges), chained with result references
|
||||||
|
* instead of three round trips. `state` is the Email state the rows were
|
||||||
|
* read at.
|
||||||
|
*/
|
||||||
|
export async function queryEmailPage(
|
||||||
|
mailboxId: string | undefined,
|
||||||
|
options?: EmailQueryOptions & { threads?: boolean },
|
||||||
|
): Promise<{
|
||||||
|
ids: string[];
|
||||||
|
total: number;
|
||||||
|
queryState?: string;
|
||||||
|
list: Email[];
|
||||||
|
state?: string;
|
||||||
|
threads: Thread[];
|
||||||
|
}> {
|
||||||
|
// The chained Email/get takes every id the query returns, so a page can't
|
||||||
|
// be larger than the server lets one /get fetch.
|
||||||
|
const args = emailQueryArgs(mailboxId, {
|
||||||
|
...options,
|
||||||
|
limit: Math.min(options?.limit ?? 50, maxInGet()),
|
||||||
|
});
|
||||||
|
const accountId = args.accountId;
|
||||||
|
const calls: JMAPMethodCall[] = [
|
||||||
|
['Email/query', args, '0'],
|
||||||
|
['Email/get', {
|
||||||
|
accountId,
|
||||||
|
'#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
|
||||||
|
properties: EMAIL_LIST_PROPERTIES,
|
||||||
|
}, '1'],
|
||||||
|
];
|
||||||
|
if (options?.threads) {
|
||||||
|
calls.push(['Thread/get', {
|
||||||
|
accountId,
|
||||||
|
'#ids': { resultOf: '1', name: 'Email/get', path: '/list/*/threadId' },
|
||||||
|
}, '2']);
|
||||||
|
}
|
||||||
|
const res = await jmapClient.request(calls);
|
||||||
|
const query = requireMethodResult(res, '0', 'Email/query');
|
||||||
|
const got = requireMethodResult(res, '1', 'Email/get');
|
||||||
|
const ids = (query.ids as string[]) ?? [];
|
||||||
|
let threads: Thread[] = [];
|
||||||
|
if (options?.threads) {
|
||||||
|
// Thread sizes only decorate the rows: a failed Thread/get keeps the page.
|
||||||
|
try {
|
||||||
|
threads = (requireMethodResult(res, '2', 'Thread/get').list as Thread[]) ?? [];
|
||||||
|
} catch { /* the list falls back to the in-page count */ }
|
||||||
|
}
|
||||||
|
// Email/get may answer in any order; keep the query's.
|
||||||
|
const byId = new Map(((got.list as Email[]) ?? []).map((e) => [e.id, e]));
|
||||||
|
return {
|
||||||
|
ids,
|
||||||
|
total: (query.total as number) ?? 0,
|
||||||
|
queryState: query.queryState as string | undefined,
|
||||||
|
list: ids.flatMap((id) => byId.get(id) ?? []),
|
||||||
|
state: got.state as string | undefined,
|
||||||
|
threads,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface EmailQueryChangesResult {
|
export interface EmailQueryChangesResult {
|
||||||
oldQueryState: string;
|
oldQueryState: string;
|
||||||
newQueryState: string;
|
newQueryState: string;
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import {
|
|||||||
} from '../lib/thread-utils';
|
} from '../lib/thread-utils';
|
||||||
import { isPermanentDelete, confirmPermanentDelete } from '../lib/delete-confirm';
|
import { isPermanentDelete, confirmPermanentDelete } from '../lib/delete-confirm';
|
||||||
import { draftContextFromEmail, isDraftEmail } from '../lib/draft-context';
|
import { draftContextFromEmail, isDraftEmail } from '../lib/draft-context';
|
||||||
import { getThreads, getFullEmail, emptyMailbox as apiEmptyMailbox } from '../api/email';
|
import { getFullEmail, emptyMailbox as apiEmptyMailbox } from '../api/email';
|
||||||
import type { RootStackParamList } from '../navigation/types';
|
import type { RootStackParamList } from '../navigation/types';
|
||||||
import type { Email } from '../api/types';
|
import type { Email } from '../api/types';
|
||||||
|
|
||||||
@@ -334,35 +334,15 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Real conversation sizes from Thread/get (a thread's other messages may
|
// Real conversation sizes from Thread/get (a thread's other messages may
|
||||||
// live in other folders); the loaded-page count is the fallback until the
|
// live in other folders), fetched by the store together with each list
|
||||||
// response lands and for messages whose thread the server no longer knows.
|
// page; the loaded-page count is the fallback until they land and for
|
||||||
const [serverThreadCounts, setServerThreadCounts] = React.useState<Map<string, number>>(new Map());
|
// messages whose thread the server no longer knows.
|
||||||
React.useEffect(() => {
|
const serverThreadCounts = useEmailStore((s) => s.threadCounts);
|
||||||
if (disableThreading || visibleEmails.length === 0) return;
|
|
||||||
const ids = Array.from(new Set(visibleEmails.map((e) => e.threadId).filter(Boolean)));
|
|
||||||
const missing = ids.filter((id) => !serverThreadCounts.has(id));
|
|
||||||
if (missing.length === 0) return;
|
|
||||||
let cancelled = false;
|
|
||||||
getThreads(missing, currentOwnerAccountId)
|
|
||||||
.then((threads) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setServerThreadCounts((prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
for (const th of threads) next.set(th.id, th.emailIds.length);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => { /* fall back to the in-page count */ });
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [visibleEmails, disableThreading, currentOwnerAccountId]);
|
|
||||||
// Counts are per account and per snapshot; drop them on folder switch.
|
|
||||||
React.useEffect(() => { setServerThreadCounts(new Map()); }, [currentMailboxId, disableThreading]);
|
|
||||||
|
|
||||||
const threadCountFor = React.useCallback((e: Email): number => {
|
const threadCountFor = React.useCallback((e: Email): number => {
|
||||||
if (disableThreading) return 1;
|
if (disableThreading) return 1;
|
||||||
const local = threadGroups.get(threadKeyOf(e, false))?.length ?? 1;
|
const local = threadGroups.get(threadKeyOf(e, false))?.length ?? 1;
|
||||||
const server = e.threadId ? serverThreadCounts.get(e.threadId) : undefined;
|
const server = e.threadId ? serverThreadCounts[e.threadId] : undefined;
|
||||||
return Math.max(local, server ?? 1);
|
return Math.max(local, server ?? 1);
|
||||||
}, [disableThreading, threadGroups, serverThreadCounts]);
|
}, [disableThreading, threadGroups, serverThreadCounts]);
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,14 @@ vi.mock('../../api/email', () => ({
|
|||||||
getMailboxesByIds: vi.fn(async () => ({ list: [], state: 'mb-state-0' })),
|
getMailboxesByIds: vi.fn(async () => ({ list: [], state: 'mb-state-0' })),
|
||||||
getMailboxChanges: vi.fn(async () => null),
|
getMailboxChanges: vi.fn(async () => null),
|
||||||
queryEmails: vi.fn(),
|
queryEmails: vi.fn(),
|
||||||
|
// A list page: Email/query, Email/get and Thread/get in one request.
|
||||||
|
// beforeEach scripts it from the queryEmails / getEmailsWithState mocks.
|
||||||
|
queryEmailPage: vi.fn(),
|
||||||
getEmailQueryChanges: vi.fn(async () => null),
|
getEmailQueryChanges: vi.fn(async () => null),
|
||||||
getEmails: vi.fn(),
|
getEmails: vi.fn(),
|
||||||
getEmailsWithState: vi.fn(async () => ({ list: [], state: 'em-state-0' })),
|
getEmailsWithState: vi.fn(async () => ({ list: [], state: 'em-state-0' })),
|
||||||
getEmailChanges: vi.fn(async () => null),
|
getEmailChanges: vi.fn(async () => null),
|
||||||
|
getThreads: vi.fn(async () => []),
|
||||||
getFullEmail: vi.fn(),
|
getFullEmail: vi.fn(),
|
||||||
importEmailBlob: vi.fn(async () => 'imported-1'),
|
importEmailBlob: vi.fn(async () => 'imported-1'),
|
||||||
patchKeywordsForEmails: vi.fn(),
|
patchKeywordsForEmails: vi.fn(),
|
||||||
@@ -151,6 +155,8 @@ import { useSettingsStore } from '../settings-store';
|
|||||||
const mockGetMailboxesWithState = emailApi.getMailboxesWithState as ReturnType<typeof vi.fn>;
|
const mockGetMailboxesWithState = emailApi.getMailboxesWithState as ReturnType<typeof vi.fn>;
|
||||||
const mockGetSharedMailboxes = emailApi.getSharedMailboxes as ReturnType<typeof vi.fn>;
|
const mockGetSharedMailboxes = emailApi.getSharedMailboxes as ReturnType<typeof vi.fn>;
|
||||||
const mockQueryEmails = emailApi.queryEmails as ReturnType<typeof vi.fn>;
|
const mockQueryEmails = emailApi.queryEmails as ReturnType<typeof vi.fn>;
|
||||||
|
const mockQueryEmailPage = emailApi.queryEmailPage as ReturnType<typeof vi.fn>;
|
||||||
|
const mockGetThreads = emailApi.getThreads as ReturnType<typeof vi.fn>;
|
||||||
const mockGetEmailQueryChanges = emailApi.getEmailQueryChanges as ReturnType<typeof vi.fn>;
|
const mockGetEmailQueryChanges = emailApi.getEmailQueryChanges as ReturnType<typeof vi.fn>;
|
||||||
const mockGetEmails = emailApi.getEmails as ReturnType<typeof vi.fn>;
|
const mockGetEmails = emailApi.getEmails as ReturnType<typeof vi.fn>;
|
||||||
const mockGetEmailsWithState = emailApi.getEmailsWithState as ReturnType<typeof vi.fn>;
|
const mockGetEmailsWithState = emailApi.getEmailsWithState as ReturnType<typeof vi.fn>;
|
||||||
@@ -168,6 +174,15 @@ beforeEach(() => {
|
|||||||
// doesn't short-circuit the tests.
|
// doesn't short-circuit the tests.
|
||||||
useEmailStore.setState({ activeAccountId: TEST_ACCOUNT_ID });
|
useEmailStore.setState({ activeAccountId: TEST_ACCOUNT_ID });
|
||||||
useSettingsStore.getState().updateSetting('mailSortAscending', false);
|
useSettingsStore.getState().updateSetting('mailSortAscending', false);
|
||||||
|
// Most tests describe a list page as its query and its Email/get; the
|
||||||
|
// chained-request tests below script queryEmailPage directly.
|
||||||
|
mockQueryEmailPage.mockImplementation(async (mailboxId: string | undefined, opts?: { accountId?: string }) => {
|
||||||
|
const query = await emailApi.queryEmails(mailboxId, opts);
|
||||||
|
const got = query.ids.length > 0
|
||||||
|
? await emailApi.getEmailsWithState(query.ids, opts?.accountId)
|
||||||
|
: { list: [], state: 'em-state-0' };
|
||||||
|
return { ...query, list: got.list, state: got.state, threads: [] };
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('email-store', () => {
|
describe('email-store', () => {
|
||||||
@@ -217,9 +232,8 @@ describe('email-store', () => {
|
|||||||
await useEmailStore.getState().selectMailbox('mb-empty');
|
await useEmailStore.getState().selectMailbox('mb-empty');
|
||||||
|
|
||||||
expect(useEmailStore.getState().emails).toEqual([]);
|
expect(useEmailStore.getState().emails).toEqual([]);
|
||||||
// First-time load with no ids hits getEmailsWithState only to prime
|
// The page's chained Email/get still reports the folder's Email state.
|
||||||
// emailState (with an empty ids array). It should NOT call legacy
|
expect(useEmailStore.getState().emailStates).toEqual({ 'mb-empty': 'em-state-0' });
|
||||||
// getEmails — that path is reserved for pagination/search.
|
|
||||||
expect(mockGetEmails).not.toHaveBeenCalled();
|
expect(mockGetEmails).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -234,8 +248,9 @@ describe('email-store', () => {
|
|||||||
loading: false,
|
loading: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
mockQueryEmails.mockResolvedValue({ ids: ['e2'], total: 2 });
|
mockQueryEmailPage.mockResolvedValue({
|
||||||
mockGetEmails.mockResolvedValue([{ id: 'e2', subject: 'Email 2' }]);
|
ids: ['e2'], total: 2, list: [{ id: 'e2', subject: 'Email 2' }], threads: [],
|
||||||
|
});
|
||||||
|
|
||||||
await useEmailStore.getState().loadMoreEmails();
|
await useEmailStore.getState().loadMoreEmails();
|
||||||
|
|
||||||
@@ -267,12 +282,13 @@ describe('email-store', () => {
|
|||||||
totalEmails: 10,
|
totalEmails: 10,
|
||||||
loading: false,
|
loading: false,
|
||||||
});
|
});
|
||||||
mockQueryEmails.mockResolvedValue({ ids: ['e4', 'e5'], total: 9 });
|
mockQueryEmailPage.mockResolvedValue({
|
||||||
mockGetEmails.mockResolvedValue([{ id: 'e4' }, { id: 'e5' }]);
|
ids: ['e4', 'e5'], total: 9, list: [{ id: 'e4' }, { id: 'e5' }], threads: [],
|
||||||
|
});
|
||||||
|
|
||||||
await useEmailStore.getState().loadMoreEmails();
|
await useEmailStore.getState().loadMoreEmails();
|
||||||
|
|
||||||
expect(mockQueryEmails).toHaveBeenCalledWith('mb-1', expect.objectContaining({
|
expect(mockQueryEmailPage).toHaveBeenCalledWith('mb-1', expect.objectContaining({
|
||||||
position: 2,
|
position: 2,
|
||||||
filter: { notKeyword: '$seen' },
|
filter: { notKeyword: '$seen' },
|
||||||
}));
|
}));
|
||||||
@@ -292,12 +308,11 @@ describe('email-store', () => {
|
|||||||
totalEmails: 4,
|
totalEmails: 4,
|
||||||
loading: false,
|
loading: false,
|
||||||
});
|
});
|
||||||
mockQueryEmails.mockResolvedValue({ ids: ['e5'], total: 4 });
|
mockQueryEmailPage.mockResolvedValue({ ids: ['e5'], total: 4, list: [{ id: 'e5' }], threads: [] });
|
||||||
mockGetEmails.mockResolvedValue([{ id: 'e5' }]);
|
|
||||||
|
|
||||||
await useEmailStore.getState().loadMoreEmails();
|
await useEmailStore.getState().loadMoreEmails();
|
||||||
|
|
||||||
expect(mockQueryEmails).toHaveBeenCalledWith('mb-1', expect.objectContaining({ position: 3 }));
|
expect(mockQueryEmailPage).toHaveBeenCalledWith('mb-1', expect.objectContaining({ position: 3 }));
|
||||||
expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['e1', 'e2', 'e3', 'e4', 'e5']);
|
expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['e1', 'e2', 'e3', 'e4', 'e5']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -709,12 +724,12 @@ describe('email-store', () => {
|
|||||||
emails: [{ id: 'e1' } as any, { id: 'e2' } as any],
|
emails: [{ id: 'e1' } as any, { id: 'e2' } as any],
|
||||||
totalEmails: 10,
|
totalEmails: 10,
|
||||||
});
|
});
|
||||||
mockQueryEmails.mockResolvedValue({ ids: ['e2', 'e3'], total: 10 });
|
mockQueryEmailPage.mockResolvedValue({
|
||||||
mockGetEmails.mockResolvedValue([{ id: 'e3' }]);
|
ids: ['e2', 'e3'], total: 10, list: [{ id: 'e2' }, { id: 'e3' }], threads: [],
|
||||||
|
});
|
||||||
|
|
||||||
await useEmailStore.getState().loadMoreEmails();
|
await useEmailStore.getState().loadMoreEmails();
|
||||||
|
|
||||||
expect(mockGetEmails).toHaveBeenCalledWith(['e3'], undefined);
|
|
||||||
expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['e1', 'e2', 'e3']);
|
expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['e1', 'e2', 'e3']);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -984,11 +999,11 @@ describe('email-store', () => {
|
|||||||
|
|
||||||
it('an empty folder keeps both its own and the other lists\' Email state', async () => {
|
it('an empty folder keeps both its own and the other lists\' Email state', async () => {
|
||||||
openInbox({ emailStates: { 'mb-1': 'em-1', 'mb-empty': 'em-0' } });
|
openInbox({ emailStates: { 'mb-1': 'em-1', 'mb-empty': 'em-0' } });
|
||||||
mockQueryEmails.mockResolvedValue({ ids: [], total: 0, queryState: 'q-empty' });
|
// An empty result from a server that leaves `state` out of Email/get.
|
||||||
|
mockQueryEmailPage.mockResolvedValueOnce({ ids: [], total: 0, queryState: 'q-empty', list: [], threads: [] });
|
||||||
|
|
||||||
await useEmailStore.getState().selectMailbox('mb-empty');
|
await useEmailStore.getState().selectMailbox('mb-empty');
|
||||||
|
|
||||||
expect(mockGetEmailsWithState).not.toHaveBeenCalled();
|
|
||||||
expect(useEmailStore.getState().emailStates).toEqual({ 'mb-1': 'em-1', 'mb-empty': 'em-0' });
|
expect(useEmailStore.getState().emailStates).toEqual({ 'mb-1': 'em-1', 'mb-empty': 'em-0' });
|
||||||
|
|
||||||
// Back in the Inbox, the refresh still runs Email/changes from the
|
// Back in the Inbox, the refresh still runs Email/changes from the
|
||||||
@@ -1051,6 +1066,111 @@ describe('email-store', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PF7: a list page or search used to be Email/query, then Email/get, then a
|
||||||
|
// Thread/get the list screen fired after rendering.
|
||||||
|
describe('list page requests', () => {
|
||||||
|
const page = {
|
||||||
|
ids: ['e1', 'e2'], total: 2, queryState: 'q-1', state: 'em-1',
|
||||||
|
list: [{ id: 'e1', threadId: 't1', keywords: {} }, { id: 'e2', threadId: 't2', keywords: {} }],
|
||||||
|
threads: [{ id: 't1', emailIds: ['e1', 'x1', 'x2'] }, { id: 't2', emailIds: ['e2'] }],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('loads a folder page with its messages and thread sizes in one request', async () => {
|
||||||
|
useEmailStore.setState({ currentMailboxId: 'mb-1' });
|
||||||
|
mockQueryEmailPage.mockResolvedValueOnce(page);
|
||||||
|
|
||||||
|
await useEmailStore.getState().refreshEmails();
|
||||||
|
|
||||||
|
expect(mockQueryEmailPage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockQueryEmailPage).toHaveBeenCalledWith('mb-1', expect.objectContaining({ limit: 25, threads: true }));
|
||||||
|
expect(mockQueryEmails).not.toHaveBeenCalled();
|
||||||
|
expect(mockGetEmailsWithState).not.toHaveBeenCalled();
|
||||||
|
expect(mockGetThreads).not.toHaveBeenCalled();
|
||||||
|
const state = useEmailStore.getState();
|
||||||
|
expect(state.emails.map((e) => e.id)).toEqual(['e1', 'e2']);
|
||||||
|
expect(state.threadCounts).toEqual({ t1: 3, t2: 1 });
|
||||||
|
expect(state.emailStates).toEqual({ 'mb-1': 'em-1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs a search as the same single request', async () => {
|
||||||
|
useEmailStore.setState({ currentMailboxId: 'mb-1' });
|
||||||
|
mockQueryEmailPage.mockResolvedValue(page);
|
||||||
|
|
||||||
|
useEmailStore.getState().setSearchQuery('invoice');
|
||||||
|
await vi.waitFor(() => expect(useEmailStore.getState().threadCounts).toEqual({ t1: 3, t2: 1 }));
|
||||||
|
|
||||||
|
expect(mockQueryEmailPage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockQueryEmailPage.mock.calls[0][1]).toMatchObject({ filter: { text: 'invoice*' }, threads: true });
|
||||||
|
expect(mockGetThreads).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves Thread/get out when threading is off', async () => {
|
||||||
|
useSettingsStore.getState().updateSetting('disableThreading', true);
|
||||||
|
try {
|
||||||
|
useEmailStore.setState({ currentMailboxId: 'mb-1' });
|
||||||
|
mockQueryEmailPage.mockResolvedValueOnce({ ...page, threads: [] });
|
||||||
|
|
||||||
|
await useEmailStore.getState().refreshEmails();
|
||||||
|
|
||||||
|
expect(mockQueryEmailPage).toHaveBeenCalledWith('mb-1', expect.objectContaining({ threads: false }));
|
||||||
|
} finally {
|
||||||
|
useSettingsStore.getState().updateSetting('disableThreading', false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('load-more adds the next page\'s thread sizes', async () => {
|
||||||
|
useEmailStore.setState({
|
||||||
|
currentMailboxId: 'mb-1',
|
||||||
|
emails: [{ id: 'e0', threadId: 't0' } as any],
|
||||||
|
totalEmails: 3,
|
||||||
|
threadCounts: { t0: 2 },
|
||||||
|
});
|
||||||
|
mockQueryEmailPage.mockResolvedValueOnce({ ...page, total: 3 });
|
||||||
|
|
||||||
|
await useEmailStore.getState().loadMoreEmails();
|
||||||
|
|
||||||
|
expect(mockQueryEmailPage).toHaveBeenCalledWith('mb-1', expect.objectContaining({ position: 1, threads: true }));
|
||||||
|
expect(mockGetThreads).not.toHaveBeenCalled();
|
||||||
|
expect(useEmailStore.getState().threadCounts).toEqual({ t0: 2, t1: 3, t2: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches the thread sizes an incremental refresh has none for', async () => {
|
||||||
|
const mockGetEmailChanges = emailApi.getEmailChanges as ReturnType<typeof vi.fn>;
|
||||||
|
const rows = page.list as any[];
|
||||||
|
useEmailStore.setState({
|
||||||
|
currentMailboxId: 'mb-1',
|
||||||
|
emails: rows,
|
||||||
|
totalEmails: 2,
|
||||||
|
queryState: 'q-1',
|
||||||
|
emailStates: { 'mb-1': 'em-1' },
|
||||||
|
threadCounts: { t1: 3 },
|
||||||
|
mailboxSnapshots: { 'mb-1': { emails: rows, total: 2, queryState: 'q-1' } },
|
||||||
|
});
|
||||||
|
mockGetEmailQueryChanges.mockResolvedValue({
|
||||||
|
oldQueryState: 'q-1', newQueryState: 'q-2', total: 2, removed: [], added: [],
|
||||||
|
});
|
||||||
|
mockGetEmailChanges.mockResolvedValue({
|
||||||
|
oldState: 'em-1', newState: 'em-2', hasMoreChanges: false, created: [], updated: [], destroyed: [],
|
||||||
|
});
|
||||||
|
mockGetThreads.mockResolvedValueOnce([{ id: 't2', emailIds: ['e2', 'x3'] }]);
|
||||||
|
|
||||||
|
await useEmailStore.getState().refreshEmails();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(useEmailStore.getState().threadCounts).toEqual({ t1: 3, t2: 2 }));
|
||||||
|
expect(mockGetThreads).toHaveBeenCalledWith(['t2'], undefined);
|
||||||
|
expect(mockQueryEmailPage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops the thread sizes when another folder opens', async () => {
|
||||||
|
useEmailStore.setState({ currentMailboxId: 'mb-1', threadCounts: { t1: 3 } });
|
||||||
|
mockQueryEmailPage.mockResolvedValueOnce({ ids: [], total: 0, list: [], threads: [] });
|
||||||
|
|
||||||
|
await useEmailStore.getState().selectMailbox('mb-2');
|
||||||
|
|
||||||
|
expect(useEmailStore.getState().threadCounts).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('pin and spam keywords', () => {
|
describe('pin and spam keywords', () => {
|
||||||
it('togglePin writes $pinned, not $important', async () => {
|
it('togglePin writes $pinned, not $important', async () => {
|
||||||
useEmailStore.setState({ emails: [{ id: 'e1', keywords: {} } as any] });
|
useEmailStore.setState({ emails: [{ id: 'e1', keywords: {} } as any] });
|
||||||
|
|||||||
+65
-35
@@ -2,7 +2,7 @@ import { create } from 'zustand';
|
|||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import { createPersistStorage, memoizeSlice } from './persist-storage';
|
import { createPersistStorage, memoizeSlice } from './persist-storage';
|
||||||
import { boundEmailCache, type PersistedEmailCache } from './email-cache-persist';
|
import { boundEmailCache, type PersistedEmailCache } from './email-cache-persist';
|
||||||
import type { Email, Mailbox, StateChange } from '../api/types';
|
import type { Email, Mailbox, StateChange, Thread } from '../api/types';
|
||||||
import { jmapClient } from '../api/jmap-client';
|
import { jmapClient } from '../api/jmap-client';
|
||||||
import {
|
import {
|
||||||
getMailboxes as fetchMailboxes,
|
getMailboxes as fetchMailboxes,
|
||||||
@@ -10,11 +10,12 @@ import {
|
|||||||
getSharedMailboxes,
|
getSharedMailboxes,
|
||||||
getMailboxesByIds,
|
getMailboxesByIds,
|
||||||
getMailboxChanges,
|
getMailboxChanges,
|
||||||
queryEmails,
|
queryEmailPage,
|
||||||
getEmailQueryChanges,
|
getEmailQueryChanges,
|
||||||
getEmails as fetchEmails,
|
getEmails as fetchEmails,
|
||||||
getEmailsWithState,
|
getEmailsWithState,
|
||||||
getEmailChanges,
|
getEmailChanges,
|
||||||
|
getThreads,
|
||||||
getFullEmail,
|
getFullEmail,
|
||||||
importEmailBlob,
|
importEmailBlob,
|
||||||
patchKeywordsForEmails,
|
patchKeywordsForEmails,
|
||||||
@@ -290,6 +291,12 @@ export interface EmailState {
|
|||||||
* `retainedInViewIds`).
|
* `retainedInViewIds`).
|
||||||
*/
|
*/
|
||||||
retainedIds: string[];
|
retainedIds: string[];
|
||||||
|
/**
|
||||||
|
* Conversation sizes (Thread/get `emailIds.length`) for the loaded rows'
|
||||||
|
* threads, keyed by thread id: a thread's other messages may live in other
|
||||||
|
* folders. Cleared when the folder or account changes.
|
||||||
|
*/
|
||||||
|
threadCounts: Record<string, number>;
|
||||||
|
|
||||||
// ── Actions ────────────────────────────────────────────────────
|
// ── Actions ────────────────────────────────────────────────────
|
||||||
setActiveAccount: (accountId: string | null) => void;
|
setActiveAccount: (accountId: string | null) => void;
|
||||||
@@ -524,22 +531,6 @@ function viewFromSnapshot(snap: AccountSnapshot | null): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// JMAP's maxObjectsInGet bounds how many ids we can pull in one Email/get.
|
|
||||||
// Chunk to that ceiling (with a small safety fallback) so large change sets
|
|
||||||
// don't trip 429/413 responses.
|
|
||||||
async function fetchEmailsChunked(ids: string[], accountId?: string): Promise<Email[]> {
|
|
||||||
if (ids.length === 0) return [];
|
|
||||||
const cap = Math.max(1, jmapClient.getMaxObjectsInGet());
|
|
||||||
const chunk = Math.min(cap, 200);
|
|
||||||
if (ids.length <= chunk) return fetchEmails(ids, accountId);
|
|
||||||
const out: Email[] = [];
|
|
||||||
for (let i = 0; i < ids.length; i += chunk) {
|
|
||||||
const slice = await fetchEmails(ids.slice(i, i + chunk), accountId);
|
|
||||||
out.push(...slice);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge a fresh batch of emails into an existing list keyed by id. New entries
|
// Merge a fresh batch of emails into an existing list keyed by id. New entries
|
||||||
// replace stale ones (keywords/mailboxIds may have changed); destroyed ids are
|
// replace stale ones (keywords/mailboxIds may have changed); destroyed ids are
|
||||||
// dropped. Order is preserved according to the supplied id order — pass the
|
// dropped. Order is preserved according to the supplied id order — pass the
|
||||||
@@ -583,6 +574,7 @@ export const useEmailStore = create<EmailState>()(
|
|||||||
filters: {},
|
filters: {},
|
||||||
pendingUndo: null,
|
pendingUndo: null,
|
||||||
retainedIds: [],
|
retainedIds: [],
|
||||||
|
threadCounts: {},
|
||||||
|
|
||||||
// Swap which account's data is currently visible. The previous account's
|
// Swap which account's data is currently visible. The previous account's
|
||||||
// view is tucked into accountSnapshots so a return-trip can restore it
|
// view is tucked into accountSnapshots so a return-trip can restore it
|
||||||
@@ -610,6 +602,7 @@ export const useEmailStore = create<EmailState>()(
|
|||||||
filters: {},
|
filters: {},
|
||||||
pendingUndo: null,
|
pendingUndo: null,
|
||||||
retainedIds: [],
|
retainedIds: [],
|
||||||
|
threadCounts: {},
|
||||||
error: null,
|
error: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
});
|
});
|
||||||
@@ -750,6 +743,7 @@ export const useEmailStore = create<EmailState>()(
|
|||||||
error: null,
|
error: null,
|
||||||
pendingUndo: null,
|
pendingUndo: null,
|
||||||
retainedIds: [],
|
retainedIds: [],
|
||||||
|
threadCounts: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Stop here if there's no live session OR jmapClient is mid-transition
|
// Stop here if there's no live session OR jmapClient is mid-transition
|
||||||
@@ -780,25 +774,29 @@ export const useEmailStore = create<EmailState>()(
|
|||||||
const scope = queryScope(state, refFor(state.mailboxes, currentMailboxId));
|
const scope = queryScope(state, refFor(state.mailboxes, currentMailboxId));
|
||||||
const filter = buildJmapFilter(searchQuery, filters);
|
const filter = buildJmapFilter(searchQuery, filters);
|
||||||
const limit = useSettingsStore.getState().emailsPerPage;
|
const limit = useSettingsStore.getState().emailsPerPage;
|
||||||
const { ids, total } = await queryEmails(scope.mailboxId, {
|
const { list, total, threads } = await queryEmailPage(scope.mailboxId, {
|
||||||
position,
|
position,
|
||||||
limit,
|
limit,
|
||||||
sort: await resolveSort(state, scope.accountId),
|
sort: await resolveSort(state, scope.accountId),
|
||||||
filter,
|
filter,
|
||||||
accountId: scope.accountId,
|
accountId: scope.accountId,
|
||||||
|
threads: !useSettingsStore.getState().disableThreading,
|
||||||
});
|
});
|
||||||
if (get().activeAccountId !== activeAccountId || get().currentMailboxId !== currentMailboxId) return;
|
if (get().activeAccountId !== activeAccountId || get().currentMailboxId !== currentMailboxId) return;
|
||||||
// A message that arrived between pages shifts positions and would come
|
// A message that arrived between pages shifts positions and would come
|
||||||
// back a second time — drop ids we already show (duplicate keys).
|
// back a second time — drop ids we already show (duplicate keys).
|
||||||
const existingIds = new Set(get().emails.map((e) => e.id));
|
const existingIds = new Set(get().emails.map((e) => e.id));
|
||||||
const fresh = ids.filter((id) => !existingIds.has(id));
|
const newEmails = list.filter((e) => !existingIds.has(e.id));
|
||||||
const newEmails = fresh.length > 0 ? await fetchEmailsChunked(fresh, scope.accountId) : [];
|
|
||||||
if (get().activeAccountId !== activeAccountId || get().currentMailboxId !== currentMailboxId) return;
|
|
||||||
const merged = [...get().emails, ...newEmails];
|
const merged = [...get().emails, ...newEmails];
|
||||||
// The server's current count: a read in the Unread view has shrunk it
|
// The server's current count: a read in the Unread view has shrunk it
|
||||||
// since the list was loaded, and a stale total keeps load-more asking
|
// since the list was loaded, and a stale total keeps load-more asking
|
||||||
// for a page that isn't there.
|
// for a page that isn't there.
|
||||||
const updates: Partial<EmailState> = { emails: merged, totalEmails: total, loading: false };
|
const updates: Partial<EmailState> = {
|
||||||
|
emails: merged,
|
||||||
|
totalEmails: total,
|
||||||
|
threadCounts: withThreadCounts(get().threadCounts, threads),
|
||||||
|
loading: false,
|
||||||
|
};
|
||||||
if (isBaseView(searchQuery, filters)) {
|
if (isBaseView(searchQuery, filters)) {
|
||||||
updates.mailboxSnapshots = {
|
updates.mailboxSnapshots = {
|
||||||
...get().mailboxSnapshots,
|
...get().mailboxSnapshots,
|
||||||
@@ -1649,6 +1647,7 @@ export const useEmailStore = create<EmailState>()(
|
|||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
filters: {},
|
filters: {},
|
||||||
retainedIds: [],
|
retainedIds: [],
|
||||||
|
threadCounts: {},
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
@@ -1894,6 +1893,34 @@ async function fetchMailboxesImpl(activeAccountId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record Thread/get results as conversation sizes for the list's badges.
|
||||||
|
function withThreadCounts(counts: Record<string, number>, threads: Thread[]): Record<string, number> {
|
||||||
|
if (threads.length === 0) return counts;
|
||||||
|
const next = { ...counts };
|
||||||
|
for (const th of threads) next[th.id] = th.emailIds.length;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conversation sizes for rows no list page brought any for: restored from a
|
||||||
|
// snapshot, or added by Email/queryChanges. Fire-and-forget; the loaded-page
|
||||||
|
// count stands in until they land (and if the request fails).
|
||||||
|
async function fillThreadCounts(emails: Email[], accountId: string | undefined): Promise<void> {
|
||||||
|
if (useSettingsStore.getState().disableThreading) return;
|
||||||
|
const { threadCounts, currentMailboxId, activeAccountId } = useEmailStore.getState();
|
||||||
|
const missing = Array.from(new Set(
|
||||||
|
emails.map((e) => e.threadId).filter((id) => id && threadCounts[id] === undefined),
|
||||||
|
));
|
||||||
|
if (missing.length === 0) return;
|
||||||
|
try {
|
||||||
|
const threads = await getThreads(missing, accountId);
|
||||||
|
const now = useEmailStore.getState();
|
||||||
|
if (now.currentMailboxId !== currentMailboxId || now.activeAccountId !== activeAccountId) return;
|
||||||
|
useEmailStore.setState({ threadCounts: withThreadCounts(now.threadCounts, threads) });
|
||||||
|
} catch {
|
||||||
|
/* keep the loaded-page count */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshEmailsImpl(): Promise<void> {
|
async function refreshEmailsImpl(): Promise<void> {
|
||||||
const get = useEmailStore.getState;
|
const get = useEmailStore.getState;
|
||||||
const set = useEmailStore.setState;
|
const set = useEmailStore.setState;
|
||||||
@@ -2053,6 +2080,7 @@ async function refreshEmailsImpl(): Promise<void> {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
void fillThreadCounts(trimmed, ref.accountId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// queryChanges === null → cannotCalculateChanges. Drop our queryState
|
// queryChanges === null → cannotCalculateChanges. Drop our queryState
|
||||||
@@ -2062,41 +2090,43 @@ async function refreshEmailsImpl(): Promise<void> {
|
|||||||
// Full re-query path. Used when there's no prior queryState, when the
|
// Full re-query path. Used when there's no prior queryState, when the
|
||||||
// user has search/filters active (queryState only tracks the base
|
// user has search/filters active (queryState only tracks the base
|
||||||
// query), or when the server returned cannotCalculateChanges above.
|
// query), or when the server returned cannotCalculateChanges above.
|
||||||
let queryRes: Awaited<ReturnType<typeof queryEmails>>;
|
// One request carries the query, its messages and (for the
|
||||||
|
// conversation badges) their threads.
|
||||||
|
const threads = !useSettingsStore.getState().disableThreading;
|
||||||
|
let queryRes: Awaited<ReturnType<typeof queryEmailPage>>;
|
||||||
try {
|
try {
|
||||||
queryRes = await queryEmails(scope.mailboxId, { limit, sort, filter, accountId: scope.accountId });
|
queryRes = await queryEmailPage(scope.mailboxId, { limit, sort, filter, accountId: scope.accountId, threads });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// The server refused a hasKeyword comparator (unsupportedSort): drop
|
// The server refused a hasKeyword comparator (unsupportedSort): drop
|
||||||
// the keyword levels for this account and re-run with the rest.
|
// the keyword levels for this account and re-run with the rest.
|
||||||
if (!isUnsupportedSort(err)) throw err;
|
if (!isUnsupportedSort(err)) throw err;
|
||||||
markKeywordSortUnsupported(scope.accountId ?? jmapClient.accountId);
|
markKeywordSortUnsupported(scope.accountId ?? jmapClient.accountId);
|
||||||
sort = await resolveSort(state, scope.accountId);
|
sort = await resolveSort(state, scope.accountId);
|
||||||
queryRes = await queryEmails(scope.mailboxId, { limit, sort, filter, accountId: scope.accountId });
|
queryRes = await queryEmailPage(scope.mailboxId, { limit, sort, filter, accountId: scope.accountId, threads });
|
||||||
}
|
}
|
||||||
const fetched = queryRes.ids.length > 0
|
|
||||||
? await getEmailsWithState(queryRes.ids, scope.accountId)
|
|
||||||
: { list: [], state: undefined as string | undefined };
|
|
||||||
|
|
||||||
if (viewChanged()) return;
|
if (viewChanged()) return;
|
||||||
|
|
||||||
const updates: Partial<EmailState> = {
|
const updates: Partial<EmailState> = {
|
||||||
// Rows the user just read/unstarred in this filtered view stay put
|
// Rows the user just read/unstarred in this filtered view stay put
|
||||||
// until the view is re-opened, instead of vanishing under them.
|
// until the view is re-opened, instead of vanishing under them.
|
||||||
emails: baseView ? fetched.list : mergeRetainedRows(get().emails, fetched.list, get().retainedIds),
|
emails: baseView ? queryRes.list : mergeRetainedRows(get().emails, queryRes.list, get().retainedIds),
|
||||||
totalEmails: queryRes.total,
|
totalEmails: queryRes.total,
|
||||||
|
threadCounts: withThreadCounts(get().threadCounts, queryRes.threads),
|
||||||
loading: false,
|
loading: false,
|
||||||
};
|
};
|
||||||
if (baseView) {
|
if (baseView) {
|
||||||
updates.queryState = queryRes.queryState;
|
updates.queryState = queryRes.queryState;
|
||||||
// An empty result ran no Email/get, so it has no state to record:
|
// Email/get reports a state even for an empty result; should a
|
||||||
// keep the list's previous one rather than dropping it.
|
// server leave it out, keep the list's previous one rather than
|
||||||
if (fetched.state) {
|
// dropping it.
|
||||||
updates.emailStates = withEmailState(get().emailStates, currentMailboxId, fetched.state);
|
if (queryRes.state) {
|
||||||
|
updates.emailStates = withEmailState(get().emailStates, currentMailboxId, queryRes.state);
|
||||||
}
|
}
|
||||||
updates.mailboxSnapshots = {
|
updates.mailboxSnapshots = {
|
||||||
...get().mailboxSnapshots,
|
...get().mailboxSnapshots,
|
||||||
[currentMailboxId]: {
|
[currentMailboxId]: {
|
||||||
emails: fetched.list,
|
emails: queryRes.list,
|
||||||
total: queryRes.total,
|
total: queryRes.total,
|
||||||
queryState: queryRes.queryState,
|
queryState: queryRes.queryState,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user