diff --git a/components/email/__tests__/thread-list-item.test.tsx b/components/email/__tests__/thread-list-item.test.tsx index 47c79b1a6..90d21f827 100644 --- a/components/email/__tests__/thread-list-item.test.tsx +++ b/components/email/__tests__/thread-list-item.test.tsx @@ -617,3 +617,32 @@ describe('ThreadListItem Mountain View desktop row', () => { expect(onToggleExpand).not.toHaveBeenCalled(); }); }); + +describe('ThreadListItem prefetch on mouse press', () => { + const renderWithPrefetch = (onPrefetch: (email: Email) => void) => { + const [thread] = groupEmailsByThread([makeEmail()]); + return render( + {}} onEmailSelect={() => {}} onPrefetch={onPrefetch} />, + ); + }; + + it('asks for the message when a mouse is pressed on the row', () => { + const onPrefetch = vi.fn(); + renderWithPrefetch(onPrefetch); + fireEvent.pointerDown(screen.getByTestId('email-list-item'), { pointerType: 'mouse', button: 0 }); + expect(onPrefetch).toHaveBeenCalledWith(expect.objectContaining({ id: 'email-1' })); + }); + + it('leaves touches, other buttons and presses on controls in the row alone', () => { + const onPrefetch = vi.fn(); + renderWithPrefetch(onPrefetch); + const row = screen.getByTestId('email-list-item'); + fireEvent.pointerDown(row, { pointerType: 'touch', button: 0 }); + fireEvent.pointerDown(row, { pointerType: 'mouse', button: 2 }); + fireEvent.pointerEnter(row); + const control = row.querySelector('button'); + expect(control).not.toBeNull(); + fireEvent.pointerDown(control!, { pointerType: 'mouse', button: 0 }); + expect(onPrefetch).not.toHaveBeenCalled(); + }); +}); diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 55bde1a5e..e31e97500 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -42,6 +42,8 @@ interface EmailListProps { emails: Email[]; selectedEmailId?: string; onEmailSelect?: (email: Email) => void; + /** A mouse was pressed on a row: the click that follows will open it. */ + onEmailPrefetch?: (email: Email) => void; onEmailDoubleClick?: (email: Email) => void; /** Mountain View: search for the mail a sender has sent, from the row menu. */ onFindFromSender?: (sender: { name: string; email: string }) => void; @@ -84,6 +86,7 @@ export function EmailList({ emails, selectedEmailId, onEmailSelect, + onEmailPrefetch, onEmailDoubleClick, onFindFromSender, className, @@ -609,6 +612,7 @@ export function EmailList({ onEmailSelect?.(email); }, onEmailDoubleClick, + onPrefetch: onEmailPrefetch, onContextMenu: openContextMenu, onOpenConversation, onToggleStar, @@ -900,6 +904,7 @@ export function EmailList({ onCollapseAllThreads={rowHandlers.onCollapseAllThreads} onEmailSelect={rowHandlers.onEmailSelect} onEmailDoubleClick={rowHandlers.onEmailDoubleClick} + onPrefetch={rowHandlers.onPrefetch} onContextMenu={rowHandlers.onContextMenu} onOpenConversation={rowHandlers.onOpenConversation} onToggleStar={rowHandlers.onToggleStar} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 887b51a67..0cbc07c99 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -43,6 +43,16 @@ import { useTranslations } from "next-intl"; * it once the row grows a chip line. Extra-compact rows have no avatar and * anchor it on their single line instead (top padding + half a line). */ +/** + * A mouse press on the row itself, i.e. one about to become a click that opens + * it: a touch may be a scroll starting, and a press on a control inside the + * row (checkbox, hover action, link) does something else. + */ +function isMousePressOnRow(e: React.PointerEvent): boolean { + if (e.pointerType !== 'mouse' || e.button !== 0) return false; + return !(e.target as Element).closest('button, input, a'); +} + /** * Whether a row has been pointed at yet. The hover actions stay display:none * until the pointer is over the row, so mounting them for every row up front @@ -254,6 +264,8 @@ interface ThreadListItemProps { onCollapseAllThreads?: () => void; onEmailSelect: (email: Email) => void; onEmailDoubleClick?: (email: Email) => void; + /** A mouse was pressed on the row: the click that follows will open it. */ + onPrefetch?: (email: Email) => void; onContextMenu?: (e: React.MouseEvent, email: Email) => void; onOpenConversation?: (thread: ThreadGroup) => void; onToggleStar?: (email: Email) => void; @@ -272,6 +284,7 @@ interface SingleEmailItemProps { selected: boolean; onClick: () => void; onDoubleClick?: () => void; + onPrefetch?: () => void; onContextMenu?: (e: React.MouseEvent, email: Email) => void; showPreview: boolean; rowTint: string | null; @@ -297,7 +310,7 @@ const SWIPE_ACTION_META: Record, { icon: AppIcon; b }; const SingleEmailItem = React.forwardRef( - function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, rowTint, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetTag, onMarkAsSpam, onUndoSpam, onOpenAttachment, loadAttachments }, ref) { + function SingleEmailItem({ email, selected, onClick, onDoubleClick, onPrefetch, onContextMenu, showPreview, rowTint, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetTag, onMarkAsSpam, onUndoSpam, onOpenAttachment, loadAttachments }, ref) { const t = useTranslations('email_viewer'); const tBatch = useTranslations('email_list.batch_actions'); const tHover = useTranslations('settings.email_behavior.hover_actions'); @@ -477,6 +490,7 @@ const SingleEmailItem = React.forwardRef( {...touchHandlers} data-testid="email-list-item" onPointerEnter={armHoverActions} + onPointerDown={(e) => { if (onPrefetch && isMousePressOnRow(e)) onPrefetch(); }} data-email-id={email.id} data-subject={email.subject || ''} data-unread={isUnread ? 'true' : 'false'} @@ -829,6 +843,7 @@ export const ThreadListItem = React.forwardRef onEmailSelect(latestEmail)} onDoubleClick={onEmailDoubleClick ? () => onEmailDoubleClick(latestEmail) : undefined} + onPrefetch={onPrefetch ? () => onPrefetch(latestEmail) : undefined} onContextMenu={onContextMenu} showPreview={showPreview} rowTint={rowTint} @@ -1045,6 +1061,7 @@ export const ThreadListItem = React.forwardRef { if (onPrefetch && isMousePressOnRow(e)) onPrefetch(latestEmail); }} aria-current={isSelected ? 'true' : undefined} data-unread={hasUnread ? 'true' : 'false'} data-mv-mobile-row={mvMobileRow ? "" : undefined} diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 2b8631906..f1b14453f 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -122,6 +122,7 @@ import { mailtoComposerDraft } from "./mailto-draft"; import { getQuoteBodies } from "@/lib/email-composer-utils"; import { emailHooks } from "@/lib/plugin-hooks"; import { afterRequestDispatch } from "@/lib/after-request-dispatch"; +import { prefetchEmailContent, requestEmailContent } from "@/lib/email-prefetch"; import { createMailboxWithHooks } from "@/lib/plugin-mail-hooks"; import { emailToReadView } from "@/lib/plugin-projection"; import { emitThreadExpand } from "@/lib/plugin-thread-expand"; @@ -3873,6 +3874,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent; // Handle email selection with mobile view switching + // A mouse pressed on a row: the click that follows opens the message, so + // its content is requested now (see lib/email-prefetch). Resolved the way + // handleEmailSelect resolves it, so the open finds this very request. + const handleEmailPrefetch = (email: Email) => { + if (!client) return; + const { client: fetchClient, accountId } = resolveEmailBlobContext(email, client); + if (fetchClient) prefetchEmailContent(fetchClient, email.id, accountId); + }; + const handleEmailSelect = async ( email: { id: string } & Partial>, ) => { @@ -3941,7 +3951,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const conversationPrefetch = mvConversationView && listEmail?.id && listEmail.threadId ? loadMvConversation(listEmail as Email, client) : null; - const fullEmail = await fetchClient.getEmail(email.id, accountId); + // The request a mouse press on the row already started, if any. + const fullEmail = await requestEmailContent(fetchClient, email.id, accountId); if (openSeq !== emailOpenSeqRef.current) return; if (fullEmail) { if (listEmail?.isScheduled) { @@ -4835,6 +4846,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } }} onEmailSelect={handleEmailSelect} + onEmailPrefetch={handleEmailPrefetch} onEmailDoubleClick={isEmbedded ? ((email) => { const store = useProTabStore.getState(); const hasSplit = store.tabs.some((tab) => tab.paneId === 'split'); diff --git a/lib/__tests__/email-prefetch.test.ts b/lib/__tests__/email-prefetch.test.ts new file mode 100644 index 000000000..7a9ce4d37 --- /dev/null +++ b/lib/__tests__/email-prefetch.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { forgetEmailPrefetches, prefetchEmailContent, requestEmailContent } from '../email-prefetch'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { Email } from '@/lib/jmap/types'; + +// A mouse press on a row starts the body request; the click that follows +// takes that request over instead of sending a second one. + +const client = (getEmail = vi.fn(async (id: string) => ({ id }) as Email)) => + ({ getAccountId: () => 'login', getEmail }) as unknown as IJMAPClient & { getEmail: typeof getEmail }; + +describe('email prefetch', () => { + afterEach(() => { + forgetEmailPrefetches(); + vi.useRealTimers(); + }); + + it('hands the open the request the press started', async () => { + const c = client(); + prefetchEmailContent(c, 'e1', 'acct'); + prefetchEmailContent(c, 'e1', 'acct'); // a second press does not ask again + const email = await requestEmailContent(c, 'e1', 'acct'); + + expect(email?.id).toBe('e1'); + expect(c.getEmail).toHaveBeenCalledTimes(1); + expect(c.getEmail).toHaveBeenCalledWith('e1', 'acct'); + }); + + it('asks again when the press was for another account or message', async () => { + const c = client(); + prefetchEmailContent(c, 'e1', 'acct'); + await requestEmailContent(c, 'e1', 'other'); + await requestEmailContent(c, 'e2', 'acct'); + expect(c.getEmail).toHaveBeenCalledTimes(3); + }); + + it('does not hand out a request older than a few seconds', async () => { + vi.useFakeTimers(); + const c = client(); + prefetchEmailContent(c, 'e1'); + vi.advanceTimersByTime(6_000); + await requestEmailContent(c, 'e1'); + expect(c.getEmail).toHaveBeenCalledTimes(2); + }); + + it('lets the open see a failed prefetch, and an unclaimed one fail quietly', async () => { + const failing = client(vi.fn(async () => { throw new Error('offline'); })); + prefetchEmailContent(failing, 'e1'); + await expect(requestEmailContent(failing, 'e1')).rejects.toThrow('offline'); + prefetchEmailContent(failing, 'e2'); // never claimed: no unhandled rejection + await new Promise((r) => setTimeout(r, 0)); + }); +}); diff --git a/lib/email-prefetch.ts b/lib/email-prefetch.ts new file mode 100644 index 000000000..81797aa0f --- /dev/null +++ b/lib/email-prefetch.ts @@ -0,0 +1,50 @@ +import type { IJMAPClient } from "@/lib/jmap/client-interface"; +import type { Email } from "@/lib/jmap/types"; + +/** + * Message bodies requested ahead of the click that opens them. + * + * A mouse press on a row comes roughly a tenth of a second before the click + * that follows it, and Chromium sends a fetch() only once the task that made + * it has ended: the click's task ends after its whole render pass, the + * press's task at once. So the body is requested on the press and the open + * takes over the request already on its way. A request nobody claims within + * PREFETCH_TTL_MS is dropped (the press may have started a drag or a + * selection instead). + */ +const PREFETCH_TTL_MS = 5_000; +const pending = new Map }>(); + +const keyFor = (client: IJMAPClient, emailId: string, accountId: string | undefined) => + `${client.getAccountId()}|${accountId ?? ""}|${emailId}`; + +function dropStale(now: number): void { + for (const [key, entry] of pending) if (now - entry.at > PREFETCH_TTL_MS) pending.delete(key); +} + +/** Starts fetching a message's content, unless that request is already on its way. */ +export function prefetchEmailContent(client: IJMAPClient, emailId: string, accountId?: string): void { + const now = Date.now(); + dropStale(now); + const key = keyFor(client, emailId, accountId); + if (pending.has(key)) return; + const email = client.getEmail(emailId, accountId); + // A prefetch nobody claims must not surface as an unhandled rejection; the + // open that claims it still sees the failure. + email.catch(() => {}); + pending.set(key, { at: now, email }); +} + +/** The message's content: the request a press already started, else a new one. */ +export function requestEmailContent(client: IJMAPClient, emailId: string, accountId?: string): Promise { + const key = keyFor(client, emailId, accountId); + const entry = pending.get(key); + pending.delete(key); + if (entry && Date.now() - entry.at <= PREFETCH_TTL_MS) return entry.email; + return client.getEmail(emailId, accountId); +} + +/** For tests. */ +export function forgetEmailPrefetches(): void { + pending.clear(); +}