'use client'; /** * Virtualized message list with date dividers. */ 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 { MessageBubble } from './MessageBubble'; import { getSenderModelId } from '../sender'; import { formatDateDivider } from '../shared/formatters'; interface MessageListProps { 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 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}`; } 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; const lineHeight = 20; const replyHeight = message.in_reply_to ? 36 : 0; return baseHeight + Math.min(lineCount, 5) * lineHeight + replyHeight; } const ESTIMATED_DIVIDER_HEIGHT = 30; export const MessageList = memo(function MessageList({ roomId, messages, messagesEndRef, onInlineEdit, onViewHistory, onRevoke, onReply, onMention, onOpenUserCard, onLoadMore, hasMore = false, isLoadingMore = false, onOpenThread, onCreateThread, }: MessageListProps) { 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); const isInitialLoadRef = useRef(true); const wasNearBottomRef = useRef(true); // Reset initial load flag when switching rooms useEffect(() => { isInitialLoadRef.current = true; wasNearBottomRef.current = true; }, [roomId]); const replyMap = useMemo(() => { const map = new Map(); messages.forEach((m) => { if (m.id) map.set(m.id, m); }); return map; }, [messages]); const rows = useMemo(() => { const result: MessageRow[] = []; let lastDateKey: string | null = null; let lastSenderKey: string | null = null; let lastMessageTime: number | null = null; const GROUP_GAP_MS = 5 * 60 * 1000; // 5 minutes for (const message of messages) { const dateKey = getDateKey(message.send_at); const senderKey = getSenderKey(message); const msgTime = new Date(message.send_at).getTime(); if (dateKey !== lastDateKey) { result.push({ type: 'divider', label: formatDateDivider(message.send_at), key: `divider-${dateKey}-${message.id}`, }); lastDateKey = dateKey; lastSenderKey = null; lastMessageTime = null; } // Group if: same sender AND within 5-minute gap (Discord-style) const sameSender = senderKey === lastSenderKey; const withinTimeGap = lastMessageTime !== null && (msgTime - lastMessageTime) < GROUP_GAP_MS; const grouped = sameSender && withinTimeGap; result.push({ type: 'message', message, grouped, replyMessage: message.in_reply_to ? replyMap.get(message.in_reply_to) ?? null : null, key: message.id, }); lastSenderKey = senderKey; lastMessageTime = msgTime; } return result; }, [messages, replyMap]); const scrollToBottom = useCallback((smooth = true) => { const container = scrollContainerRef.current; if (container) { container.scrollTo({ top: container.scrollHeight, behavior: smooth ? 'smooth' : 'auto' }); } }, []); const handleScroll = useCallback(() => { const container = scrollContainerRef.current; if (!container) return; const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; const nearBottom = distanceFromBottom < 100; wasNearBottomRef.current = nearBottom; requestAnimationFrame(() => { setShowScrollToBottom(!nearBottom); }); if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current); scrollTimeoutRef.current = setTimeout(() => { 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]); useEffect(() => { if (messages.length === 0) return; const container = scrollContainerRef.current; if (!container) return; // On initial load, jump to bottom instantly (no animation) if (isInitialLoadRef.current) { isInitialLoadRef.current = false; wasNearBottomRef.current = true; // Use requestAnimationFrame to wait for virtualizer to layout requestAnimationFrame(() => { requestAnimationFrame(() => { scrollToBottom(false); }); }); return; } // For new messages: auto-scroll only if user was near bottom const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; if (distanceFromBottom < 150) { wasNearBottomRef.current = true; 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); }} id={row.message ? `msg-${row.message.id}` : undefined} data-index={virtualRow.index} data-message-id={row.message?.id} >
); })}
{showScrollToBottom && ( )}
); });