import { motion } from "framer-motion"; import { Hash } from "lucide-react"; import { useEffect, useMemo, useRef } from "react"; import { parseMetadata } from "../../../entities/message/types"; import type { MessageRecord } from "../../../shared/api/client"; import { cardItem, cardStagger } from "../../../shared/hooks/useFramerStagger"; import { ScrollArea, EmptyStateMascot } from "../../../shared/ui"; import { MessageCard, MessageCardSkeleton } from "./MessageCard"; export interface MessageFeedProps { messages: MessageRecord[]; onReanalyze: (id: string) => Promise; emptyText?: string; loading?: boolean; onLoadMore?: () => void; hasMore?: boolean; loadingMore?: boolean; } /** Messages from the same user within 5 minutes are visually grouped. */ const GROUP_WINDOW_MS = 5 * 60 * 1000; interface UserMessageGroup { messages: MessageRecord[]; } function groupByUser(messages: MessageRecord[]): UserMessageGroup[] { const groups: UserMessageGroup[] = []; for (const msg of messages) { const lastGroup = groups[groups.length - 1]; if ( lastGroup && lastGroup.messages[0].user_id === msg.user_id && lastGroup.messages[lastGroup.messages.length - 1].created_at - msg.created_at < GROUP_WINDOW_MS ) { lastGroup.messages.push(msg); } else { groups.push({ messages: [msg] }); } } return groups; } interface ChannelSection { key: string; channelId: string; threadId: string | null; label: string; groups: UserMessageGroup[]; } function computeChannelSections(messages: MessageRecord[]): ChannelSection[] { if (messages.length === 0) return []; // Group by (channel_id, thread_id) const map = new Map(); for (const msg of messages) { const key = msg.thread_id ? `${msg.channel_id}:t:${msg.thread_id}` : msg.channel_id; const list = map.get(key); if (list) list.push(msg); else map.set(key, [msg]); } const sections: ChannelSection[] = []; for (const [key, channelMessages] of map) { const first = channelMessages[0]; const meta = parseMetadata(first.metadata); const ch = meta?.channel; let label: string; if (ch?.threadName && ch?.channelName) { label = `# ${ch.channelName} › ${ch.threadName}`; } else if (ch?.channelName) { label = `# ${ch.channelName}`; } else if (first.thread_id) { label = `thread:${first.thread_id.slice(0, 8)}`; } else { label = `# ${first.channel_id.slice(0, 8)}`; } sections.push({ key, channelId: first.channel_id, threadId: first.thread_id, label, groups: groupByUser(channelMessages), }); } return sections; } export function MessageFeed({ messages, onReanalyze, emptyText: _emptyText, loading, onLoadMore, hasMore, loadingMore, }: MessageFeedProps) { // IntersectionObserver for infinite scroll — fires when sentinel becomes visible const sentinelRef = useRef(null); useEffect(() => { if (!onLoadMore || !hasMore) return; const el = sentinelRef.current; if (!el) return; const observer = new IntersectionObserver( (entries) => { if (entries[0]?.isIntersecting) onLoadMore(); }, { rootMargin: "400px" }, ); observer.observe(el); return () => observer.disconnect(); }, [onLoadMore, hasMore]); const sections = useMemo(() => computeChannelSections(messages), [messages]); if (loading) { return (
{[1, 2, 3, 4, 5].map((i) => ( ))}
); } if (messages.length === 0) { return ; } return ( {sections.map((section) => (
{/* Channel/Thread section header */}
{section.label}
{section.groups.map((group) => ( ))}
))} {/* Infinite-scroll sentinel */} {hasMore && (
{loadingMore ? ( ) : (
)}
)} ); }