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.
This commit is contained in:
Linus Rath
2026-09-24 20:12:17 +02:00
parent ac8e3fb2e9
commit 1348e7d20a
6 changed files with 76 additions and 21 deletions
+11 -4
View File
@@ -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<RootStackParam
// The list holds the open folder's mail: name its account so the
// viewer never has to guess it from whatever folder is open.
const { mailboxes: all, currentMailboxId } = useEmailStore.getState();
navigation.navigate('EmailThread', {
emailId: email.id,
threadId: email.threadId,
subject: email.subject,
const target = {
jmapAccountId: mailboxAccountId(all, currentMailboxId),
// A row of an "All folders" list or a tag view names its own
// account instead, and pages over that account's rows.
...viewerParamsForRow(email),
};
// Start on the body now: mounting the viewer takes a while.
prefetchMessage(email, target.jmapAccountId);
navigation.navigate('EmailThread', {
emailId: email.id,
threadId: email.threadId,
subject: email.subject,
...target,
});
}}
/>
+5 -3
View File
@@ -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 (
<Pressable
key={email.id}
onPress={() =>
onPress={() => {
prefetchMessage(email);
navigation.navigate('EmailThread', {
emailId: email.id,
threadId: email.threadId,
subject: email.subject ?? undefined,
})
}
});
}}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
<SenderAvatar name={senderName} email={sender?.email} size={32} />
+18 -1
View File
@@ -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<typeof vi.fn>;
@@ -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: {} }];
}
+27 -4
View File
@@ -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<string, DetailEntry>();
const threads = new Map<string, ThreadEntry>();
const rows = new Map<string, Email>();
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.
@@ -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<Thread
return p;
}
/** Keep list rows the viewer may page over; see {@link peekRow}. */
export function rememberRows(list: Email[], accountId?: string): void {
for (const row of list) {
if (!row.receivedAt) continue;
const key = keyOf(row.id, accountId);
rows.delete(key);
rows.set(key, row);
}
while (rows.size > 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<Email, 'id' | 'threadId'> & FlagsHint,
email: Pick<Email, 'id' | 'threadId'> & Partial<Email>,
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);
}
+4 -2
View File
@@ -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<Email[]>(() => 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),
+11 -7
View File
@@ -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);