mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
perf: start loading a message when the mouse is pressed on it
This commit is contained in:
@@ -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(
|
||||
<ThreadListItem thread={thread} isExpanded={false} onToggleExpand={() => {}} 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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<Exclude<SwipeAction, 'none'>, { icon: AppIcon; b
|
||||
};
|
||||
|
||||
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
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<HTMLDivElement, SingleEmailItemProps>(
|
||||
{...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<HTMLDivElement, ThreadListItemPro
|
||||
onCollapseAllThreads,
|
||||
onEmailSelect,
|
||||
onEmailDoubleClick,
|
||||
onPrefetch,
|
||||
onContextMenu,
|
||||
onOpenConversation,
|
||||
onToggleStar,
|
||||
@@ -949,6 +964,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
selected={selectedEmailId === latestEmail.id}
|
||||
onClick={() => 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<HTMLDivElement, ThreadListItemPro
|
||||
{...dragHandlers}
|
||||
{...threadLongPressHandlers}
|
||||
onPointerEnter={armHoverActions}
|
||||
onPointerDown={(e) => { if (onPrefetch && isMousePressOnRow(e)) onPrefetch(latestEmail); }}
|
||||
aria-current={isSelected ? 'true' : undefined}
|
||||
data-unread={hasUnread ? 'true' : 'false'}
|
||||
data-mv-mobile-row={mvMobileRow ? "" : undefined}
|
||||
|
||||
@@ -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<Pick<Email, 'sourceClientAccountId' | 'sourceAccountId'>>,
|
||||
) => {
|
||||
@@ -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');
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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<string, { at: number; email: Promise<Email | null> }>();
|
||||
|
||||
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<Email | null> {
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user