fix: open a message from the cache and fetch only the open cards' bodies

Every open went to the network for the full message, even for a message
the viewer had just shown, and the conversation fetch then downloaded
every member's full body again, the opened one included. The pager's
three panes each started their own detail and thread fetch, so one open
from the Unified Inbox fetched the body twice and the thread twice.

Messages and conversations now live in a shared cache
(`lib/email-detail-cache`), keyed by server, login and JMAP account:

- a held copy renders at once; bodies never change, so it is only
  checked, with a keywords/folders-only `Email/get`, when the account's
  Email state (from push or our own reads) moved on or the list row the
  message was opened from disagrees with it;
- a copy in the offline cache is shown before anything goes out and is
  checked the same way;
- a conversation is listed from its members' headers (`Thread/get` and
  list properties in one request) and bodies are downloaded only for
  the open cards, in one batched `Email/get`;
- concurrent loads of the same message or thread share one request.

The store's network-first `getEmailDetail` is gone; its offline-copy
refresh moved into the cache.
This commit is contained in:
Linus Rath
2026-09-24 20:12:16 +02:00
parent 96c5a3f046
commit 33e303e248
8 changed files with 1082 additions and 158 deletions
@@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
request: vi.fn(),
getMaxObjectsInGet: vi.fn(() => 500),
getMaxObjectsInSet: vi.fn(() => 500),
},
}));
import { jmapClient } from '../jmap-client';
import { getEmailFlags, getFullEmailsWithState, getThreadHeaders } from '../email';
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
});
describe('viewer fetches', () => {
it('lists a conversation from headers only, in thread order, without bodies', async () => {
mockRequest.mockResolvedValue({
methodResponses: [
['Thread/get', { list: [{ id: 't1', emailIds: ['a', 'gone', 'b'] }] }, '0'],
['Email/get', { state: 's9', list: [{ id: 'b', subject: 'Re: hi' }, { id: 'a', subject: 'hi' }] }, '1'],
],
});
const res = await getThreadHeaders('t1', 'group-1');
expect(res.emailIds).toEqual(['a', 'b']);
expect(res.list.map((e) => e.id)).toEqual(['a', 'b']);
expect(res.state).toBe('s9');
const [calls] = mockRequest.mock.calls[0];
expect(calls[0]).toEqual(['Thread/get', { accountId: 'group-1', ids: ['t1'] }, '0']);
const args = calls[1][1];
expect(args.accountId).toBe('group-1');
expect(args['#ids']).toEqual({ resultOf: '0', name: 'Thread/get', path: '/list/*/emailIds' });
expect(args.properties).toContain('preview');
expect(args.properties).toContain('sentAt');
expect(args.properties).not.toContain('bodyValues');
expect(args.fetchHTMLBodyValues).toBeUndefined();
});
it('checks keywords and folders without downloading bodies', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['Email/get', {
state: 's2',
list: [{ id: 'a', keywords: { $seen: true }, mailboxIds: { inbox: true } }],
notFound: ['b'],
}, '0']],
});
const res = await getEmailFlags(['a', 'b']);
expect(res).toEqual({
list: [{ id: 'a', keywords: { $seen: true }, mailboxIds: { inbox: true } }],
notFound: ['b'],
state: 's2',
});
const [[[, args]]] = mockRequest.mock.calls[0];
expect(args).toEqual({ accountId: 'acc-1', ids: ['a', 'b'], properties: ['id', 'keywords', 'mailboxIds'] });
});
it('returns the Email state and the missing ids with the bodies', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['Email/get', { state: 's3', list: [{ id: 'a', bodyValues: {} }], notFound: ['b'] }, '0']],
});
const res = await getFullEmailsWithState(['a', 'b']);
expect(res.list.map((e) => e.id)).toEqual(['a']);
expect(res.notFound).toEqual(['b']);
expect(res.state).toBe('s3');
const [[[, args]]] = mockRequest.mock.calls[0];
expect(args.fetchHTMLBodyValues).toBe(true);
});
});
+86 -4
View File
@@ -752,19 +752,101 @@ export async function getFullEmail(id: string, accountIdOverride?: string): Prom
// Batch variant for offline sync and the thread view. Splits to the server's
// maxObjectsInGet ceiling itself.
export async function getFullEmails(ids: string[], accountIdOverride?: string): Promise<Email[]> {
if (ids.length === 0) return [];
return (await getFullEmailsWithState(ids, accountIdOverride)).list;
}
/** An `Email/get` result: what came back, what the server does not know, and its Email state. */
export interface EmailGetResult<T> {
list: T[];
notFound: string[];
/** The account's Email state the result was read at (RFC 8620 §5.1). */
state?: string;
}
/**
* {@link getFullEmails} with the Email state of the response and the ids the
* server reported missing, for callers that cache what they read.
*/
export async function getFullEmailsWithState(
ids: string[],
accountIdOverride?: string,
): Promise<EmailGetResult<Email>> {
if (ids.length === 0) return { list: [], notFound: [] };
const accountId = accountIdOverride ?? jmapClient.accountId;
const out: Email[] = [];
const out: EmailGetResult<Email> = { list: [], notFound: [] };
for (const slice of batched(ids, maxInGet())) {
const res = await jmapClient.request([
['Email/get', { accountId, ids: slice, ...FULL_BODY_ARGS }, '0'],
]);
out.push(...((requireMethodResult(res, '0', 'Email/get').list as Email[]) ?? []));
const body = requireMethodResult(res, '0', 'Email/get');
out.list.push(...((body.list as Email[]) ?? []));
out.notFound.push(...((body.notFound as string[] | undefined) ?? []));
out.state = body.state as string | undefined;
}
await refetchTruncatedBodyValues(out, accountId);
await refetchTruncatedBodyValues(out.list, accountId);
return out;
}
/** The two mutable properties of an Email (RFC 8621 §4.1); nothing else ever changes. */
export type EmailFlags = Pick<Email, 'id' | 'keywords' | 'mailboxIds'>;
/**
* Only the keywords and folders of messages, to bring a copy read earlier up
* to date without downloading its body again.
*/
export async function getEmailFlags(
ids: string[],
accountIdOverride?: string,
): Promise<EmailGetResult<EmailFlags>> {
if (ids.length === 0) return { list: [], notFound: [] };
const accountId = accountIdOverride ?? jmapClient.accountId;
const out: EmailGetResult<EmailFlags> = { list: [], notFound: [] };
for (const slice of batched(ids, maxInGet())) {
const res = await jmapClient.request([
['Email/get', { accountId, ids: slice, properties: ['id', 'keywords', 'mailboxIds'] }, '0'],
]);
const body = requireMethodResult(res, '0', 'Email/get');
out.list.push(...((body.list as EmailFlags[]) ?? []));
out.notFound.push(...((body.notFound as string[] | undefined) ?? []));
out.state = body.state as string | undefined;
}
return out;
}
// What a collapsed conversation card shows; `sentAt` is the date it prints.
export const THREAD_HEADER_PROPERTIES = [...EMAIL_LIST_PROPERTIES, 'sentAt'];
/**
* The messages of a thread without their bodies, oldest first, in one
* back-referenced Thread/get → Email/get. The viewer lists a conversation from
* these and downloads bodies only for the cards it opens.
*/
export async function getThreadHeaders(
threadId: string,
accountIdOverride?: string,
): Promise<{ emailIds: string[]; list: Email[]; state?: string }> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const res = await jmapClient.request([
['Thread/get', { accountId, ids: [threadId] }, '0'],
['Email/get', {
accountId,
'#ids': { resultOf: '0', name: 'Thread/get', path: '/list/*/emailIds' },
properties: THREAD_HEADER_PROPERTIES,
}, '1'],
]);
const thread = (requireMethodResult(res, '0', 'Thread/get').list as Thread[] | undefined)?.[0];
if (!thread) throw new Error(`Thread ${threadId} not found`);
const body = requireMethodResult(res, '1', 'Email/get');
const byId = new Map(((body.list as Email[]) ?? []).map((e) => [e.id, e]));
// Members the server did not return (no access to them) are left out.
const emailIds = thread.emailIds.filter((id) => byId.has(id));
return {
emailIds,
list: emailIds.map((id) => byId.get(id)!),
state: body.state as string | undefined,
};
}
/**
* Import an already-uploaded raw MIME message (a `.eml` blob) into a mailbox
* via JMAP `Email/import`. Returns the new email id. Mirrors the webmail's
+5 -2
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { View, Text, StyleSheet, Pressable, ActivityIndicator } from 'react-native';
import { Paperclip, Reply, ReplyAll, Forward, Star } from 'lucide-react-native';
import type { Email } from '../../api/types';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
@@ -13,6 +13,8 @@ import { singleLine } from '../../lib/single-line';
interface Props extends MessageContentProps {
expanded: boolean;
/** Opened, with its body still on the way: the summary row with a spinner. */
loading?: boolean;
onToggleExpanded: () => void;
onReply: (mode: 'reply' | 'replyAll' | 'forward', email: Email) => void;
}
@@ -22,7 +24,7 @@ interface Props extends MessageContentProps {
* preview) that expands into the full message with its own reply / forward
* actions - the webmail's thread-conversation-view cards.
*/
export function ThreadMessageCard({ expanded, onToggleExpanded, onReply, ...content }: Props) {
export function ThreadMessageCard({ expanded, loading, onToggleExpanded, onReply, ...content }: Props) {
const { email, onToggleStar } = content;
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -48,6 +50,7 @@ export function ThreadMessageCard({ expanded, onToggleExpanded, onReply, ...cont
</View>
<Text style={styles.collapsedPreview} numberOfLines={1}>{singleLine(email.preview)}</Text>
</View>
{loading && <ActivityIndicator size="small" color={c.textMuted} />}
{starred && <Star size={14} color={c.starred} fill={c.starred} />}
</Pressable>
);
@@ -0,0 +1,301 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../api/jmap-client', () => ({
jmapClient: { accountId: 'own', serverUrl: 'https://mail.example', username: 'me' },
}));
vi.mock('../../api/email', () => ({
getFullEmailsWithState: vi.fn(),
getEmailFlags: vi.fn(),
getThreadHeaders: vi.fn(),
}));
import type { Email } from '../../api/types';
import { getEmailFlags, getFullEmailsWithState, getThreadHeaders } from '../../api/email';
import { useOfflineCacheStore } from '../../stores/offline-cache-store';
import { dispatchStateChange } from '../state-change-bus';
import {
clearEmailDetailCache, loadDetail, loadDetails, loadThread, needsRevalidation, noteEmailState,
patchDetail, peekDetail, peekThread, prefetchMessage, subscribeEmailCache,
} from '../email-detail-cache';
const fullGet = getFullEmailsWithState as ReturnType<typeof vi.fn>;
const flagsGet = getEmailFlags as ReturnType<typeof vi.fn>;
const threadGet = getThreadHeaders as ReturnType<typeof vi.fn>;
function full(id: string, keywords: Record<string, boolean> = {}): Email {
return {
id,
threadId: `t-${id}`,
mailboxIds: { inbox: true },
keywords,
size: 10,
receivedAt: '2026-09-01T10:00:00Z',
hasAttachment: false,
htmlBody: [{ partId: '1', type: 'text/html' }],
textBody: [],
bodyValues: { 1: { value: `<p>${id}</p>` } },
} as unknown as Email;
}
function fullResponse(emails: Email[], state = 's1', notFound: string[] = []) {
return { list: emails, notFound, state };
}
const flush = () => new Promise((r) => setTimeout(r, 0));
beforeEach(async () => {
vi.clearAllMocks();
clearEmailDetailCache();
await useOfflineCacheStore.getState().setAccount(null);
await useOfflineCacheStore.getState().setAccount('login-1');
await useOfflineCacheStore.getState().clearAll();
});
describe('needsRevalidation', () => {
const copy = { keywords: { $seen: true }, mailboxIds: { inbox: true } };
it('trusts a copy read at the current Email state', () => {
expect(needsRevalidation(copy, 's1', 's1')).toBe(false);
});
it('checks a copy when the state moved on or is unknown', () => {
expect(needsRevalidation(copy, 's1', 's2')).toBe(true);
expect(needsRevalidation(copy, 's1', undefined)).toBe(true);
expect(needsRevalidation(copy, undefined, 's1')).toBe(true);
});
it('checks a copy the list row disagrees with', () => {
expect(needsRevalidation(copy, 's1', 's1', { keywords: {} })).toBe(true);
expect(needsRevalidation(copy, 's1', 's1', { mailboxIds: { archive: true } })).toBe(true);
// False-valued keywords are the same as absent ones.
expect(needsRevalidation(copy, 's1', 's1', { keywords: { $seen: true, $flagged: false } })).toBe(false);
});
});
describe('loading a message', () => {
it('downloads a message it does not hold, once for concurrent loads', async () => {
fullGet.mockResolvedValue(fullResponse([full('e1')]));
const [a, b] = await Promise.all([loadDetail('e1'), loadDetail('e1')]);
expect(fullGet).toHaveBeenCalledTimes(1);
expect(fullGet).toHaveBeenCalledWith(['e1'], undefined);
expect(a).toBe(b);
expect(peekDetail('e1')).toBe(a);
});
it('serves a copy read at the current state without asking the server', async () => {
fullGet.mockResolvedValue(fullResponse([full('e1')], 's1'));
const first = await loadDetail('e1');
const again = await loadDetail('e1', undefined, { keywords: {}, mailboxIds: { inbox: true } });
expect(again).toBe(first);
expect(fullGet).toHaveBeenCalledTimes(1);
expect(flagsGet).not.toHaveBeenCalled();
});
it('only rechecks keywords and folders once the account state moved on', async () => {
fullGet.mockResolvedValue(fullResponse([full('e1')], 's1'));
const first = await loadDetail('e1');
dispatchStateChange({ '@type': 'StateChange', changed: { own: { Email: 's2' } } } as never);
flagsGet.mockResolvedValue({ list: [{ id: 'e1', keywords: { $seen: true }, mailboxIds: { inbox: true } }], notFound: [], state: 's2' });
const checked = await loadDetail('e1');
expect(fullGet).toHaveBeenCalledTimes(1);
expect(flagsGet).toHaveBeenCalledWith(['e1'], undefined);
expect(checked.keywords).toEqual({ $seen: true });
expect(checked.bodyValues).toBe(first.bodyValues);
// Now current again: no further request.
await loadDetail('e1');
expect(flagsGet).toHaveBeenCalledTimes(1);
});
it('keeps the same object when the recheck finds nothing new', async () => {
fullGet.mockResolvedValue(fullResponse([full('e1', { $seen: true })], 's1'));
const first = await loadDetail('e1');
noteEmailState(undefined, 's2');
flagsGet.mockResolvedValue({ list: [{ id: 'e1', keywords: { $seen: true }, mailboxIds: { inbox: true } }], notFound: [], state: 's2' });
expect(await loadDetail('e1')).toBe(first);
});
it('rechecks when the list row disagrees even at the same state', async () => {
fullGet.mockResolvedValue(fullResponse([full('e1', { $seen: true })], 's1'));
await loadDetail('e1');
// Marked unread from the list before the push for it arrived.
flagsGet.mockResolvedValue({ list: [{ id: 'e1', keywords: {}, mailboxIds: { inbox: true } }], notFound: [], state: 's2' });
const checked = await loadDetail('e1', undefined, { keywords: {} });
expect(flagsGet).toHaveBeenCalledTimes(1);
expect(checked.keywords).toEqual({});
});
it('shows the held copy when the recheck fails', async () => {
fullGet.mockResolvedValue(fullResponse([full('e1')], 's1'));
const first = await loadDetail('e1');
noteEmailState(undefined, 's2');
flagsGet.mockRejectedValue(new Error('offline'));
expect(await loadDetail('e1')).toBe(first);
});
it('drops a held copy the server says is gone', async () => {
fullGet.mockResolvedValue(fullResponse([full('e1')], 's1'));
await loadDetail('e1');
noteEmailState(undefined, 's2');
flagsGet.mockResolvedValue({ list: [], notFound: ['e1'], state: 's2' });
await expect(loadDetail('e1')).rejects.toThrow('not found');
expect(peekDetail('e1')).toBeUndefined();
});
it('rejects with the fetch error when nothing is held', async () => {
fullGet.mockRejectedValue(new Error('boom'));
await expect(loadDetail('e1')).rejects.toThrow('boom');
fullGet.mockResolvedValue(fullResponse([], 's1', ['e2']));
await expect(loadDetail('e2')).rejects.toThrow('not found');
});
it('shows the offline copy first and then only rechecks it', async () => {
const offline = full('e1');
await useOfflineCacheStore.getState().put(offline, 100);
let release!: () => void;
flagsGet.mockReturnValue(new Promise((resolve) => {
release = () => resolve({ list: [{ id: 'e1', keywords: { $flagged: true }, mailboxIds: { inbox: true } }], notFound: [], state: 's3' });
}));
const pending = loadDetail('e1');
await flush();
expect(peekDetail('e1')?.id).toBe('e1');
release();
const checked = await pending;
expect(fullGet).not.toHaveBeenCalled();
expect(checked.keywords).toEqual({ $flagged: true });
expect((await useOfflineCacheStore.getState().get('e1'))?.keywords).toEqual({ $flagged: true });
});
it('batches what it holds and what it lacks into one request each', async () => {
fullGet.mockResolvedValueOnce(fullResponse([full('a'), full('b')], 's1'));
await loadDetails(['a', 'b']);
noteEmailState(undefined, 's2');
// `a` is current again after this recheck; `b` too; `c` and `d` are new.
flagsGet.mockResolvedValue({
list: [{ id: 'a', keywords: {}, mailboxIds: { inbox: true } }, { id: 'b', keywords: {}, mailboxIds: { inbox: true } }],
notFound: [],
state: 's2',
});
fullGet.mockResolvedValueOnce(fullResponse([full('c'), full('d')], 's2'));
const got = await loadDetails(['a', 'b', 'c', 'd', 'c']);
expect([...got.keys()].sort()).toEqual(['a', 'b', 'c', 'd']);
expect(flagsGet).toHaveBeenCalledTimes(1);
expect(flagsGet).toHaveBeenCalledWith(['a', 'b'], undefined);
expect(fullGet).toHaveBeenCalledTimes(2);
expect(fullGet).toHaveBeenLastCalledWith(['c', 'd'], undefined);
});
it('keeps messages of different accounts apart', async () => {
fullGet.mockImplementation(async (ids: string[], accountId?: string) =>
fullResponse(ids.map((id) => ({ ...full(id), subject: accountId ?? 'own' })), 's1'));
await loadDetail('e1');
await loadDetail('e1', 'group');
expect(fullGet).toHaveBeenCalledTimes(2);
expect(peekDetail('e1')?.subject).toBe('own');
expect(peekDetail('e1', 'group')?.subject).toBe('group');
// Naming the own account explicitly is the same account.
expect(peekDetail('e1', 'own')?.subject).toBe('own');
});
it('tells subscribers when a copy arrives or changes', async () => {
const listener = vi.fn();
const unsubscribe = subscribeEmailCache(listener);
fullGet.mockResolvedValue(fullResponse([full('e1')], 's1'));
await loadDetail('e1');
expect(listener).toHaveBeenCalled();
listener.mockClear();
patchDetail('e1', undefined, { keywords: { $flagged: true } });
expect(listener).toHaveBeenCalledTimes(1);
expect(peekDetail('e1')?.keywords).toEqual({ $flagged: true });
unsubscribe();
});
});
describe('loading a conversation', () => {
const headers = (ids: string[]) => ids.map((id) => ({ id, threadId: 't1', keywords: {}, mailboxIds: {} }));
it('fetches member headers once for concurrent loads and keeps them', async () => {
threadGet.mockResolvedValue({ emailIds: ['m1', 'm2'], list: headers(['m1', 'm2']), state: 's1' });
const [a, b] = await Promise.all([loadThread('t1'), loadThread('t1')]);
expect(threadGet).toHaveBeenCalledTimes(1);
expect(a).toBe(b);
expect(peekThread('t1')?.ids).toEqual(['m1', 'm2']);
// Current state: served from memory.
await loadThread('t1');
expect(threadGet).toHaveBeenCalledTimes(1);
});
it('refetches a conversation once the state moved on, keeping it if that fails', async () => {
threadGet.mockResolvedValueOnce({ emailIds: ['m1'], list: headers(['m1']), state: 's1' });
await loadThread('t1');
noteEmailState(undefined, 's2');
threadGet.mockRejectedValueOnce(new Error('offline'));
const view = await loadThread('t1');
expect(threadGet).toHaveBeenCalledTimes(2);
expect(view.ids).toEqual(['m1']);
});
it('brings a member copy held from an earlier open up to date with the headers', async () => {
fullGet.mockResolvedValue(fullResponse([full('m1', { $seen: true })], 's1'));
const held = await loadDetail('m1');
threadGet.mockResolvedValue({
emailIds: ['m1'],
list: [{ id: 'm1', threadId: 't1', keywords: {}, mailboxIds: { inbox: true } }],
state: 's2',
});
await loadThread('t1');
expect(peekDetail('m1')?.keywords).toEqual({});
expect(peekDetail('m1')?.bodyValues).toBe(held.bodyValues);
});
it('applies local keyword changes to the conversation headers too', async () => {
threadGet.mockResolvedValue({ emailIds: ['m1'], list: headers(['m1']), state: 's1' });
await loadThread('t1');
patchDetail('m1', undefined, { keywords: { $seen: true } });
expect(peekThread('t1')?.headers.get('m1')?.keywords).toEqual({ $seen: true });
});
});
describe('prefetchMessage', () => {
it('starts the body and the conversation so the viewer joins them', async () => {
fullGet.mockResolvedValue(fullResponse([full('e1')], 's1'));
threadGet.mockResolvedValue({ emailIds: ['e1'], list: headers1(), state: 's1' });
prefetchMessage({ id: 'e1', threadId: 't-e1', keywords: {}, mailboxIds: { inbox: true } });
await Promise.all([loadDetail('e1'), loadThread('t-e1')]);
expect(fullGet).toHaveBeenCalledTimes(1);
expect(threadGet).toHaveBeenCalledTimes(1);
});
function headers1() {
return [{ id: 'e1', threadId: 't-e1', keywords: {}, mailboxIds: {} }];
}
});
+467
View File
@@ -0,0 +1,467 @@
// The messages the viewer has read, kept across viewer instances so opening a
// message again (or swiping back to it) paints its body at once instead of
// waiting for the network.
//
// A body never changes once a message exists (RFC 8621 §4.1): only its
// keywords and mailboxes do. So a copy held here is shown straight away and
// revalidated in the background, and only when something may have changed:
// the account's Email state moved on since the copy was read, or the list row
// the viewer was opened from disagrees with it. Revalidating asks for just the
// keywords and mailboxes. Concurrent loads of the same message or thread share
// one request, which is what stops the pager's panes and a tap's prefetch from
// fetching the same body twice.
import { AppState } from 'react-native';
import type { Email } from '../api/types';
import { jmapClient } from '../api/jmap-client';
import { getEmailFlags, getFullEmailsWithState, getThreadHeaders } from '../api/email';
import { useOfflineCacheStore } from '../stores/offline-cache-store';
import { useSettingsStore } from '../stores/settings-store';
import { onStateChangeType } from './state-change-bus';
/** What the list row the viewer was opened from says about the message. */
export type FlagsHint = Partial<Pick<Email, 'keywords' | 'mailboxIds'>>;
interface DetailEntry {
email: Email;
/** The account's Email state this copy was read or last checked at. */
state?: string;
/** Rough size of the bodies, for the memory cap. */
chars: number;
}
interface ThreadEntry {
/** Member ids, oldest first. */
ids: string[];
/** Members without bodies (list properties). */
headers: Map<string, Email>;
state?: string;
}
export interface ThreadView {
ids: string[];
headers: ReadonlyMap<string, Email>;
}
// Large newsletters run to hundreds of KB, so the cap is by size as well as count.
const MAX_DETAILS = 40;
const MAX_DETAIL_CHARS = 8_000_000;
const MAX_THREADS = 60;
const details = new Map<string, DetailEntry>();
const threads = new Map<string, ThreadEntry>();
const pendingDetails = new Map<string, Promise<Email>>();
const pendingThreads = new Map<string, Promise<ThreadView>>();
// The newest Email state heard of per account, from push and our own reads.
const latestStates = new Map<string, string>();
const listeners = new Set<() => void>();
let detailChars = 0;
let wired = false;
// JMAP ids are only unique per account (RFC 8621 §1.3), and two signed-in
// servers can hand out the same account ids, so the key names both.
function accountKey(accountId: string | undefined): string {
let own = '';
try { own = jmapClient.accountId; } catch { /* not connected */ }
return `${jmapClient.serverUrl ?? ''}|${jmapClient.username ?? ''}|${accountId ?? own}`;
}
function keyOf(id: string, accountId: string | undefined): string {
return `${accountKey(accountId)}|${id}`;
}
function bodyChars(email: Email): number {
let n = 2_000;
for (const v of Object.values(email.bodyValues ?? {})) n += v?.value?.length ?? 0;
return n;
}
function emit(): void {
for (const l of listeners) {
try { l(); } catch { /* a broken listener must not break the others */ }
}
}
// The Email state is only worth trusting while push is delivering changes:
// events that happen while the app is in the background are lost.
function wire(): void {
if (wired) return;
wired = true;
onStateChangeType('Email', (accountId, state) => {
latestStates.set(accountKey(accountId), state);
});
try {
AppState.addEventListener('change', (next) => {
if (next !== 'active') latestStates.clear();
});
} catch { /* no AppState (tests) */ }
}
/** Record the account's current Email state (push event or a response). */
export function noteEmailState(accountId: string | undefined, state: string | undefined): void {
if (state) latestStates.set(accountKey(accountId), state);
}
function sameFlags(a: Record<string, boolean> | undefined, b: Record<string, boolean> | undefined): boolean {
const ak = Object.keys(a ?? {}).filter((k) => a![k]);
const bk = Object.keys(b ?? {}).filter((k) => b![k]);
return ak.length === bk.length && ak.every((k) => !!b?.[k]);
}
/**
* Whether a copy read at `copyState` has to be checked against the server
* before it is trusted: the account's Email state is unknown or has moved on,
* or the list row the message was opened from disagrees with the copy.
*/
export function needsRevalidation(
copy: Pick<Email, 'keywords' | 'mailboxIds'>,
copyState: string | undefined,
latestState: string | undefined,
hint?: FlagsHint,
): boolean {
if (!copyState || !latestState || copyState !== latestState) return true;
if (hint?.keywords && !sameFlags(hint.keywords, copy.keywords)) return true;
if (hint?.mailboxIds && !sameFlags(hint.mailboxIds, copy.mailboxIds)) return true;
return false;
}
function putDetail(key: string, entry: DetailEntry): void {
const prev = details.get(key);
if (prev) {
detailChars -= prev.chars;
details.delete(key);
}
details.set(key, entry);
detailChars += entry.chars;
// Evict least recently used, never the entry just stored.
for (const [k, e] of details) {
if (details.size <= MAX_DETAILS && detailChars <= MAX_DETAIL_CHARS) break;
if (k === key) continue;
details.delete(k);
detailChars -= e.chars;
}
}
function touchDetail(key: string, entry: DetailEntry): void {
details.delete(key);
details.set(key, entry);
}
function putThread(key: string, entry: ThreadEntry): void {
threads.delete(key);
threads.set(key, entry);
while (threads.size > MAX_THREADS) {
const oldest = threads.keys().next().value as string;
threads.delete(oldest);
}
}
/** The copy held for a message, if any. Synchronous: for rendering. */
export function peekDetail(id: string, accountId?: string): Email | undefined {
return details.get(keyOf(id, accountId))?.email;
}
/** The conversation held for a thread, if any. */
export function peekThread(threadId: string, accountId?: string): ThreadView | undefined {
return threads.get(keyOf(threadId, accountId));
}
/** Re-render on every change to what is held here. */
export function subscribeEmailCache(listener: () => void): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
/**
* Apply a local change (star, read, tag, `$answered`…) to the held copy and to
* any conversation that lists the message, so the next open shows it.
*/
export function patchDetail(id: string, accountId: string | undefined, patch: FlagsHint): void {
const key = keyOf(id, accountId);
let changed = false;
const entry = details.get(key);
if (entry) {
details.set(key, { ...entry, email: { ...entry.email, ...patch } });
changed = true;
}
const prefix = accountKey(accountId);
for (const [k, thread] of threads) {
if (!k.startsWith(`${prefix}|`)) continue;
const header = thread.headers.get(id);
if (!header) continue;
const headers = new Map(thread.headers);
headers.set(id, { ...header, ...patch });
threads.set(k, { ...thread, headers });
changed = true;
}
if (changed) emit();
}
/** Drop everything (tests, sign-out). */
export function clearEmailDetailCache(): void {
details.clear();
threads.clear();
pendingDetails.clear();
pendingThreads.clear();
latestStates.clear();
detailChars = 0;
}
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (err: unknown) => void;
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (err: unknown) => void;
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
// Callers may drop the promise (prefetch); an unobserved rejection is noise.
promise.catch(() => undefined);
return { promise, resolve, reject };
}
// Keep the offline copy's keywords/folders current: the next offline open
// should not show a star or a folder from weeks ago.
function refreshOfflineCopy(email: Email, accountId: string | undefined): void {
const offline = useOfflineCacheStore.getState();
if (!offline.has(email.id, accountId)) return;
try {
void offline.put(email, JSON.stringify(email).length, accountId).catch(() => undefined);
} catch { /* best effort */ }
}
async function fetchBodies(
ids: string[],
accountId: string | undefined,
waiting: Map<string, Deferred<Email>>,
): Promise<void> {
if (ids.length === 0) return;
try {
const res = await getFullEmailsWithState(ids, accountId);
noteEmailState(accountId, res.state);
const found = new Map<string, Email>();
for (const email of res.list) {
found.set(email.id, email);
putDetail(keyOf(email.id, accountId), { email, state: res.state, chars: bodyChars(email) });
refreshOfflineCopy(email, accountId);
}
emit();
for (const id of ids) {
const email = found.get(id);
if (email) waiting.get(id)?.resolve(email);
else waiting.get(id)?.reject(new Error(`Email ${id} not found`));
}
} catch (err) {
for (const id of ids) waiting.get(id)?.reject(err);
}
}
async function revalidate(
ids: string[],
accountId: string | undefined,
waiting: Map<string, Deferred<Email>>,
): Promise<void> {
if (ids.length === 0) return;
let gone = new Set<string>();
try {
const res = await getEmailFlags(ids, accountId);
noteEmailState(accountId, res.state);
gone = new Set(res.notFound);
let changed = false;
for (const flags of res.list) {
const key = keyOf(flags.id, accountId);
const entry = details.get(key);
if (!entry) continue;
const same = sameFlags(flags.keywords, entry.email.keywords)
&& sameFlags(flags.mailboxIds, entry.email.mailboxIds);
// Keep the object when nothing changed, so nothing re-renders.
const email = same ? entry.email : { ...entry.email, keywords: flags.keywords, mailboxIds: flags.mailboxIds };
details.set(key, { ...entry, email, state: res.state });
if (!same) {
changed = true;
refreshOfflineCopy(email, accountId);
}
}
// Destroyed since it was read: not something to show as current.
for (const id of gone) {
const key = keyOf(id, accountId);
const entry = details.get(key);
if (!entry) continue;
details.delete(key);
detailChars -= entry.chars;
changed = true;
}
if (changed) emit();
} catch {
// Offline or refused: the copy held is still the best there is.
}
for (const id of ids) {
const email = peekDetail(id, accountId);
if (email) waiting.get(id)?.resolve(email);
else waiting.get(id)?.reject(new Error(`Email ${id} not found`));
}
}
/**
* Start loading messages, cache first, and return a promise per id:
* - a held copy that is still current resolves at once;
* - one that may be stale is shown and checked with a single
* keywords/folders request for all of them;
* - a copy in the offline cache is shown, then checked the same way;
* - only the rest are downloaded, in one `Email/get`.
* A load already running for an id is joined instead of repeated.
*/
function startLoads(
ids: string[],
accountId: string | undefined,
hints?: Record<string, FlagsHint | undefined>,
): Map<string, Promise<Email>> {
wire();
const latest = latestStates.get(accountKey(accountId));
const offline = useOfflineCacheStore.getState();
const results = new Map<string, Promise<Email>>();
const waiting = new Map<string, Deferred<Email>>();
const stale: string[] = [];
const fromOffline: string[] = [];
const missing: string[] = [];
for (const id of new Set(ids)) {
const key = keyOf(id, accountId);
const pending = pendingDetails.get(key);
if (pending) {
results.set(id, pending);
continue;
}
const entry = details.get(key);
if (entry) {
touchDetail(key, entry);
if (!needsRevalidation(entry.email, entry.state, latest, hints?.[id])) {
results.set(id, Promise.resolve(entry.email));
continue;
}
}
const d = deferred<Email>();
waiting.set(id, d);
results.set(id, d.promise);
pendingDetails.set(key, d.promise);
const done = () => { if (pendingDetails.get(key) === d.promise) pendingDetails.delete(key); };
d.promise.then(done, done);
if (entry) stale.push(id);
else if (offline.has(id, accountId)) fromOffline.push(id);
else missing.push(id);
}
if (waiting.size === 0) return results;
void (async () => {
// An offline copy is shown right away, then checked like a held one.
if (fromOffline.length > 0) {
const copies = await Promise.all(fromOffline.map((id) => offline.get(id, accountId).catch(() => null)));
let shown = false;
fromOffline.forEach((id, i) => {
const email = copies[i];
if (!email) {
missing.push(id);
return;
}
putDetail(keyOf(id, accountId), { email, chars: bodyChars(email) });
stale.push(id);
shown = true;
});
if (shown) emit();
}
await Promise.all([
fetchBodies(missing, accountId, waiting),
revalidate(stale, accountId, waiting),
]);
})();
return results;
}
/** Load several messages (see {@link startLoads}); ones that failed are left out. */
export async function loadDetails(
ids: string[],
accountId?: string,
hints?: Record<string, FlagsHint | undefined>,
): Promise<Map<string, Email>> {
const out = new Map<string, Email>();
await Promise.all(Array.from(startLoads(ids, accountId, hints), async ([id, p]) => {
try { out.set(id, await p); } catch { /* left out */ }
}));
return out;
}
/**
* Load one message (see {@link startLoads}). Resolves with the checked copy,
* which is what decisions like mark-as-read must be based on; rejects when
* the message cannot be loaded.
*/
export function loadDetail(id: string, accountId?: string, hint?: FlagsHint): Promise<Email> {
return startLoads([id], accountId, hint ? { [id]: hint } : undefined).get(id)!;
}
/**
* A conversation's members without bodies, cache first. A held conversation
* resolves at once while the account's Email state says it is current; it is
* otherwise refetched (one request) and, if that fails, still returned.
*/
export function loadThread(threadId: string, accountId?: string): Promise<ThreadView> {
wire();
const key = keyOf(threadId, accountId);
const held = threads.get(key);
const latest = latestStates.get(accountKey(accountId));
if (held && held.state && held.state === latest) return Promise.resolve(held);
const pending = pendingThreads.get(key);
if (pending) return pending;
const p = getThreadHeaders(threadId, accountId)
.then((res) => {
noteEmailState(accountId, res.state);
const entry: ThreadEntry = {
ids: res.emailIds,
headers: new Map(res.list.map((e) => [e.id, e])),
state: res.state,
};
putThread(key, entry);
// The headers were just read: a member's copy held from an earlier
// open takes their keywords and folders, which may have changed since.
for (const header of res.list) {
const memberKey = keyOf(header.id, accountId);
const held = details.get(memberKey);
if (!held) continue;
if (sameFlags(header.keywords, held.email.keywords) && sameFlags(header.mailboxIds, held.email.mailboxIds)) {
continue;
}
details.set(memberKey, {
...held,
email: { ...held.email, keywords: header.keywords, mailboxIds: header.mailboxIds },
state: res.state,
});
}
emit();
return entry as ThreadView;
})
.catch((err) => {
if (held) return held as ThreadView;
throw err;
})
.finally(() => { pendingThreads.delete(key); });
pendingThreads.set(key, p);
return p;
}
/**
* Start loading a message the user is about to open (the list row was
* tapped), and its conversation when threading is on, so the viewer finds the
* requests in flight or done.
*/
export function prefetchMessage(
email: Pick<Email, 'id' | 'threadId'> & FlagsHint,
accountId?: string,
): void {
void loadDetail(email.id, accountId, { keywords: email.keywords, mailboxIds: email.mailboxIds })
.catch(() => undefined);
if (email.threadId && !useSettingsStore.getState().disableThreading) {
void loadThread(email.threadId, accountId).catch(() => undefined);
}
}
+140 -109
View File
@@ -28,7 +28,11 @@ import {
REPLY_QUICK_ACTIONS,
type QuickAction,
} from '../stores/settings-store';
import { patchKeywordsForEmails, getThreadEmails } from '../api/email';
import { patchKeywordsForEmails } from '../api/email';
import {
loadDetail, loadDetails, loadThread, patchDetail, peekDetail, peekThread, subscribeEmailCache,
type FlagsHint,
} from '../lib/email-detail-cache';
import { shareEmailEml } from '../lib/email-export';
import { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store';
import { useSheetDrag } from '../lib/use-sheet-drag';
@@ -66,7 +70,6 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
const [activeEmailId, setActiveEmailId] = React.useState(route.params.emailId);
const insets = useSafeAreaInsets();
const { width: windowWidth } = useWindowDimensions();
const getEmailDetail = useEmailStore((s) => s.getEmailDetail);
const markRead = useEmailStore((s) => s.markRead);
const setKeywordForEmails = useEmailStore((s) => s.setKeywordForEmails);
const deleteEmail = useEmailStore((s) => s.deleteEmail);
@@ -126,21 +129,25 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
// Per-message override of the light/dark rendering (More sheet toggle).
const [themeOverrides, setThemeOverrides] = React.useState<Record<string, 'light' | 'dark'>>({});
// In-memory cache of fetched message details keyed by id. This is the single
// source of truth for every rendered pane: the active message *and* its
// prefetched neighbours all read their detail from here, so a swipe lands on
// ready content with no spinner and no late content-swap. `cacheVersion` is
// bumped whenever an entry changes so the panes re-render.
const detailCache = React.useRef(new Map<string, Email>()).current;
// Thread id -> message ids (oldest first), once the conversation was fetched.
const threadCache = React.useRef(new Map<string, string[]>()).current;
// Message details and conversations come from the shared cache
// (lib/email-detail-cache): every rendered pane, the active one and its
// neighbours, reads from it, so a swipe lands on ready content and a message
// opened before paints at once. `cacheVersion` is bumped on every change to
// it so the panes re-render.
const [cacheVersion, setCacheVersion] = React.useState(0);
const bumpCache = React.useCallback(() => setCacheVersion((v) => v + 1), []);
const email = React.useMemo(
() => detailCache.get(activeEmailId) ?? null,
// eslint-disable-next-line react-hooks/exhaustive-deps
[activeEmailId, cacheVersion, detailCache],
React.useEffect(() => subscribeEmailCache(bumpCache), [bumpCache]);
const detailOf = React.useCallback(
(id: string) => peekDetail(id, ownerAccountId),
[ownerAccountId],
);
const email = React.useMemo(
() => detailOf(activeEmailId) ?? null,
// eslint-disable-next-line react-hooks/exhaustive-deps
[activeEmailId, cacheVersion, detailOf],
);
// Conversations whose fetch failed with nothing held: shown as one message.
const failedThreads = React.useRef(new Set<string>()).current;
// --- Pager -------------------------------------------------------------
// A horizontal, page-snapping FlatList over `emails`. Native scroll provides
@@ -152,49 +159,50 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
Math.max(0, emails.findIndex((e) => e.id === route.params.emailId)),
);
// Coalesced detail fetch: stores the result in the shared cache and bumps the
// version so every mounted pane (and the toolbar) re-reads it. Concurrent
// calls for the same id share a single in-flight request.
const inFlight = React.useRef(new Map<string, Promise<Email | null>>()).current;
const ensureDetail = React.useCallback((id: string): Promise<Email | null> => {
const pending = inFlight.get(id);
if (pending) return pending;
const p = getEmailDetail(id, ownerAccountId)
.then((fetched) => { detailCache.set(id, fetched); bumpCache(); return fetched; })
.catch(() => detailCache.get(id) ?? null)
.finally(() => { inFlight.delete(id); });
inFlight.set(id, p);
return p;
}, [getEmailDetail, ownerAccountId, detailCache, bumpCache, inFlight]);
// What each page's list row said about its message the first time it is
// loaded here: a copy held from an earlier open that disagrees with it (the
// user marked it unread in the list a moment ago) is checked first. Later
// loads go by the cache, which has the viewer's own changes.
const hintedIds = React.useRef(new Set<string>()).current;
const hintFor = React.useCallback((id: string): FlagsHint | undefined => {
if (hintedIds.has(id)) return undefined;
hintedIds.add(id);
const row = emails.find((e) => e.id === id);
return row?.keywords ? { keywords: row.keywords, mailboxIds: row.mailboxIds } : undefined;
}, [emails, hintedIds]);
// Whole conversation (Thread/get -> Email/get), cached per thread id. Every
// message lands in the detail cache so the cards render straight from it.
const threadInFlight = React.useRef(new Map<string, Promise<string[]>>()).current;
const ensureThread = React.useCallback((threadId: string): Promise<string[]> => {
const cached = threadCache.get(threadId);
if (cached) return Promise.resolve(cached);
const pending = threadInFlight.get(threadId);
if (pending) return pending;
const p = getThreadEmails(threadId, ownerAccountId)
.then((list) => {
for (const e of list) detailCache.set(e.id, e);
const ids = list.map((e) => e.id);
threadCache.set(threadId, ids);
bumpCache();
return ids;
})
.catch((err) => {
console.warn('[thread] fetch failed', err);
// Fall back to the single message so the pane still renders.
const single = detailCache.has(activeEmailId) ? [activeEmailId] : [];
threadCache.set(threadId, single);
bumpCache();
return single;
})
.finally(() => { threadInFlight.delete(threadId); });
threadInFlight.set(threadId, p);
return p;
}, [ownerAccountId, detailCache, threadCache, bumpCache, threadInFlight, activeEmailId]);
// Cache first: a held copy renders at once and the promise resolves with the
// checked one (which mark-as-read goes by). Concurrent loads share a request.
const ensureDetail = React.useCallback(
(id: string): Promise<Email | null> =>
loadDetail(id, ownerAccountId, hintFor(id)).catch(() => peekDetail(id, ownerAccountId) ?? null),
[ownerAccountId, hintFor],
);
const ensureDetails = React.useCallback((ids: string[]) => {
const missing = ids.filter((id) => !peekDetail(id, ownerAccountId));
if (missing.length > 0) void loadDetails(missing, ownerAccountId);
}, [ownerAccountId]);
// The conversation's members without bodies; the cards fetch bodies only
// when opened (ensureDetails).
const ensureThread = React.useCallback((threadId: string): void => {
void loadThread(threadId, ownerAccountId).catch((err) => {
console.warn('[thread] fetch failed', err);
// Show the single message instead of a spinner that never ends.
failedThreads.add(threadId);
bumpCache();
});
}, [ownerAccountId, failedThreads, bumpCache]);
const threadIdsOf = React.useCallback((threadId: string): string[] | null => {
const view = peekThread(threadId, ownerAccountId);
if (view) return view.ids;
return failedThreads.has(threadId) ? [] : null;
}, [ownerAccountId, failedThreads]);
const memberOf = React.useCallback(
(threadId: string, id: string): Email | undefined =>
peekDetail(id, ownerAccountId) ?? peekThread(threadId, ownerAccountId)?.headers.get(id),
[ownerAccountId],
);
const goToIndex = React.useCallback((index: number) => {
if (index < 0 || index >= emails.length) return;
@@ -239,13 +247,10 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
React.useEffect(() => { if (!keywordsHydrated) void hydrateKeywords(); }, [keywordsHydrated, hydrateKeywords]);
// Optimistically write a message's keywords into the cache (the single source
// of truth for every pane) and bump the version so the panes re-render.
// of truth for every pane, and for the next open); its listeners re-render.
const updateLocalKeywords = React.useCallback((id: string, next: Record<string, boolean>) => {
const prev = detailCache.get(id);
if (!prev) return;
detailCache.set(id, { ...prev, keywords: next });
bumpCache();
}, [detailCache, bumpCache]);
patchDetail(id, ownerAccountId, { keywords: next });
}, [ownerAccountId]);
// Mark read through the store (its list row and offline queue) only when
// the list holds the message's account; the store would otherwise read the
@@ -293,7 +298,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
const fetched = await ensureDetail(activeEmailId);
if (cancelled) return;
if (!fetched) {
if (!detailCache.has(activeEmailId)) setError(t('email_viewer.load_failed', 'Failed to load email'));
if (!detailOf(activeEmailId)) setError(t('email_viewer.load_failed', 'Failed to load email'));
return;
}
cancelRead = scheduleMarkRead(fetched);
@@ -302,7 +307,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
cancelled = true;
cancelRead?.();
};
}, [activeEmailId, ensureDetail, scheduleMarkRead, detailCache, t]);
}, [activeEmailId, ensureDetail, scheduleMarkRead, detailOf, t]);
const starred = !!email?.keywords?.$flagged;
const unread = !!email && !email.keywords?.$seen;
@@ -385,10 +390,11 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
}
};
// Banners and the quick reply hand back the message with a keyword changed
// (`$mdnsent`, `$answered`); only that goes into the cache.
const onEmailPatched = React.useCallback((patched: Email) => {
detailCache.set(patched.id, patched);
bumpCache();
}, [detailCache, bumpCache]);
patchDetail(patched.id, ownerAccountId, { keywords: patched.keywords });
}, [ownerAccountId]);
const performDelete = () => {
if (!email || !sourceMailbox || !trashMailbox) return;
@@ -693,19 +699,20 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
id={item.id}
active={item.id === activeEmailId}
threadIdHint={item.threadId}
email={detailCache.get(item.id) ?? null}
detailCache={detailCache}
email={detailOf(item.id) ?? null}
threadIds={
!disableThreading
? threadCache.get(detailCache.get(item.id)?.threadId ?? item.threadId) ?? null
? threadIdsOf(detailOf(item.id)?.threadId ?? item.threadId)
: null
}
memberOf={memberOf}
threading={!disableThreading}
jmapAccountId={ownerAccountId}
currentMailboxRole={currentMailboxRole}
identities={identities}
themeOverrides={themeOverrides}
ensureDetail={ensureDetail}
ensureDetails={ensureDetails}
ensureThread={ensureThread}
scheduleMarkRead={scheduleMarkRead}
styles={styles}
@@ -838,16 +845,18 @@ interface EmailPaneProps {
active: boolean;
threadIdHint?: string;
email: Email | null;
detailCache: Map<string, Email>;
/** Ids of the whole conversation (oldest first) once fetched; null = not (yet) loaded. */
threadIds: string[] | null;
/** A conversation member: its full copy when held, else its header. */
memberOf: (threadId: string, id: string) => Email | undefined;
threading: boolean;
jmapAccountId?: string;
currentMailboxRole: string | null;
identities: Identity[];
themeOverrides: Record<string, 'light' | 'dark'>;
ensureDetail: (id: string) => Promise<Email | null>;
ensureThread: (threadId: string) => Promise<string[]>;
ensureDetails: (ids: string[]) => void;
ensureThread: (threadId: string) => void;
scheduleMarkRead: (email: Email) => () => void;
styles: ReturnType<typeof makeStyles>;
bottomBarHeight: number;
@@ -862,11 +871,12 @@ interface EmailPaneProps {
// One swipeable page: the subject plus either a single message or the whole
// conversation as collapsible cards (newest + unread expanded, mark-read on
// expand). The pager keeps three of these mounted (prev, current, next) so a
// swipe slides ready content into view.
// swipe slides ready content into view. A conversation is listed from its
// members' headers; bodies are only downloaded for the cards that are open.
function EmailPane({
id, active, threadIdHint, email, detailCache, threadIds, threading, jmapAccountId, currentMailboxRole,
identities, themeOverrides, ensureDetail, ensureThread, scheduleMarkRead, styles, bottomBarHeight,
onToggleStar, onAddressPress, onEmailPatched, onReply, onSwipe, onZoomChange,
id, active, threadIdHint, email, threadIds, memberOf, threading, jmapAccountId, currentMailboxRole,
identities, themeOverrides, ensureDetail, ensureDetails, ensureThread, scheduleMarkRead, styles,
bottomBarHeight, onToggleStar, onAddressPress, onEmailPatched, onReply, onSwipe, onZoomChange,
}: EmailPaneProps) {
const c = useColors();
const t = useLocaleStore((s) => s.t);
@@ -882,22 +892,33 @@ function EmailPane({
if (!email) void ensureDetail(id);
}, [id, email, ensureDetail]);
// Also run for a conversation already held: it is checked against the
// account's Email state (and refetched only when that moved on).
const threadId = email?.threadId ?? threadIdHint;
React.useEffect(() => {
if (threading && threadId && !threadIds) void ensureThread(threadId);
}, [threading, threadId, threadIds, ensureThread]);
if (threading && threadId) ensureThread(threadId);
}, [threading, threadId, ensureThread]);
// Seeded once the message itself is known: before that `threadId` may be
// a guess (a page handed over by id only carries the opened one's).
React.useEffect(() => {
if (!threadIds || expanded) return;
if (!email || !threadIds || !threadId || expanded) return;
const seed = new Set<string>();
for (const mid of threadIds) {
const m = detailCache.get(mid);
const m = memberOf(threadId, mid);
if (m && !m.keywords?.$seen) seed.add(mid);
}
seed.add(id);
if (threadIds.length > 0) seed.add(threadIds[threadIds.length - 1]);
setExpanded(seed);
}, [threadIds, expanded, detailCache, id]);
}, [email, threadIds, threadId, expanded, memberOf, id]);
// Bodies of the open cards, in one request.
React.useEffect(() => {
if (expanded && threadIds && threadIds.length > 1) {
ensureDetails(threadIds.filter((mid) => expanded.has(mid)));
}
}, [expanded, threadIds, ensureDetails]);
React.useEffect(() => () => { readTimers.forEach((cancel) => cancel()); readTimers.clear(); }, [readTimers]);
@@ -908,7 +929,7 @@ function EmailPane({
next.delete(mid);
} else {
next.add(mid);
const m = detailCache.get(mid);
const m = threadId ? memberOf(threadId, mid) : undefined;
if (m && mid !== id) {
readTimers.get(mid)?.();
readTimers.set(mid, scheduleMarkRead(m));
@@ -923,10 +944,12 @@ function EmailPane({
}
const subject = singleLine(email.subject) || t('email_viewer.no_subject', '(No Subject)');
const conversation = threading && threadIds && threadIds.length > 1
? threadIds.map((mid) => detailCache.get(mid)).filter((m): m is Email => !!m)
const conversation = threading && threadId && threadIds && threadIds.length > 1
? threadIds.map((mid) => memberOf(threadId, mid)).filter((m): m is Email => !!m)
: null;
const newest = conversation ? conversation[conversation.length - 1] : email;
// The quick reply quotes the message, so it waits for the newest one's body.
const newestLoaded = !conversation || !!peekDetail(newest.id, jmapAccountId);
return (
<ScrollView
@@ -962,25 +985,31 @@ function EmailPane({
)}
{conversation ? (
conversation.map((m) => (
<ThreadMessageCard
key={m.id}
email={m}
expanded={expanded?.has(m.id) ?? m.id === id}
onToggleExpanded={() => toggleCard(m.id)}
onReply={onReply}
jmapAccountId={jmapAccountId}
identities={identities}
currentMailboxRole={currentMailboxRole}
active={active}
themeOverride={themeOverrides[m.id] ?? null}
onSwipe={onSwipe}
onZoomChange={(z) => { setPinching(z.pinching); onZoomChange(z); }}
onToggleStar={onToggleStar}
onAddressPress={onAddressPress}
onEmailPatched={onEmailPatched}
/>
))
conversation.map((m) => {
const open = expanded?.has(m.id) ?? m.id === id;
// An open card shows its header until its body has arrived.
const full = open ? peekDetail(m.id, jmapAccountId) : undefined;
return (
<ThreadMessageCard
key={m.id}
email={full ?? m}
expanded={!!full}
loading={open && !full}
onToggleExpanded={() => toggleCard(m.id)}
onReply={onReply}
jmapAccountId={jmapAccountId}
identities={identities}
currentMailboxRole={currentMailboxRole}
active={active}
themeOverride={themeOverrides[m.id] ?? null}
onSwipe={onSwipe}
onZoomChange={(z) => { setPinching(z.pinching); onZoomChange(z); }}
onToggleStar={onToggleStar}
onAddressPress={onAddressPress}
onEmailPatched={onEmailPatched}
/>
);
})
) : (
<MessageContent
email={email}
@@ -996,12 +1025,14 @@ function EmailPane({
/>
)}
<QuickReplyBox
email={newest}
jmapAccountId={jmapAccountId}
onMoreOptions={() => onReply('reply', newest)}
onSent={onEmailPatched}
/>
{newestLoaded && (
<QuickReplyBox
email={newest}
jmapAccountId={jmapAccountId}
onMoreOptions={() => onReply('reply', newest)}
onSent={onEmailPatched}
/>
)}
</ScrollView>
);
}
+2 -15
View File
@@ -101,9 +101,8 @@ vi.mock('../settings-store', () => {
};
});
// offline-cache-store is touched by selectMailbox (cache-seed fallback),
// getEmailDetail (best-effort body refresh), and setActiveAccount (account
// switch). Stub it as an empty cache so tests don't need to set up
// offline-cache-store is touched by selectMailbox (cache-seed fallback) and
// setActiveAccount (account switch). Stub it as an empty cache so tests don't need to set up
// AsyncStorage.
vi.mock('../offline-cache-store', () => ({
useOfflineCacheStore: {
@@ -164,7 +163,6 @@ const mockGetThreads = emailApi.getThreads as ReturnType<typeof vi.fn>;
const mockGetEmailQueryChanges = emailApi.getEmailQueryChanges as ReturnType<typeof vi.fn>;
const mockGetEmails = emailApi.getEmails as ReturnType<typeof vi.fn>;
const mockGetEmailsWithState = emailApi.getEmailsWithState as ReturnType<typeof vi.fn>;
const mockGetFullEmail = emailApi.getFullEmail as ReturnType<typeof vi.fn>;
const mockPatchKeywords = emailApi.patchKeywordsForEmails as ReturnType<typeof vi.fn>;
const mockMoveEmail = emailApi.moveEmail as ReturnType<typeof vi.fn>;
const mockDeleteEmail = emailApi.deleteEmail as ReturnType<typeof vi.fn>;
@@ -686,17 +684,6 @@ describe('email-store', () => {
});
});
describe('getEmailDetail', () => {
it('should fetch full email', async () => {
const fullEmail = { id: 'e1', subject: 'Test', bodyValues: { html: { value: '<p>Hi</p>' } } };
mockGetFullEmail.mockResolvedValue(fullEmail);
const result = await useEmailStore.getState().getEmailDetail('e1');
expect(result).toEqual(fullEmail);
});
});
// ── Search scope (#788), tag view, keeping the search across folders (#553)
describe('search scope', () => {
it('searches every folder by default when a text query is active (#788)', async () => {
+2 -28
View File
@@ -379,7 +379,6 @@ export interface EmailState {
mailboxId: string,
) => Promise<{ imported: number; failed: number }>;
handleStateChange: (change: StateChange) => Promise<void>;
getEmailDetail: (id: string, accountId?: string) => Promise<Email>;
markRead: (emailId: string, accountId?: string) => Promise<void>;
markUnread: (emailId: string) => Promise<void>;
toggleStar: (emailId: string, starred: boolean) => Promise<void>;
@@ -857,8 +856,8 @@ export const useEmailStore = create<EmailState>()(
loading: false,
});
// Point the offline body cache at the same account so getEmailDetail's
// fallback and selectMailbox's seed read from the right bucket. Fire-
// Point the offline body cache at the same account so the viewer's
// cache-first open and selectMailbox's seed read from the right bucket. Fire-
// and-forget — the cache returns empty until hydration completes,
// which is the correct degraded behaviour.
void useOfflineCacheStore.getState().setAccount(accountId);
@@ -1232,31 +1231,6 @@ export const useEmailStore = create<EmailState>()(
void get().refreshEmails();
},
getEmailDetail: async (id, accountId) => {
// Try the network first so the user sees fresh keywords/flags. If that
// fails (offline / server unreachable), fall back to the offline cache
// when the message is in it. Without the cache hit, propagate the error
// so the caller can surface it. `accountId` targets a group/shared inbox
// message opened from the unified view.
try {
const fresh = await getFullEmail(id, accountId);
// Opportunistically refresh the cached copy so the next offline open
// reflects the latest keywords without needing a full sync.
const cache = useOfflineCacheStore.getState();
if (cache.has(id, accountId)) {
try {
const size = JSON.stringify(fresh).length;
await cache.put(fresh, size, accountId);
} catch { /* ignore — best-effort refresh */ }
}
return fresh;
} catch (err) {
const cached = await useOfflineCacheStore.getState().get(id, accountId);
if (cached) return cached;
throw err;
}
},
markRead: async (emailId, accountId) => {
const state = get();
// Ids repeat across the accounts of a list spanning accounts: take the