gitdataai/src/components/room/RoomMessageList.tsx

397 lines
13 KiB
TypeScript

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<HTMLDivElement | null>;
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<HTMLDivElement>(null);
const topSentinelRef = useRef<HTMLDivElement>(null);
const prevScrollHeightRef = useRef<number | null>(null);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const scrollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isRestoringScrollRef = useRef(false);
const firstVisibleMessageIdRef = useRef<string | null>(null);
// Build reply lookup map (stable reference, recomputes only when messages change)
const replyMap = useMemo(() => {
const map = new Map<string, MessageWithMeta>();
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<MessageRow[]>(() => {
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 (
<div className="flex flex-1 items-center justify-center px-6 py-12">
<div className="rounded-lg border border-dashed border-border/70 bg-muted/20 px-6 py-8 text-center">
<p className="text-sm font-medium text-foreground">No messages yet</p>
<p className="mt-1 text-xs text-muted-foreground">Start the conversation below.</p>
</div>
<div ref={messagesEndRef} />
</div>
);
}
return (
<div className="relative min-h-0 flex-1">
<div ref={scrollContainerRef} className="h-full overflow-y-auto">
<div
className="relative"
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
}}
>
<div ref={topSentinelRef} className="absolute top-0 h-1 w-full" />
{isLoadingMore && (
<div className="sticky top-0 z-10 flex items-center justify-center bg-background/80 py-3 backdrop-blur-sm">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
{virtualItems.map((virtualRow) => {
const row = rows[virtualRow.index];
if (!row) return null;
if (row.type === 'divider') {
return (
<div
key={row.key}
className="absolute left-0 w-full"
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<div className="flex items-center gap-3 px-4 py-2">
<div className="h-px flex-1 bg-border/70" />
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{row.label}
</span>
<div className="h-px flex-1 bg-border/70" />
</div>
</div>
);
}
return (
<div
key={row.key}
className="absolute left-0 top-0 w-full"
style={{
transform: `translateY(${virtualRow.start}px)`,
}}
>
<div
ref={(el) => {
if (el) virtualizer.measureElement(el);
}}
data-index={virtualRow.index}
data-message-id={row.message?.id}
>
<RoomMessageBubble
roomId={roomId}
message={row.message!}
replyMessage={row.replyMessage ?? null}
grouped={row.grouped}
showDate={!row.grouped}
onInlineEdit={onInlineEdit}
onViewHistory={onViewHistory}
onRevoke={onRevoke}
onReply={onReply}
onMention={onMention}
onOpenUserCard={onOpenUserCard}
onOpenThread={onOpenThread}
onCreateThread={onCreateThread}
/>
</div>
</div>
);
})}
<div
ref={messagesEndRef}
className="absolute left-0 w-full"
style={{
height: '1px',
transform: `translateY(${virtualizer.getTotalSize()}px)`,
}}
/>
</div>
</div>
{showScrollToBottom && (
<Button
variant="secondary"
size="icon"
className="absolute bottom-4 right-4 z-10 h-9 w-9 rounded-full shadow-md border border-border/70"
onClick={() => scrollToBottom(true)}
title="Scroll to bottom"
>
<ArrowDown className="h-4 w-4" />
</Button>
)}
</div>
);
});
export { RoomMessageListInner };
export const RoomMessageList = RoomMessageListInner;