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 (
)
}
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 = (
{file.filename}{formatFileSize(file.size_bytes)}
)
if (getPreviewKind(file.filename)) {
return (
)
}
return (
{info}
)
}
interface MessageListProps {
roomId: string
messages: (Message | ChatMessageEnvelope)[]
members: RoomMember[]
myRooms: Map
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(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)
const [reactingId, setReactingId] = useState(null)
const [reactionPlacement, setReactionPlacement] = useState<'above' | 'below'>('below')
const [previewFile, setPreviewFile] = useState(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 (
{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 (