149 lines
4.9 KiB
TypeScript
149 lines
4.9 KiB
TypeScript
import React, { useEffect, useRef, useCallback, useState, useMemo } from 'react';
|
|
import { Message } from './Message';
|
|
import { useChatStore } from '../../stores/chatStore';
|
|
import { LoadingSpinner } from '../ui/LoadingSpinner';
|
|
import type { MessageWithUser } from '@opencord/shared';
|
|
|
|
const EMPTY_MESSAGES: MessageWithUser[] = [];
|
|
|
|
interface MessageListProps {
|
|
channelId: string;
|
|
}
|
|
|
|
function isSameGroup(prev: MessageWithUser, curr: MessageWithUser): boolean {
|
|
if (prev.userId !== curr.userId) return false;
|
|
const timeDiff = curr.createdAt - prev.createdAt;
|
|
return timeDiff < 5 * 60 * 1000; // 5 minutes
|
|
}
|
|
|
|
function formatDateDivider(timestamp: number): string {
|
|
const date = new Date(timestamp);
|
|
return date.toLocaleDateString(undefined, {
|
|
weekday: 'long',
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
});
|
|
}
|
|
|
|
function shouldShowDateDivider(prev: MessageWithUser | undefined, curr: MessageWithUser): boolean {
|
|
if (!prev) return true;
|
|
const prevDate = new Date(prev.createdAt).toDateString();
|
|
const currDate = new Date(curr.createdAt).toDateString();
|
|
return prevDate !== currDate;
|
|
}
|
|
|
|
export function MessageList({ channelId }: MessageListProps) {
|
|
const messages = useChatStore((s) => s.messages.get(channelId)) ?? EMPTY_MESSAGES;
|
|
const loadMessages = useChatStore((s) => s.loadMessages);
|
|
const loadMoreMessages = useChatStore((s) => s.loadMoreMessages);
|
|
const isLoading = useChatStore((s) => s.isLoading);
|
|
const hasMore = useChatStore((s) => s.hasMore.get(channelId) ?? true);
|
|
const bottomRef = useRef<HTMLDivElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [isNearBottom, setIsNearBottom] = useState(true);
|
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
const prevMessagesLength = useRef(0);
|
|
|
|
useEffect(() => {
|
|
loadMessages(channelId);
|
|
}, [channelId, loadMessages]);
|
|
|
|
// Auto-scroll to bottom on new messages (if near bottom)
|
|
useEffect(() => {
|
|
if (messages.length > prevMessagesLength.current && isNearBottom) {
|
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
}
|
|
prevMessagesLength.current = messages.length;
|
|
}, [messages.length, isNearBottom]);
|
|
|
|
// Scroll to bottom on initial load
|
|
useEffect(() => {
|
|
if (messages.length > 0 && prevMessagesLength.current === 0) {
|
|
bottomRef.current?.scrollIntoView();
|
|
}
|
|
}, [messages.length]);
|
|
|
|
const handleScroll = useCallback(async () => {
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
|
|
// Check if near bottom
|
|
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
|
setIsNearBottom(distanceFromBottom < 100);
|
|
|
|
// Load more when scrolled to top
|
|
if (container.scrollTop < 50 && hasMore && !isLoadingMore) {
|
|
setIsLoadingMore(true);
|
|
const prevScrollHeight = container.scrollHeight;
|
|
const loaded = await loadMoreMessages(channelId);
|
|
if (loaded) {
|
|
// Maintain scroll position
|
|
requestAnimationFrame(() => {
|
|
container.scrollTop = container.scrollHeight - prevScrollHeight;
|
|
});
|
|
}
|
|
setIsLoadingMore(false);
|
|
}
|
|
}, [channelId, hasMore, isLoadingMore, loadMoreMessages]);
|
|
|
|
if (isLoading && messages.length === 0) {
|
|
return (
|
|
<div className="flex-1 flex items-center justify-center">
|
|
<LoadingSpinner />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className="flex-1 overflow-y-auto overflow-x-hidden"
|
|
onScroll={handleScroll}
|
|
>
|
|
{isLoadingMore && (
|
|
<div className="py-4">
|
|
<LoadingSpinner size={24} />
|
|
</div>
|
|
)}
|
|
|
|
{!hasMore && (
|
|
<div className="px-4 pt-6 pb-4">
|
|
<h3 className="text-2xl font-bold text-discord-text-primary">Welcome to the channel!</h3>
|
|
<p className="text-discord-text-muted text-sm mt-1">This is the start of the conversation.</p>
|
|
<div className="mt-4 border-b border-discord-bg-hover" />
|
|
</div>
|
|
)}
|
|
|
|
<div className="pb-6">
|
|
{messages.map((msg, i) => {
|
|
const prevMsg = messages[i - 1];
|
|
const showDate = shouldShowDateDivider(prevMsg, msg);
|
|
const isFirstInGroup = !prevMsg || showDate || !isSameGroup(prevMsg, msg);
|
|
|
|
return (
|
|
<React.Fragment key={msg.id}>
|
|
{showDate && (
|
|
<div className="flex items-center px-4 my-4">
|
|
<div className="flex-1 border-t border-discord-bg-hover" />
|
|
<span className="px-2 text-xs font-semibold text-discord-text-muted">
|
|
{formatDateDivider(msg.createdAt)}
|
|
</span>
|
|
<div className="flex-1 border-t border-discord-bg-hover" />
|
|
</div>
|
|
)}
|
|
<Message
|
|
message={msg}
|
|
isCompact={!isFirstInGroup}
|
|
isFirstInGroup={isFirstInGroup}
|
|
/>
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div ref={bottomRef} />
|
|
</div>
|
|
);
|
|
}
|