- Remove duplicate smooth scroll effect from DiscordChatPanel; handle all scroll logic in MessageList instead - MessageList: track isInitialLoadRef to instant-jump to bottom on first load (no animation), and only auto-scroll for new messages when user is already near the bottom - sender.ts: getSenderDisplayName rejects UUID values and falls back to 'AI' for AI messages; getSenderModelId uses display_name
409 lines
13 KiB
TypeScript
409 lines
13 KiB
TypeScript
'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<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 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<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);
|
|
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<string, MessageWithMeta>();
|
|
messages.forEach((m) => {
|
|
if (m.id) map.set(m.id, m);
|
|
});
|
|
return map;
|
|
}, [messages]);
|
|
|
|
const rows = useMemo<MessageRow[]>(() => {
|
|
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 (
|
|
<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);
|
|
}}
|
|
id={row.message ? `msg-${row.message.id}` : undefined}
|
|
data-index={virtualRow.index}
|
|
data-message-id={row.message?.id}
|
|
>
|
|
<MessageBubble
|
|
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>
|
|
);
|
|
});
|