"use client"; /** * T-158 — the shared ACTIVITY feed (spec §14: "User activity feed and space * activity feed"). The API (done 2026-10-11) serves two permission-scoped, * newest-first, keyset-paginated projections over `audit_logs` (A-007): * * - user: GET /users/me/activity * - space: GET /workspaces/:id/activity * * Both return `{ data: ActivityRow[], nextCursor }` with `actorName` * resolved server-side. This component is the ONE reader for both (same row * shape, same ordering); the parent supplies the fetch + where a resource * deep-links (a user row carries its own `workspaceId`; a space row is * already scoped to the current workspace). */ import { useCallback, useEffect, useRef, useState } from "react"; import { Activity as ActivityIcon, ArrowUpRight, Bot, Database, FileText, Loader2, ListTodo, MessageSquare, Puzzle, Square, User, Users, Workflow } from "lucide-react"; import { cn, formatRelativeTime } from "@/lib/utils"; import type { ActivityRow } from "@/lib/types"; export type { ActivityRow }; export interface ActivityFeedProps { /** Fetch one page (cursor-paginated; first call passes no cursor). */ fetchPage: (cursor?: string) => Promise<{ data: ActivityRow[]; nextCursor: string | null }>; /** Where a linkable resource navigates (null = no deep link). */ hrefFor: (row: ActivityRow) => string | null; emptyMessage?: string; } const RESOURCE_ICON: Record> = { page: FileText, whiteboard: Square, database: Database, record: Square, block: Square, task: ListTodo, comment: MessageSquare, file: FileText, template: FileText, automation: Workflow, user: User, workspace: Users, team: Users, form: Puzzle }; /** `page.version_restored` → "restored a page version" (the feed renders * the audit `action` as a verb phrase, not the machine name). */ function summarize(action: string, resourceType: string): string { const [domain = "action", verb] = action.split(".", 2); const v = (verb ?? domain).replace(/_/g, " "); const noun = resourceType === "record" ? "a record" : resourceType === "block" ? "a block" : domain === "comment" ? "a comment" : resourceType.startsWith("ai") ? "an AI feature" : `a ${resourceType}`; return `${v} ${noun}`; } export function ActivityFeed({ fetchPage, hrefFor, emptyMessage }: ActivityFeedProps) { const [rows, setRows] = useState([]); const [nextCursor, setNextCursor] = useState(null); const [status, setStatus] = useState<"loading" | "ready" | "error">("loading"); const [loadingMore, setLoadingMore] = useState(false); // Guards against an out-of-order fetchPage settling after a newer one. const reqRef = useRef(0); useEffect(() => { let cancelled = false; const my = ++reqRef.current; setStatus("loading"); fetchPage() .then((res) => { if (cancelled || my !== reqRef.current) return; setRows(res.data); setNextCursor(res.nextCursor); setStatus("ready"); }) .catch(() => { if (!cancelled && my === reqRef.current) setStatus("error"); }); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const loadMore = useCallback(async () => { if (!nextCursor || loadingMore) return; setLoadingMore(true); const my = reqRef.current; try { const res = await fetchPage(nextCursor); if (my === reqRef.current) { setRows((r) => [...r, ...res.data]); setNextCursor(res.nextCursor); } } finally { if (my === reqRef.current) setLoadingMore(false); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [nextCursor, loadingMore]); if (status === "loading") { return (

Loading activity…

); } if (status === "error") { return (

Activity is temporarily unavailable.

); } if (rows.length === 0) { return (

{emptyMessage ?? "No activity yet."}

); } return (
    {rows.map((row) => { const Icon = RESOURCE_ICON[row.resourceType] ?? ActivityIcon; const link = hrefFor(row); return (
  1. { if (!link) e.preventDefault(); }} className={cn( "flex items-center gap-3 px-1 py-2.5", link ? "hover:bg-accent/50" : "cursor-default" )} > {row.actorType === "SYSTEM" ? ( ) : ( {(row.actorName ?? "?").slice(0, 2)} )}
    {row.actorType === "SYSTEM" ? "System" : row.actorName ?? "Someone"} {" "} {summarize(row.action, row.resourceType)}{" "} {formatRelativeTime(row.createdAt)}
  2. ); })}
{nextCursor ? ( ) : null}
); }