From bb20f234f39d9208b79df3583616775775a0ce22 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:38:06 -0700 Subject: [PATCH] Always scroll to bottom of chat after sending a message (#10913) --- .../Messages/components/MessagesList.tsx | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index 1976e973a3..2b3c48ad98 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -200,6 +200,21 @@ export function MessagesList({ // the bottom. const isAtBottom = useSharedValue(true) + // Set when the local user sends a message so we follow it to the end even + // from a scrolled-up position. On native, onContentSizeChange can't be relied + // on: with maintainVisibleContentPosition anchored to item 0, appending a + // message below the viewport reports no content-size change, so that callback + // never fires for the send. We watch the rendered item count instead and + // scroll imperatively once our pending message lands (APP-2223). On web, + // onContentSizeChange also fires for the send, so the scroll may run from both + // paths - both target the end, so the result is correct. + const pendingSendScroll = useRef(false) + + // Handle for the in-flight send-scroll burst (see startSendScrollBurst). Held + // here so the re-init effect below can cancel a burst that belongs to the + // previous convo lifecycle. + const sendScrollRaf = useRef(0) + // This will be used on web to assist in determining if we need to maintain the content offset const isAtTop = useSharedValue(true) @@ -220,6 +235,12 @@ export function MessagesList({ if (prevHasScrolled.current && !hasScrolled) { hasInitiallyScrolled.current = false setDidInitialScroll(false) + // Drop any unfired send pin and stop an in-flight scroll burst: the + // initial-scroll path owns positioning during re-init, and the pending + // message they referred to belongs to the previous lifecycle. + pendingSendScroll.current = false + cancelAnimationFrame(sendScrollRaf.current) + sendScrollRaf.current = 0 } prevHasScrolled.current = hasScrolled }, [hasScrolled]) @@ -253,6 +274,59 @@ export function MessagesList({ } }, [convoState.status]) + // Scroll to a saturating offset rather than scrollToEnd: when the keyboard is + // open, KeyboardChatScrollView lifts the content via extraContentPadding, and + // scrollToEnd's internal target is unaware of that lift, so it lands short by + // the keyboard height. An over-large offset clamps to the true bottom. + const scrollSendToBottom = useCallback(() => { + flatListRef.current?.scrollToOffset({ + offset: Number.MAX_SAFE_INTEGER, + animated: true, + }) + }, [flatListRef]) + + // A single scroll can't follow a send to the bottom: a multi-line send settles + // over several layout passes (the tall pending item being measured, then the + // composer collapsing back to one line), and the content bottom keeps moving + // after the scroll target was clamped. We can't drive this off the composer's + // height drop either - that signal is global, outlives the send, and races the + // pending-message append. Instead we re-assert the saturating scroll across a + // short window keyed to the send. Each call re-clamps to the *current* true + // bottom, so the last one lands settled regardless of how many passes it took. + // The burst is bounded and self-terminating, so it can't leak into a later + // unrelated resize, and it polls geometry rather than depending on + // onContentSizeChange (which doesn't fire for the send append on native - see + // the pendingSendScroll declaration). + const stopSendScrollBurst = useCallback(() => { + cancelAnimationFrame(sendScrollRaf.current) + sendScrollRaf.current = 0 + }, []) + const startSendScrollBurst = useCallback(() => { + stopSendScrollBurst() + const deadline = Date.now() + 200 + const tick = () => { + scrollSendToBottom() + sendScrollRaf.current = + Date.now() < deadline ? requestAnimationFrame(tick) : 0 + } + tick() + }, [scrollSendToBottom, stopSendScrollBurst]) + + // Cancel any in-flight burst on unmount. + useEffect(() => stopSendScrollBurst, [stopSendScrollBurst]) + + // Follow a just-sent message to the end. This runs when the rendered item + // count changes, but only fires once the tail item is our own optimistic + // pending message (pending-message items are local-only). That way a foreign + // message arriving between send and our append doesn't consume the pin or yank + // a scrolled-up reader down to it - the pin waits for our message to land. + useEffect(() => { + if (!pendingSendScroll.current) return + if (renderItems.at(-1)?.type !== 'pending-message') return + pendingSendScroll.current = false + startSendScrollBurst() + }, [renderItems, startSendScrollBurst]) + // -- Scroll handling // Every time the content size changes, that means one of two things is happening: @@ -479,6 +553,11 @@ export function MessagesList({ setHasScrolled(true) } + // Sending your own message should always take you to it, regardless of + // current scroll position. The effect watching renderItems.length scrolls + // to the end once the pending message is appended. + pendingSendScroll.current = true + convoState.sendMessage( { text: rt.text,