From 1348e7d20af0d9ac75616027f8542f5c3cc29df7 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Thu, 24 Sep 2026 19:37:35 +0200
Subject: [PATCH] fix: start loading a message on the tap, before the viewer
opens
The detail and conversation requests only went out once the viewer had
mounted and its effects ran, after the navigation had rendered.
A tap on a row in the folder list, the Unified Inbox or a contact's
activity, and a notification tap, now start the message's detail and
its conversation (when threading is on) through the shared cache, and
the viewer joins the requests already in flight. It runs on the tap and
not on press-in, because on a phone a touch that starts a scroll
presses rows too.
The Unified Inbox and contact activity also leave their rows in the
cache, so pages handed over by id get their header painted from the row
(and the right thread id) instead of a skeleton.
---
App.tsx | 15 +++++++---
src/components/contacts/ContactActivity.tsx | 8 +++--
src/lib/__tests__/email-detail-cache.test.ts | 19 +++++++++++-
src/lib/email-detail-cache.ts | 31 +++++++++++++++++---
src/screens/EmailThreadScreen.tsx | 6 ++--
src/screens/UnifiedInboxScreen.tsx | 18 +++++++-----
6 files changed, 76 insertions(+), 21 deletions(-)
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);