Private
Public Access
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:
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user