import type { MessageWithMeta } from '@/contexts'; import { Button } from '@/components/ui/button'; import { ArrowDown, Loader2 } from 'lucide-react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { RoomMessageBubble } from './RoomMessageBubble'; import { getSenderModelId } from './sender'; interface RoomMessageListProps { roomId: string; messages: MessageWithMeta[]; messagesEndRef: React.RefObject; onInlineEdit?: (message: MessageWithMeta, newContent: string) => void; onViewHistory?: (message: MessageWithMeta) => void; onRevoke?: (message: MessageWithMeta) => void; onReply?: (message: MessageWithMeta) => void; onMention?: (name: string, type: 'user' | 'ai') => void; onOpenUserCard?: (payload: { username: string; displayName?: string | null; avatarUrl?: string | null; userId: string; point: { x: number; y: number }; }) => void; onLoadMore?: () => void; hasMore?: boolean; isLoadingMore?: boolean; onOpenThread?: (message: MessageWithMeta) => void; onCreateThread?: (message: MessageWithMeta) => void; } interface MessageRow { type: 'divider' | 'message'; label?: string; message?: MessageWithMeta; grouped?: boolean; replyMessage?: MessageWithMeta | null; key: string; } function getDateKey(iso: string): string { return new Date(iso).toDateString(); } function formatDateDivider(iso: string): string { const date = new Date(iso); const now = new Date(); const today = now.toDateString(); const yesterday = new Date(now.getTime() - 86400000).toDateString(); const key = date.toDateString(); if (key === today) return 'Today'; if (key === yesterday) return 'Yesterday'; return date.toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', }); } function getSenderKey(message: MessageWithMeta): string { const userUid = message.sender_id; if (userUid) return `user:${userUid}`; if (message.sender_type === 'ai') { const modelId = getSenderModelId(message); if (modelId) return `ai-model:${modelId}`; return `ai-streaming:${message.id}`; } return `sender:${message.sender_type}`; } /** Estimate message row height based on content characteristics */ function estimateMessageRowHeight(message: MessageWithMeta): number { const lineCount = message.content.split(/\r?\n/).reduce((total, line) => { return total + Math.max(1, Math.ceil(line.trim().length / 90)); }, 0); const baseHeight = 24; // avatar + padding const lineHeight = 20; const replyHeight = message.in_reply_to ? 36 : 0; return baseHeight + Math.min(lineCount, 5) * lineHeight + replyHeight; } const ESTIMATED_DIVIDER_HEIGHT = 30; const RoomMessageListInner = memo(function RoomMessageListInner({ roomId, messages, messagesEndRef, onInlineEdit, onViewHistory, onRevoke, onReply, onMention, onOpenUserCard, onLoadMore, hasMore = false, isLoadingMore = false, onOpenThread, onCreateThread, }: RoomMessageListProps) { const scrollContainerRef = useRef(null); const topSentinelRef = useRef(null); const prevScrollHeightRef = useRef(null); const [showScrollToBottom, setShowScrollToBottom] = useState(false); const scrollTimeoutRef = useRef | null>(null); const isRestoringScrollRef = useRef(false); const firstVisibleMessageIdRef = useRef(null); // Build reply lookup map (stable reference, recomputes only when messages change) const replyMap = useMemo(() => { const map = new Map(); messages.forEach((m) => { if (m.id) map.set(m.id, m); }); return map; }, [messages]); // Build rows: date dividers + messages // Use a separate Map to avoid rows depending on replyMap (which changes reference) const rows = useMemo(() => { const result: MessageRow[] = []; let lastDateKey: string | null = null; let lastSenderKey: string | null = null; for (const message of messages) { const dateKey = getDateKey(message.send_at); const senderKey = getSenderKey(message); if (dateKey !== lastDateKey) { result.push({ type: 'divider', label: formatDateDivider(message.send_at), key: `divider-${dateKey}-${message.id}`, }); lastDateKey = dateKey; lastSenderKey = null; } const grouped = senderKey === lastSenderKey; result.push({ type: 'message', message, grouped, replyMessage: message.in_reply_to ? replyMap.get(message.in_reply_to) ?? null : null, key: message.id, }); lastSenderKey = senderKey; } return result; }, [messages, replyMap]); const scrollToBottom = useCallback((smooth = true) => { messagesEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' }); }, [messagesEndRef]); // Lightweight scroll handler: only update the "show" flag, decoupled from layout reads // Reads scroll position synchronously but defers state updates so browser can paint first. const handleScroll = useCallback(() => { const container = scrollContainerRef.current; if (!container) return; // Synchronous read of scroll position (triggers layout, unavoidable) const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; const nearBottom = distanceFromBottom < 100; // Update state asynchronously — browser can process other frames before committing requestAnimationFrame(() => { setShowScrollToBottom(!nearBottom); }); // Reset user-scrolling flag after delay if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current); scrollTimeoutRef.current = setTimeout(() => { // Only clear if still at bottom const c = scrollContainerRef.current; if (c) { const dist = c.scrollHeight - c.scrollTop - c.clientHeight; if (dist < 100) setShowScrollToBottom(false); } }, 500); }, []); useEffect(() => { const container = scrollContainerRef.current; if (!container) return; container.addEventListener('scroll', handleScroll, { passive: true }); return () => { container.removeEventListener('scroll', handleScroll); if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current); }; }, [handleScroll]); // Auto-scroll when new messages arrive (only if user was already at bottom) useEffect(() => { if (messages.length === 0) return; // Check if near bottom before scheduling scroll const container = scrollContainerRef.current; if (!container) return; const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; if (distanceFromBottom < 100) { requestAnimationFrame(() => scrollToBottom(false)); } }, [messages.length, scrollToBottom]); const virtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => scrollContainerRef.current, estimateSize: (index) => { const row = rows[index]; if (row?.type === 'divider') return ESTIMATED_DIVIDER_HEIGHT; if (row?.type === 'message' && row.message) return estimateMessageRowHeight(row.message); return 60; }, overscan: 30, gap: 0, }); const virtualItems = virtualizer.getVirtualItems(); // IntersectionObserver for load more useEffect(() => { const sentinel = topSentinelRef.current; const container = scrollContainerRef.current; if (!sentinel || !container || !onLoadMore) return; const observer = new IntersectionObserver( (entries) => { if (entries[0]?.isIntersecting && !isLoadingMore && hasMore) { prevScrollHeightRef.current = container.scrollHeight; const items = virtualizer.getVirtualItems(); if (items.length > 0) { const firstVisibleRow = rows[items[0].index]; if (firstVisibleRow?.type === 'message' && firstVisibleRow.message) { firstVisibleMessageIdRef.current = firstVisibleRow.message.id; } } isRestoringScrollRef.current = true; onLoadMore(); } }, { root: container, threshold: 0.1 }, ); observer.observe(sentinel); return () => observer.disconnect(); }, [onLoadMore, hasMore, isLoadingMore, rows, virtualizer]); // Maintain scroll position after loading more messages useEffect(() => { if (!isRestoringScrollRef.current) return; const container = scrollContainerRef.current; if (!container || prevScrollHeightRef.current === null) { isRestoringScrollRef.current = false; return; } const delta = container.scrollHeight - prevScrollHeightRef.current; if (firstVisibleMessageIdRef.current) { const el = container.querySelector(`[data-message-id="${firstVisibleMessageIdRef.current}"]`); if (el) { el.scrollIntoView({ block: 'start' }); prevScrollHeightRef.current = null; firstVisibleMessageIdRef.current = null; isRestoringScrollRef.current = false; return; } } if (delta > 0) container.scrollTop += delta; prevScrollHeightRef.current = null; firstVisibleMessageIdRef.current = null; isRestoringScrollRef.current = false; }, [messages.length]); if (messages.length === 0) { return (

No messages yet

Start the conversation below.

); } return (
{isLoadingMore && (
)} {virtualItems.map((virtualRow) => { const row = rows[virtualRow.index]; if (!row) return null; if (row.type === 'divider') { return (
{row.label}
); } return (
{ if (el) virtualizer.measureElement(el); }} data-index={virtualRow.index} data-message-id={row.message?.id} >
); })}
{showScrollToBottom && ( )}
); }); export { RoomMessageListInner }; export const RoomMessageList = RoomMessageListInner;