gitdataai/src/components/room/message/MessageList.tsx
ZhenYi 616c0c0e88 fix(room): scroll-to-bottom logic and AI sender display name
- 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
2026-04-25 09:52:58 +08:00

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>
);
});