import React, { useMemo, useState } from 'react'; import { FileTextIcon, MessageSquareIcon } from 'lucide-react'; import type { ICopilotThreadSummary } from '../../../service/copilot/copilot.interface'; import type { ArticleSummary } from '../../../service/visibility/visibility.interface'; import { cn } from '../cn'; /** How many rows the list shows. */ const MAX_RECENT = 6; /** What kind of thing a row is, which decides its icon and its filter. */ type ActivityKind = 'chat' | 'content'; /** The filter tabs, in display order. */ const ACTIVITY_FILTERS: ReadonlyArray<{ value: 'all' | ActivityKind; label: string; }> = [ { value: 'all', label: 'All' }, { value: 'chat', label: 'Chats' }, { value: 'content', label: 'Content' }, ]; /** Icon and colour per kind, so the list is scannable by shape. */ const KIND_STYLE: Record< ActivityKind, { icon: typeof MessageSquareIcon; color: string } > = { chat: { icon: MessageSquareIcon, color: 'text-amber-700' }, content: { icon: FileTextIcon, color: 'text-brand' }, }; /** One row in the list. */ interface ActivityRow { key: string; kind: ActivityKind; label: string; stampMs: number; open: () => void; } /** * A stamp as a sortable number, with the undated sorting last. * * @param stamp - ISO timestamp. * @return Milliseconds, or 0 when there is no usable stamp. */ function toMillis(stamp: string | null | undefined): number { const parsed: number = Date.parse(stamp ?? ''); return Number.isNaN(parsed) ? 0 : parsed; } /** * A stamp as a short relative age. * * @param stampMs - When it happened. * @param nowMs - The current time, passed in so this stays a pure render. * @return Something like "2h ago", or an empty string. */ function relativeAge(stampMs: number, nowMs: number): string { if (!stampMs) return ''; const minutes: number = Math.max(0, Math.round((nowMs - stampMs) / 60000)); if (minutes < 60) return `${minutes}m ago`; const hours: number = Math.round(minutes / 60); if (hours < 24) return `${hours}h ago`; return `${Math.round(hours / 24)}d ago`; } /** * Recently updated: the conversations and the content, newest first, so the * merchant lands back on whatever they were last doing. * * @param props - Component props. * @param props.threads - Saved conversations. * @param props.articles - The brand's content. * @param props.nowMs - Current time, for the relative ages. * @param props.onOpenThread - Open a conversation. * @param props.onOpenArticle - Open a piece of content. * @return The list, or nothing when there is no history yet. */ export function RecentActivity({ threads, articles, nowMs, onOpenThread, onOpenArticle, }: { threads: ICopilotThreadSummary[]; articles: ArticleSummary[]; nowMs: number; onOpenThread: (threadId: string) => void; onOpenArticle: (articleId: string) => void; }): React.ReactElement | null { const [activeFilter, setActiveFilter] = useState<'all' | ActivityKind>('all'); const rows: ActivityRow[] = useMemo( () => [ ...threads.map((thread: ICopilotThreadSummary) => ({ key: `thread-${thread.thread_id}`, kind: 'chat' as ActivityKind, label: thread.title || 'Conversation', stampMs: toMillis(thread.updated_at), open: () => onOpenThread(thread.thread_id), })), ...articles.map((article: ArticleSummary) => ({ key: `article-${article.article_id}`, kind: 'content' as ActivityKind, label: article.title, stampMs: toMillis(article.updated_at ?? article.created_at), open: () => onOpenArticle(article.article_id), })), ].sort((left, right) => right.stampMs - left.stampMs), [articles, onOpenArticle, onOpenThread, threads] ); if (rows.length === 0) return null; const visibleRows: ActivityRow[] = rows .filter(row => activeFilter === 'all' || row.kind === activeFilter) .slice(0, MAX_RECENT); return (

Recently updated

{ACTIVITY_FILTERS.map(filter => ( ))}
{visibleRows.map((row: ActivityRow, index: number) => { const style = KIND_STYLE[row.kind]; const Icon = style.icon; return ( ); })}
); }