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 (
{isGroupStart && ( )}
{isGroupStart && (
{displayNameFor(msg.username, members)} {new Date(msg.created_at).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
)} {editing ? (