From 650ba0020b654b2f95526c73be0f8d9073063db4 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Thu, 27 Aug 2026 07:11:16 -0700 Subject: [PATCH] Hold the opening scroll while the conversation settles Opening an already-read conversation stopped 39px short of the bottom, every time (#89). The messages were all there and one scroll fixed it, but the pane was not where it meant to be. The scroll runs in an effect, which is too early. Message bodies go into shadow roots from the child effects underneath it, and the images in those load later still, so the pane goes on growing after the scroll has already happened -- and `scrollIntoView` clamps to the scroll range as it stands the moment it is called. The read-thread fallback aims at the last message, which no thread has the room to lift to the top, so that clamp *is* the whole of the range. Measuring it before the images landed measured it short. So the target is now held against the top of the pane while the thread settles: a ResizeObserver over the children of the scroller re-aligns it whenever one of them changes height. The hold ends the instant the reader touches the pane -- wheel, pointer, touch or any key -- and after two seconds regardless. A pane that re-scrolls under someone who has started reading is far worse than one that lands short, so it lets go on the first sign of them rather than waiting for the content to stop changing. Verified against the mock, on the same already-read seven-message thread, eight opens each way: before, all eight landed at scrollTop 96 of a 135 range; after, all eight land at 135. The #87 cases are unchanged -- an unread message mid-thread still comes to rest flush against the top of the pane, and a thread whose first message is the unread one still stays at 0 with the subject in view. Scrolling or pressing a key during the hold leaves the pane exactly where it was put. One correction to #89 while I am here: it reported the pane sometimes not moving at all. That was an artifact of measuring in a background tab, where Chrome suspends rendering and clamps timers -- the behaviour in a visible tab is the deterministic 39px above. The issue is real; that one observation in it was not. --- web/src/views/mail/ThreadView.tsx | 52 ++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/web/src/views/mail/ThreadView.tsx b/web/src/views/mail/ThreadView.tsx index 6c45c7e..11e2ce8 100644 --- a/web/src/views/mail/ThreadView.tsx +++ b/web/src/views/mail/ThreadView.tsx @@ -12,6 +12,9 @@ import { client } from "@/jmap/client"; import { LabelPicker } from "./LabelPicker"; import { threadScrollTarget } from "@/lib/threadScroll"; +/** How long the opening scroll keeps its place while bodies and images land. */ +const HOLD_MS = 2000; + interface Props { threadId: Id; mailboxId: Id | null; @@ -116,13 +119,54 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h // eslint-disable-next-line react-hooks/exhaustive-deps }, [messages.map((m) => m.id + (m.keywords.$seen ? "1" : "0")).join(","), settings.markReadDelay]); - // Open on the first unread message rather than the newest one (#87). + /* + * Open on the first unread message rather than the newest one (#87). + * + * Scrolling once is not enough. Message bodies are written into shadow roots + * by child effects, and the images in them load later still, so the pane goes + * on growing after the scroll -- and `scrollIntoView` clamps to the scroll + * range as it stands the moment it is called. The read-thread fallback always + * aims at the last message, which no thread has the room to lift to the top, + * so that clamp is the whole of the range: measuring it before the images + * landed stopped 39px short of the bottom, every time (#89). + * + * So the target is held against the top of the pane while the thread settles, + * and let go the moment the reader touches it. A pane that re-scrolls under + * someone who has started reading is worse than one that lands short, which + * is why the hold ends on the first sign of them rather than when the content + * stops changing. + */ useEffect(() => { - if (!messages.length || !scrollRef.current) return; + const sc = scrollRef.current; + if (!messages.length || !sc) return; const target = threadScrollTarget(messages, wasUnread); if (!target) return; - const el = scrollRef.current.querySelector(`[data-msg-id="${CSS.escape(target)}"]`); - el?.scrollIntoView({ block: "start" }); + + let held = true; + const align = () => { + if (held) sc.querySelector(`[data-msg-id="${CSS.escape(target)}"]`)?.scrollIntoView({ block: "start" }); + }; + const release = () => { + held = false; + }; + + align(); + + // The messages and the reply box: what grows is one of their heights. + const ro = new ResizeObserver(align); + for (const child of sc.children) ro.observe(child); + // `scroll` is not in here: the aligning does that itself. + for (const ev of ["wheel", "pointerdown", "touchstart"]) sc.addEventListener(ev, release, { passive: true }); + window.addEventListener("keydown", release); + const settled = window.setTimeout(release, HOLD_MS); + + return () => { + release(); + ro.disconnect(); + for (const ev of ["wheel", "pointerdown", "touchstart"]) sc.removeEventListener(ev, release); + window.removeEventListener("keydown", release); + window.clearTimeout(settled); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [threadId, messages.length > 0]);