From 77809758a2c094054083bcbbc745489489616409 Mon Sep 17 00:00:00 2001 From: Keith Smith Date: Tue, 18 Aug 2026 17:55:06 -0600 Subject: [PATCH] Fix chat view not landing on the latest message after switching rooms (#50) MessageList auto-scrolled to the bottom in a useEffect keyed on messages.length, but .message-image has no reserved width/height (only max-width/max-height caps) -- unlike UserAvatar and LinkPreviewCard's thumbnail, which both reserve fixed pixel dimensions. If a message near the bottom of a room's history has an image attachment, that scroll ran before the image loaded; the image then grew the container a moment later, leaving the view scrolled short of the true bottom until the user scrolled down manually. Now tracks whether the view is pinned to the bottom (via a scroll listener) and re-runs the scroll whenever any image inside the list finishes loading, but only while still pinned -- a late-loading image in history you've deliberately scrolled up to read won't yank you back down. A single capture-phase 'load' listener on the container catches every image (load doesn't bubble, but capture-phase listeners on an ancestor still see it) without wiring an onLoad prop through each one. Verified with a direct A/B comparison against the pre-fix code: same scrolled-away state, same synthetic image load event -- old code never calls scrollIntoView, new code does and lands back at the bottom. Co-Authored-By: Claude Sonnet 5 --- frontend/src/components/MessageList.tsx | 37 +++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index 3557253..2a97ceb 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -71,7 +71,13 @@ interface MessageListProps { export function MessageList({ roomId, messages, members, myRooms, onEdit, onReact }: MessageListProps) { const { user } = useAuth() + const containerRef = useRef(null) const bottomRef = useRef(null) + // Whether the view should be pinned to the latest message -- true right + // after a room switch/new message, flipped off if the user deliberately + // scrolls away from the bottom. Read by the image-load handler below so a + // late-loading image doesn't yank someone back down mid-scrollback. + const pinnedToBottomRef = useRef(true) const [editingId, setEditingId] = useState(null) const [draft, setDraft] = useState('') const [lightboxSrc, setLightboxSrc] = useState(null) @@ -86,8 +92,35 @@ export function MessageList({ roomId, messages, members, myRooms, onEdit, onReac } useEffect(() => { + pinnedToBottomRef.current = true bottomRef.current?.scrollIntoView({ block: 'end' }) - }, [messages.length]) + }, [roomId, messages.length]) + + useEffect(() => { + const container = containerRef.current + if (!container) return + function handleScroll() { + if (!container) return + // Within 48px of the true bottom counts as "at the bottom" -- an + // exact-equality check would drop pinning from sub-pixel scroll + // rounding alone. + pinnedToBottomRef.current = + container.scrollHeight - container.scrollTop - container.clientHeight < 48 + } + // `load` doesn't bubble, but a capture-phase listener on an ancestor + // still sees it fire on the way down -- lets one listener catch every + // image in the list (message attachments and link-preview thumbnails + // alike) without wiring an onLoad prop through each of them. + function handleContentGrow() { + if (pinnedToBottomRef.current) bottomRef.current?.scrollIntoView({ block: 'end' }) + } + container.addEventListener('scroll', handleScroll, { passive: true }) + container.addEventListener('load', handleContentGrow, true) + return () => { + container.removeEventListener('scroll', handleScroll) + container.removeEventListener('load', handleContentGrow, true) + } + }, []) function startEdit(msg: Message | ChatMessageEnvelope) { setEditingId(msg.id) @@ -101,7 +134,7 @@ export function MessageList({ roomId, messages, members, myRooms, onEdit, onReac } return ( -
+
{messages.map((msg, i) => { const mine = msg.user_id === user?.id const prev = messages[i - 1]