diff --git a/App.tsx b/App.tsx index 893dbde7..6939ad64 100644 --- a/App.tsx +++ b/App.tsx @@ -55,6 +55,7 @@ import { useCalendarStore } from './src/stores/calendar-store'; import { useContactsStore } from './src/stores/contacts-store'; import { useEmailStore, viewerParamsForRow } from './src/stores/email-store'; import { mailboxAccountId } from './src/lib/mailbox-tree'; +import { prefetchMessage } from './src/lib/email-detail-cache'; import { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities'; import { useSettingsStore } from './src/stores/settings-store'; import { useLocaleStore } from './src/stores/locale-store'; @@ -93,6 +94,7 @@ async function navigateToNotificationTap(payload: NotificationTapPayload): Promi if (useAuthStore.getState().activeAccountId !== payload.accountId) return; } + prefetchMessage({ id: payload.emailId, threadId: payload.threadId }); navigationRef.navigate('EmailThread', { emailId: payload.emailId, threadId: payload.threadId, @@ -222,14 +224,19 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps diff --git a/src/components/contacts/ContactActivity.tsx b/src/components/contacts/ContactActivity.tsx index ccd85b36..d78c3e4d 100644 --- a/src/components/contacts/ContactActivity.tsx +++ b/src/components/contacts/ContactActivity.tsx @@ -13,6 +13,7 @@ import { useLocaleStore } from '../../stores/locale-store'; import { getEventColor } from '../../lib/calendar-utils'; import { hasCalendarCapability } from '../../lib/capabilities'; import { singleLine } from '../../lib/single-line'; +import { prefetchMessage } from '../../lib/email-detail-cache'; import SenderAvatar from '../SenderAvatar'; import { radius, spacing, typography, componentSizes, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; @@ -207,13 +208,14 @@ export function ContactActivity({ contact }: Props) { return ( + onPress={() => { + prefetchMessage(email); navigation.navigate('EmailThread', { emailId: email.id, threadId: email.threadId, subject: email.subject ?? undefined, - }) - } + }); + }} style={({ pressed }) => [styles.row, pressed && styles.rowPressed]} > diff --git a/src/lib/__tests__/email-detail-cache.test.ts b/src/lib/__tests__/email-detail-cache.test.ts index 2a8d3cad..c12e0c57 100644 --- a/src/lib/__tests__/email-detail-cache.test.ts +++ b/src/lib/__tests__/email-detail-cache.test.ts @@ -16,7 +16,7 @@ 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, + patchDetail, peekDetail, peekRow, peekThread, prefetchMessage, rememberRows, subscribeEmailCache, } from '../email-detail-cache'; const fullGet = getFullEmailsWithState as ReturnType; @@ -295,6 +295,23 @@ describe('prefetchMessage', () => { expect(threadGet).toHaveBeenCalledTimes(1); }); + it('remembers the tapped row so the viewer can paint its header', () => { + fullGet.mockReturnValue(new Promise(() => undefined)); + threadGet.mockReturnValue(new Promise(() => undefined)); + const row = { ...full('e1'), subject: 'Lunch?', bodyValues: undefined } as Email; + + prefetchMessage(row, 'group'); + + expect(peekRow('e1', 'group')?.subject).toBe('Lunch?'); + expect(peekRow('e1')).toBeUndefined(); + }); + + it('does not remember a bare id as a row', () => { + fullGet.mockReturnValue(new Promise(() => undefined)); + rememberRows([{ id: 'e9', threadId: 't9' } as Email]); + expect(peekRow('e9')).toBeUndefined(); + }); + 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 index 82bb2b77..3e834763 100644 --- a/src/lib/email-detail-cache.ts +++ b/src/lib/email-detail-cache.ts @@ -47,9 +47,13 @@ export interface ThreadView { const MAX_DETAILS = 40; const MAX_DETAIL_CHARS = 8_000_000; const MAX_THREADS = 60; +// List rows handed to the viewer by screens whose list it cannot read (the +// Unified Inbox, contact activity), so it can paint their headers too. +const MAX_ROWS = 500; const details = new Map(); const threads = new Map(); +const rows = new Map(); const pendingDetails = new Map>(); const pendingThreads = new Map>(); // The newest Email state heard of per account, from push and our own reads. @@ -201,6 +205,7 @@ export function patchDetail(id: string, accountId: string | undefined, patch: Fl export function clearEmailDetailCache(): void { details.clear(); threads.clear(); + rows.clear(); pendingDetails.clear(); pendingThreads.clear(); latestStates.clear(); @@ -450,17 +455,35 @@ export function loadThread(threadId: string, accountId?: string): Promise MAX_ROWS) rows.delete(rows.keys().next().value as string); +} + +/** A list row remembered for the message, if any. */ +export function peekRow(id: string, accountId?: string): Email | undefined { + return rows.get(keyOf(id, accountId)); +} + /** * 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. + * requests in flight or done. On a phone this runs on the tap, not on + * press-in: a touch that starts a scroll presses rows too. */ export function prefetchMessage( - email: Pick & FlagsHint, + email: Pick & Partial, accountId?: string, ): void { - void loadDetail(email.id, accountId, { keywords: email.keywords, mailboxIds: email.mailboxIds }) - .catch(() => undefined); + rememberRows([email as Email], accountId); + const hint = email.keywords ? { keywords: email.keywords, mailboxIds: email.mailboxIds } : undefined; + void loadDetail(email.id, accountId, hint).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 d5c5b13d..c4dcbb65 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -30,7 +30,7 @@ import { } from '../stores/settings-store'; import { patchKeywordsForEmails } from '../api/email'; import { - loadDetail, loadDetails, loadThread, patchDetail, peekDetail, peekThread, subscribeEmailCache, + loadDetail, loadDetails, loadThread, patchDetail, peekDetail, peekRow, peekThread, subscribeEmailCache, type FlagsHint, } from '../lib/email-detail-cache'; import { shareEmailEml } from '../lib/email-export'; @@ -103,6 +103,8 @@ export default function EmailThreadScreen({ route, navigation }: Props) { // viewer opens: new mail or a refresh must not shift the page under the // user while the toolbar acts on `activeEmailId` (B5). Delete, archive, // move and spam leave the screen, so no page outlives its message here. + // A page handed over by id takes the row its list left in the cache (the + // Unified Inbox's, a tapped one), so its header can be painted too. const [emails] = React.useState(() => viewerPages({ emailId: route.params.emailId, threadId: route.params.threadId, @@ -110,7 +112,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) { list: useEmailStore.getState().emails, listIsMessageAccount: listAccountId === ownerAccountId, threading: !disableThreading, - })); + }).map((page) => (page.receivedAt ? page : peekRow(page.id, ownerAccountId) ?? page))); const currentMailboxRole = React.useMemo( () => (currentMailboxId ? mailboxes.find((m) => m.id === currentMailboxId)?.role ?? null : null), diff --git a/src/screens/UnifiedInboxScreen.tsx b/src/screens/UnifiedInboxScreen.tsx index db1a4340..e8710a1b 100644 --- a/src/screens/UnifiedInboxScreen.tsx +++ b/src/screens/UnifiedInboxScreen.tsx @@ -22,6 +22,7 @@ import { useSettingsStore, type SwipeAction } from '../stores/settings-store'; import { useLocaleStore } from '../stores/locale-store'; import { formatListDate } from '../lib/date-format'; import { singleLine } from '../lib/single-line'; +import { prefetchMessage, rememberRows } from '../lib/email-detail-cache'; import { isPermanentDelete, confirmPermanentDelete } from '../lib/delete-confirm'; import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; @@ -159,17 +160,20 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) { } // Page over the rows of the same account the user is looking at // (ids are only unique per JMAP account, so never mix accounts). - const emailIds = emails - .filter((e) => e.sourceAccountId === email.sourceAccountId && e.jmapAccountId === email.jmapAccountId) - .map((e) => e.id); + const pages = emails + .filter((e) => e.sourceAccountId === email.sourceAccountId && e.jmapAccountId === email.jmapAccountId); + // Group/shared messages live under another JMAP account in the + // same session; pass it so the thread opens against the right one. + const jmapAccountId = email.isShared ? email.jmapAccountId : undefined; + // The viewer paints these rows' headers, and starts on the body now. + rememberRows(pages, jmapAccountId); + prefetchMessage(email, jmapAccountId); navigation.navigate('EmailThread', { emailId: email.id, threadId: email.threadId, subject: email.subject, - // Group/shared messages live under another JMAP account in the - // same session; pass it so the thread opens against the right one. - jmapAccountId: email.isShared ? email.jmapAccountId : undefined, - emailIds, + jmapAccountId, + emailIds: pages.map((e) => e.id), }); } finally { setOpening(false);