397 lines
13 KiB
TypeScript
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;
|