diff --git a/src/api/__tests__/email-viewer-fetch.test.ts b/src/api/__tests__/email-viewer-fetch.test.ts new file mode 100644 index 00000000..4e01cab0 --- /dev/null +++ b/src/api/__tests__/email-viewer-fetch.test.ts @@ -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; + +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); + }); +}); diff --git a/src/api/email.ts b/src/api/email.ts index 408b6d7d..f0da145c 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -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 { - 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 { + 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> { + if (ids.length === 0) return { list: [], notFound: [] }; const accountId = accountIdOverride ?? jmapClient.accountId; - const out: Email[] = []; + const out: EmailGetResult = { 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; + +/** + * 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> { + if (ids.length === 0) return { list: [], notFound: [] }; + const accountId = accountIdOverride ?? jmapClient.accountId; + const out: EmailGetResult = { 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 diff --git a/src/components/email/ThreadMessageCard.tsx b/src/components/email/ThreadMessageCard.tsx index 979c589d..f6801225 100644 --- a/src/components/email/ThreadMessageCard.tsx +++ b/src/components/email/ThreadMessageCard.tsx @@ -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 {singleLine(email.preview)} + {loading && } {starred && } ); diff --git a/src/lib/__tests__/email-detail-cache.test.ts b/src/lib/__tests__/email-detail-cache.test.ts new file mode 100644 index 00000000..2a8d3cad --- /dev/null +++ b/src/lib/__tests__/email-detail-cache.test.ts @@ -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; +const flagsGet = getEmailFlags as ReturnType; +const threadGet = getThreadHeaders as ReturnType; + +function full(id: string, keywords: Record = {}): 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: `

${id}

` } }, + } 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: {} }]; + } +}); diff --git a/src/lib/email-detail-cache.ts b/src/lib/email-detail-cache.ts new file mode 100644 index 00000000..82bb2b77 --- /dev/null +++ b/src/lib/email-detail-cache.ts @@ -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>; + +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; + state?: string; +} + +export interface ThreadView { + ids: string[]; + headers: ReadonlyMap; +} + +// 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(); +const threads = new Map(); +const pendingDetails = new Map>(); +const pendingThreads = new Map>(); +// The newest Email state heard of per account, from push and our own reads. +const latestStates = new Map(); +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 | undefined, b: Record | 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, + 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 { + promise: Promise; + resolve: (value: T) => void; + reject: (err: unknown) => void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (err: unknown) => void; + const promise = new Promise((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>, +): Promise { + if (ids.length === 0) return; + try { + const res = await getFullEmailsWithState(ids, accountId); + noteEmailState(accountId, res.state); + const found = new Map(); + 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>, +): Promise { + if (ids.length === 0) return; + let gone = new Set(); + 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, +): Map> { + wire(); + const latest = latestStates.get(accountKey(accountId)); + const offline = useOfflineCacheStore.getState(); + const results = new Map>(); + const waiting = new Map>(); + 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(); + 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, +): Promise> { + const out = new Map(); + 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 { + 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 { + 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 & 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); + } +} diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index 56202568..9ce581be 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -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>({}); - // 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()).current; - // Thread id -> message ids (oldest first), once the conversation was fetched. - const threadCache = React.useRef(new Map()).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()).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>()).current; - const ensureDetail = React.useCallback((id: string): Promise => { - 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()).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>()).current; - const ensureThread = React.useCallback((threadId: string): Promise => { - 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 => + 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) => { - 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; /** 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; ensureDetail: (id: string) => Promise; - ensureThread: (threadId: string) => Promise; + ensureDetails: (ids: string[]) => void; + ensureThread: (threadId: string) => void; scheduleMarkRead: (email: Email) => () => void; styles: ReturnType; 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(); 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 ( ( - 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 ( + 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} + /> + ); + }) ) : ( )} - onReply('reply', newest)} - onSent={onEmailPatched} - /> + {newestLoaded && ( + onReply('reply', newest)} + onSent={onEmailPatched} + /> + )} ); } diff --git a/src/stores/__tests__/email-store.test.ts b/src/stores/__tests__/email-store.test.ts index 0570f874..a28a20c1 100644 --- a/src/stores/__tests__/email-store.test.ts +++ b/src/stores/__tests__/email-store.test.ts @@ -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; const mockGetEmailQueryChanges = emailApi.getEmailQueryChanges as ReturnType; const mockGetEmails = emailApi.getEmails as ReturnType; const mockGetEmailsWithState = emailApi.getEmailsWithState as ReturnType; -const mockGetFullEmail = emailApi.getFullEmail as ReturnType; const mockPatchKeywords = emailApi.patchKeywordsForEmails as ReturnType; const mockMoveEmail = emailApi.moveEmail as ReturnType; const mockDeleteEmail = emailApi.deleteEmail as ReturnType; @@ -686,17 +684,6 @@ describe('email-store', () => { }); }); - describe('getEmailDetail', () => { - it('should fetch full email', async () => { - const fullEmail = { id: 'e1', subject: 'Test', bodyValues: { html: { value: '

Hi

' } } }; - 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 () => { diff --git a/src/stores/email-store.ts b/src/stores/email-store.ts index 4726918b..3e06d23d 100644 --- a/src/stores/email-store.ts +++ b/src/stores/email-store.ts @@ -379,7 +379,6 @@ export interface EmailState { mailboxId: string, ) => Promise<{ imported: number; failed: number }>; handleStateChange: (change: StateChange) => Promise; - getEmailDetail: (id: string, accountId?: string) => Promise; markRead: (emailId: string, accountId?: string) => Promise; markUnread: (emailId: string) => Promise; toggleStar: (emailId: string, starred: boolean) => Promise; @@ -857,8 +856,8 @@ export const useEmailStore = create()( 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()( 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