From 76170648f5792543cbfbcd18b3d966cc749a8ace Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:29:25 +0200 Subject: [PATCH] fix: open a long conversation at its first unread message --- src/screens/EmailThreadScreen.tsx | 50 +++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index 2ef49a6a..1c994aa6 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -57,6 +57,12 @@ type Props = NativeStackScreenProps & { onActiveChange?: (emailId: string) => void; }; +// How much of the card above the opened one stays in view when a long +// conversation opens scrolled to it. +const OPENED_CARD_PEEK = 56; +/** How long after opening the viewer may still scroll to the open card. */ +const REVEAL_WINDOW_MS = 2000; + // How long the pages beside the one on screen wait for its body at most. const NEIGHBOUR_FALLBACK_MS = 1500; @@ -1027,6 +1033,32 @@ function EmailPane({ // renders the skeleton first, and a hook after it crashed the app. const scrollRef = React.useRef(null); const isInputFocused = React.useRef(false); + const cardsTopRef = React.useRef(0); + // The cards above the open one settle over a few frames (their rows fill + // in), so the reveal waits until the open card stops moving, gives up + // after a moment, and never fights a scroll the user started. + const revealDoneRef = React.useRef(false); + const revealTimerRef = React.useRef | null>(null); + const mountedAtRef = React.useRef(Date.now()); + const viewportHeightRef = React.useRef(0); + const revealOpenedCard = React.useCallback((cardY: number) => { + if (revealDoneRef.current || Date.now() - mountedAtRef.current > REVEAL_WINDOW_MS) return; + if (revealTimerRef.current) clearTimeout(revealTimerRef.current); + revealTimerRef.current = setTimeout(() => { + if (revealDoneRef.current) return; + // Only when the card starts low on the screen (a short conversation + // is fine as it is); keep the bottom of the previous card in sight so + // it reads as a thread. + const top = cardsTopRef.current + cardY; + if (top < viewportHeightRef.current * 0.6) return; + scrollRef.current?.scrollTo({ y: top - OPENED_CARD_PEEK, animated: false }); + }, 250); + }, []); + const stopReveal = React.useCallback(() => { + revealDoneRef.current = true; + if (revealTimerRef.current) clearTimeout(revealTimerRef.current); + }, []); + React.useEffect(() => stopReveal, [stopReveal]); React.useEffect(() => { if (kbObstruction > 0 && isInputFocused.current) { setTimeout(() => { @@ -1074,6 +1106,7 @@ function EmailPane({ // A single message goes through the same card list (bare), so its content // and WebView stay mounted when a conversation arrives around it. const members = conversation ?? [shown]; + const firstOpenId = conversation?.find((m) => expanded?.has(m.id) ?? m.id === id)?.id; const newest = conversation ? conversation[conversation.length - 1] : email; // The quick reply quotes the message, so it waits for the newest one's body. const newestLoaded = !!newest && !!peekDetail(newest.id, jmapAccountId); @@ -1107,6 +1140,8 @@ function EmailPane({ contentContainerStyle={[styles.paneContent, { paddingBottom: bottomPadding }]} keyboardShouldPersistTaps="handled" keyboardDismissMode="on-drag" + onScrollBeginDrag={stopReveal} + onLayout={(e) => { viewportHeightRef.current = e.nativeEvent.layout.height; }} scrollEnabled={!pinching} > {/* Subject block */} @@ -1144,13 +1179,16 @@ function EmailPane({ - + { cardsTopRef.current = e.nativeEvent.layout.y; }} + > {Array.from({ length: pendingCards }, (_, i) => )} {members.map((m) => { const open = !asConversation || (expanded?.has(m.id) ?? m.id === id); // An open card without its body yet shows its header over placeholder lines. const full = peekDetail(m.id, jmapAccountId); - return ( + const card = ( ); + // In a long conversation the open messages (the opened one, and + // unread ones) sit below the older, collapsed cards. Bring the first + // open card into view once, like Gmail, instead of opening on the + // oldest message. Only collapsed rows sit above it, so its place + // doesn't move as message bodies load. + return conversation && m.id === firstOpenId ? ( + revealOpenedCard(e.nativeEvent.layout.y)}>{card} + ) : card; })}