123 lines
3.2 KiB
TypeScript
123 lines
3.2 KiB
TypeScript
import { motion } from "framer-motion";
|
|
import { useEffect, useMemo, useRef } from "react";
|
|
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<void>;
|
|
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 MessageGroup {
|
|
messages: MessageRecord[];
|
|
}
|
|
|
|
function groupMessages(messages: MessageRecord[]): MessageGroup[] {
|
|
const groups: MessageGroup[] = [];
|
|
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;
|
|
}
|
|
|
|
export function MessageFeed({
|
|
messages,
|
|
onReanalyze,
|
|
emptyText: _emptyText,
|
|
loading,
|
|
onLoadMore,
|
|
hasMore,
|
|
loadingMore,
|
|
}: MessageFeedProps) {
|
|
// IntersectionObserver for infinite scroll — fires when sentinel becomes visible
|
|
const sentinelRef = useRef<HTMLDivElement | null>(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 groupedMessages = useMemo(() => groupMessages(messages), [messages]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<ScrollArea className="h-[calc(100vh-260px)] pr-3">
|
|
<div className="space-y-3">
|
|
{[1, 2, 3, 4, 5].map((i) => (
|
|
<MessageCardSkeleton key={i} />
|
|
))}
|
|
</div>
|
|
</ScrollArea>
|
|
);
|
|
}
|
|
|
|
if (messages.length === 0) {
|
|
return <EmptyStateMascot />;
|
|
}
|
|
|
|
return (
|
|
<ScrollArea className="h-[calc(100vh-260px)] pr-3">
|
|
<motion.div
|
|
className="space-y-3"
|
|
variants={cardStagger}
|
|
initial="initial"
|
|
animate="animate"
|
|
>
|
|
{groupedMessages.map((group) => (
|
|
<motion.div key={group.messages[0].id} variants={cardItem}>
|
|
<MessageCard
|
|
messages={group.messages}
|
|
onReanalyze={onReanalyze}
|
|
/>
|
|
</motion.div>
|
|
))}
|
|
|
|
{/* Infinite-scroll sentinel */}
|
|
{hasMore && (
|
|
<div
|
|
ref={sentinelRef}
|
|
className="flex items-center justify-center py-4"
|
|
>
|
|
{loadingMore ? (
|
|
<MessageCardSkeleton />
|
|
) : (
|
|
<div className="h-2 w-2 rounded-full bg-primary/40" />
|
|
)}
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
</ScrollArea>
|
|
);
|
|
}
|