Files
ds-chat/frontend/src/components/MessageList.tsx
T
ksmithandClaude Sonnet 5 77809758a2 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>
2026-08-18 17:55:06 -06:00

298 lines
12 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react'
import { getRoomFileUrl, getRoomImageUrl } from '../api/rooms'
import { useAuth } from '../context/AuthContext'
import { formatFileSize } from '../lib/fileSize'
import { avatarUrlFor, displayNameFor, senderColorIndex, statusFor } from '../lib/messageGrouping'
import type { ChatMessageEnvelope, Message, MessageFileInfo, RoomMember } from '../types'
import { EMOJI_PICKER_MAX_HEIGHT, EmojiPicker } from './EmojiPicker'
import { FilePreviewModal, getPreviewKind } from './FilePreviewModal'
import { ImageLightbox } from './ImageLightbox'
import { LinkPreviewCard } from './LinkPreviewCard'
import { MessageContent } from './MessageContent'
import { UserAvatar } from './UserAvatar'
import './MessageList.css'
export function FileAttachmentIcon() {
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path
d="M6 2.5h6l4 4V16a1.5 1.5 0 0 1-1.5 1.5h-8A1.5 1.5 0 0 1 5 16V4A1.5 1.5 0 0 1 6 2.5Z"
stroke="currentColor"
strokeWidth="1.4"
strokeLinejoin="round"
/>
<path d="M12 2.5V6a1 1 0 0 0 1 1h3.5" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round" />
</svg>
)
}
interface FileAttachmentCardProps {
file: MessageFileInfo
roomId: string
onPreview: () => void
}
// Previewable files (markdown/text) open a modal on click, with a small
// explicit download icon alongside; everything else keeps the original
// click-to-download behavior unchanged.
function FileAttachmentCard({ file, roomId, onPreview }: FileAttachmentCardProps) {
const info = (
<span className="message-file-info">
<span className="message-file-name">{file.filename}</span>
<span className="message-file-size">{formatFileSize(file.size_bytes)}</span>
</span>
)
if (getPreviewKind(file.filename)) {
return (
<button type="button" className="message-file-attachment" onClick={onPreview}>
<FileAttachmentIcon />
{info}
</button>
)
}
return (
<a href={getRoomFileUrl(roomId, file.id)} download={file.filename} className="message-file-attachment">
<FileAttachmentIcon />
{info}
</a>
)
}
interface MessageListProps {
roomId: string
messages: (Message | ChatMessageEnvelope)[]
members: RoomMember[]
myRooms: Map<string, string>
onEdit: (messageId: string, content: string) => void
onReact: (messageId: string, emoji: string) => void
}
export function MessageList({ roomId, messages, members, myRooms, onEdit, onReact }: MessageListProps) {
const { user } = useAuth()
const containerRef = 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 [draft, setDraft] = useState('')
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
const [reactingId, setReactingId] = useState<string | null>(null)
const [reactionPlacement, setReactionPlacement] = useState<'above' | 'below'>('below')
const [previewFile, setPreviewFile] = useState<MessageFileInfo | null>(null)
const memberUsernames = useMemo(() => new Set(members.map((m) => m.username)), [members])
function displayNameForUserId(userId: string): string {
const member = members.find((m) => m.user_id === userId)
return member?.display_name || member?.username || 'someone'
}
useEffect(() => {
pinnedToBottomRef.current = true
bottomRef.current?.scrollIntoView({ block: 'end' })
}, [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)
setDraft(msg.content ?? '')
}
function commitEdit(messageId: string) {
const trimmed = draft.trim()
if (trimmed) onEdit(messageId, trimmed)
setEditingId(null)
}
return (
<div className="message-list" ref={containerRef}>
{messages.map((msg, i) => {
const mine = msg.user_id === user?.id
const prev = messages[i - 1]
// Mattermost-style grouping: every message shows who sent it, but
// consecutive messages from the same sender only repeat the
// avatar/name/timestamp header on the first one in the run --
// applies uniformly, including to your own messages.
const isGroupStart = !prev || prev.user_id !== msg.user_id
const editing = editingId === msg.id
return (
<div key={msg.id} className={`message-row${isGroupStart ? ' message-row-start' : ''}`}>
<div className="message-avatar-slot">
{isGroupStart && (
<UserAvatar
username={msg.username}
colorIndex={senderColorIndex(msg.username, members)}
avatarUrl={avatarUrlFor(msg.username, members)}
status={statusFor(msg.username, members)}
/>
)}
</div>
<div className="message-content">
{isGroupStart && (
<div className="message-header">
<span className="message-author">{displayNameFor(msg.username, members)}</span>
<span className="message-time">
{new Date(msg.created_at).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
</span>
</div>
)}
{editing ? (
<textarea
autoFocus
rows={Math.min(10, draft.split('\n').length)}
className="message-edit-input"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
commitEdit(msg.id)
}
if (e.key === 'Escape') setEditingId(null)
}}
onBlur={() => commitEdit(msg.id)}
spellCheck
/>
) : (
<>
{msg.image_id && (
<img
src={getRoomImageUrl(roomId, msg.image_id)}
alt=""
className="message-image"
onClick={() => setLightboxSrc(getRoomImageUrl(roomId, msg.image_id!))}
/>
)}
{msg.file && (
<FileAttachmentCard
file={msg.file}
roomId={roomId}
onPreview={() => setPreviewFile(msg.file!)}
/>
)}
{msg.content && (
<div className="message-text">
<MessageContent content={msg.content} memberUsernames={memberUsernames} myRooms={myRooms} />
{msg.edited_at && <span className="message-edited"> (edited)</span>}
</div>
)}
{msg.link_preview && (
<LinkPreviewCard preview={msg.link_preview} onImageClick={setLightboxSrc} />
)}
{msg.reactions.length > 0 && (
<div className="message-reaction-pills">
{msg.reactions.map((r) => {
const mineReaction = !!user && r.user_ids.includes(user.id)
return (
<button
key={r.emoji}
type="button"
className={`message-reaction-pill${mineReaction ? ' message-reaction-pill-mine' : ''}`}
title={r.user_ids.map(displayNameForUserId).join(', ')}
onClick={() => onReact(msg.id, r.emoji)}
>
<span>{r.emoji}</span>
<span>{r.count}</span>
</button>
)
})}
</div>
)}
</>
)}
</div>
{!editing && (
<div className="message-row-actions">
<div className="message-reaction-wrap">
<button
type="button"
className="message-reaction-trigger"
onClick={(e) => {
if (reactingId === msg.id) {
setReactingId(null)
return
}
// Flip upward when the picker wouldn't fit below the
// trigger -- a message near the bottom of the
// scrolled list otherwise opens a picker that runs
// off-screen and can't be used.
const rect = e.currentTarget.getBoundingClientRect()
const spaceBelow = window.innerHeight - rect.bottom
setReactionPlacement(spaceBelow < EMOJI_PICKER_MAX_HEIGHT ? 'above' : 'below')
setReactingId(msg.id)
}}
aria-label="Add reaction"
>
🙂
</button>
{reactingId === msg.id && (
<EmojiPicker
onPick={(emoji) => {
onReact(msg.id, emoji)
setReactingId(null)
}}
onClose={() => setReactingId(null)}
placement={reactionPlacement}
align="right"
/>
)}
</div>
{mine && (
<button
type="button"
className="message-edit-link"
onClick={() => startEdit(msg)}
aria-label="Edit message"
>
Edit
</button>
)}
</div>
)}
</div>
)
})}
<div ref={bottomRef} />
{lightboxSrc && <ImageLightbox src={lightboxSrc} onClose={() => setLightboxSrc(null)} />}
{previewFile && (
<FilePreviewModal
roomId={roomId}
file={previewFile}
kind={getPreviewKind(previewFile.filename) ?? 'text'}
onClose={() => setPreviewFile(null)}
/>
)}
</div>
)
}