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);