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 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 17:55:06 -06:00
co-authored by Claude Sonnet 5
parent aadf620014
commit 77809758a2
+35 -2
View File
@@ -71,7 +71,13 @@ interface MessageListProps {
export function MessageList({ roomId, messages, members, myRooms, onEdit, onReact }: MessageListProps) { export function MessageList({ roomId, messages, members, myRooms, onEdit, onReact }: MessageListProps) {
const { user } = useAuth() const { user } = useAuth()
const containerRef = useRef<HTMLDivElement>(null)
const bottomRef = useRef<HTMLDivElement>(null) const bottomRef = useRef<HTMLDivElement>(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<string | null>(null) const [editingId, setEditingId] = useState<string | null>(null)
const [draft, setDraft] = useState('') const [draft, setDraft] = useState('')
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null) const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
@@ -86,8 +92,35 @@ export function MessageList({ roomId, messages, members, myRooms, onEdit, onReac
} }
useEffect(() => { useEffect(() => {
pinnedToBottomRef.current = true
bottomRef.current?.scrollIntoView({ block: 'end' }) 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) { function startEdit(msg: Message | ChatMessageEnvelope) {
setEditingId(msg.id) setEditingId(msg.id)
@@ -101,7 +134,7 @@ export function MessageList({ roomId, messages, members, myRooms, onEdit, onReac
} }
return ( return (
<div className="message-list"> <div className="message-list" ref={containerRef}>
{messages.map((msg, i) => { {messages.map((msg, i) => {
const mine = msg.user_id === user?.id const mine = msg.user_id === user?.id
const prev = messages[i - 1] const prev = messages[i - 1]