Open conversations in one request, and start them early (#392)

On a 250 ms link, opening a conversation took two round trips: Thread/get,
then the bodies. It now takes one. A known thread sends Thread/get and the
missing bodies in the same tick; an unknown one chains Email/get off
Thread/get with a back-reference, and falls back to fetching in parts when
the thread is longer than one Email/get may carry.

Conversations also start loading before the click: when the pointer rests
on a row, as soon as a press begins, and for the row below the open one.
The open waits for that load and does not repeat it.

Going back to one of the last twelve folders shows its previous list at
once, less messages that have left it, while the query runs.
This commit is contained in:
jcoffey
2026-09-16 13:15:21 -07:00
committed by GitHub
parent 5fe89d6e15
commit 786976312f
5 changed files with 261 additions and 8 deletions
+18
View File
@@ -183,6 +183,24 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
return -1;
}, [ids, focusId, openMessageId, threadId, rowThreadId]);
/*
* Reading down a folder usually means the next row is next. Once the open
* conversation has had its turn, the one below starts loading while the
* reader reads, so moving on waits on nothing.
*/
const nextRowId = threadId && currentRowIndex >= 0 ? ids[currentRowIndex + 1] : undefined;
const nextThreadId = nextRowId ? rowThreadId(nextRowId) : undefined;
useEffect(() => {
if (!nextThreadId || nextThreadId === threadId) return;
const start = () => useMail.getState().prefetchThread(nextThreadId);
if (typeof window.requestIdleCallback === "function") {
const handle = window.requestIdleCallback(start, { timeout: 2000 });
return () => window.cancelIdleCallback(handle);
}
const handle = window.setTimeout(start, 500);
return () => window.clearTimeout(handle);
}, [nextThreadId, threadId]);
/** Email ids affected by an action on rows (selection or focused/open row). */
const targetIds = useCallback(
async (rowIds?: Id[]): Promise<Id[]> => {
+25 -1
View File
@@ -1,4 +1,4 @@
import { Fragment, lazy, memo, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from "react";
import { Fragment, lazy, memo, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent, type PointerEvent, type ReactNode } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useShallow } from "zustand/react/shallow";
import { Archive, ArrowLeft, CalendarDays, CalendarRange, CalendarPlus, CheckSquare, FolderInput, PanelRight, PanelBottom, PanelTop, Filter, Inbox, Mail, MailOpen, MailPlus, MoreVertical, Paperclip, RefreshCw, Reply, Search, Star, Tag, Trash2, AlertOctagon, Forward, Eraser, ShieldCheck, X } from "lucide-react";
@@ -718,6 +718,27 @@ function RowView({ email: e, threadEmails, top, height, selected, focused, open,
},
});
/*
* A conversation starts loading when the pointer settles on its row, or the
* moment a finger or button goes down, so that on a slow link the click
* finds it on its way. Drafts open in the composer instead.
*/
const hover = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(hover.current), []);
const prefetch = () => {
if (!isDrafts) useMail.getState().prefetchThread(e.threadId);
};
const onPointerEnter = (ev: PointerEvent) => {
if (ev.pointerType !== "mouse") return;
window.clearTimeout(hover.current);
hover.current = window.setTimeout(prefetch, 80);
};
const onPointerLeave = () => window.clearTimeout(hover.current);
const onPointerDown = (ev: PointerEvent<HTMLDivElement>) => {
prefetch();
gesture.onPointerDown?.(ev);
};
const onDragStart = (ev: DragEvent) => {
// Read when the drag starts, so the row need not re-render on every change of selection.
const selectedIds = useMail.getState().selected;
@@ -754,6 +775,9 @@ function RowView({ email: e, threadEmails, top, height, selected, focused, open,
draggable={!touch}
onDragStart={onDragStart}
{...gesture}
onPointerDown={onPointerDown}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
role="row"
aria-selected={selected}
>