1"use client";
2
3
17import { useCallback, useEffect, useRef, useState } from "react";
18import {
19 Activity as ActivityIcon,
20 ArrowUpRight,
21 Bot,
22 Database,
23 FileText,
24 Loader2,
25 ListTodo,
26 MessageSquare,
27 Puzzle,
28 Square,
29 User,
30 Users,
31 Workflow
32} from "lucide-react";
33import { cn, formatRelativeTime } from "@/lib/utils";
34import type { ActivityRow } from "@/lib/types";
35
36export type { ActivityRow };
37
38export interface ActivityFeedProps {
39
40 fetchPage: (cursor?: string) => Promise<{ data: ActivityRow[]; nextCursor: string | null }>;
41
42 hrefFor: (row: ActivityRow) => string | null;
43 emptyMessage?: string;
44}
45
46const RESOURCE_ICON: Record<string, React.ComponentType<{ className?: string }>> = {
47 page: FileText,
48 whiteboard: Square,
49 database: Database,
50 record: Square,
51 block: Square,
52 task: ListTodo,
53 comment: MessageSquare,
54 file: FileText,
55 template: FileText,
56 automation: Workflow,
57 user: User,
58 workspace: Users,
59 team: Users,
60 form: Puzzle
61};
62
63
65function summarize(action: string, resourceType: string): string {
66 const [domain = "action", verb] = action.split(".", 2);
67 const v = (verb ?? domain).replace(/_/g, " ");
68 const noun =
69 resourceType === "record"
70 ? "a record"
71 : resourceType === "block"
72 ? "a block"
73 : domain === "comment"
74 ? "a comment"
75 : resourceType.startsWith("ai")
76 ? "an AI feature"
77 : `a ${resourceType}`;
78 return `${v} ${noun}`;
79}
80
81export function ActivityFeed({ fetchPage, hrefFor, emptyMessage }: ActivityFeedProps) {
82 const [rows, setRows] = useState<ActivityRow[]>([]);
83 const [nextCursor, setNextCursor] = useState<string | null>(null);
84 const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
85 const [loadingMore, setLoadingMore] = useState(false);
86
87 const reqRef = useRef(0);
88
89 useEffect(() => {
90 let cancelled = false;
91 const my = ++reqRef.current;
92 setStatus("loading");
93 fetchPage()
94 .then((res) => {
95 if (cancelled || my !== reqRef.current) return;
96 setRows(res.data);
97 setNextCursor(res.nextCursor);
98 setStatus("ready");
99 })
100 .catch(() => {
101 if (!cancelled && my === reqRef.current) setStatus("error");
102 });
103 return () => {
104 cancelled = true;
105 };
106
107 }, []);
108
109 const loadMore = useCallback(async () => {
110 if (!nextCursor || loadingMore) return;
111 setLoadingMore(true);
112 const my = reqRef.current;
113 try {
114 const res = await fetchPage(nextCursor);
115 if (my === reqRef.current) {
116 setRows((r) => [...r, ...res.data]);
117 setNextCursor(res.nextCursor);
118 }
119 } finally {
120 if (my === reqRef.current) setLoadingMore(false);
121 }
122
123 }, [nextCursor, loadingMore]);
124
125 if (status === "loading") {
126 return (
127 <p className="flex items-center gap-2 py-8 text-sm text-muted-foreground" role="status">
128 <Loader2 className="size-4 animate-spin" aria-hidden /> Loading activity…
129 </p>
130 );
131 }
132
133 if (status === "error") {
134 return (
135 <p className="py-8 text-sm text-muted-foreground" role="alert">
136 Activity is temporarily unavailable.
137 </p>
138 );
139 }
140
141 if (rows.length === 0) {
142 return (
143 <p className="py-8 text-center text-sm text-muted-foreground">
144 {emptyMessage ?? "No activity yet."}
145 </p>
146 );
147 }
148
149 return (
150 <div>
151 <ol data-testid="activity-feed" className="divide-y divide-border" aria-label="Activity">
152 {rows.map((row) => {
153 const Icon = RESOURCE_ICON[row.resourceType] ?? ActivityIcon;
154 const link = hrefFor(row);
155 return (
156 <li key={row.id}>
157 <a
158 data-testid="activity-item"
159 href={link ?? undefined}
160 aria-disabled={!link}
161 tabIndex={link ? undefined : -1}
162 onClick={(e) => {
163 if (!link) e.preventDefault();
164 }}
165 className={cn(
166 "flex items-center gap-3 px-1 py-2.5",
167 link ? "hover:bg-accent/50" : "cursor-default"
168 )}
169 >
170 {row.actorType === "SYSTEM" ? (
171 <span className="flex size-7 shrink-0 items-center justify-center" aria-hidden>
172 <Bot className="size-4 text-muted-foreground" />
173 </span>
174 ) : (
175 <span
176 className="flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-[11px] font-medium uppercase text-muted-foreground"
177 aria-hidden
178 >
179 {(row.actorName ?? "?").slice(0, 2)}
180 </span>
181 )}
182 <div className="min-w-0 flex-1 text-sm">
183 <span className="text-foreground" data-testid="activity-actor">
184 {row.actorType === "SYSTEM" ? "System" : row.actorName ?? "Someone"}
185 </span>{" "}
186 <span>{summarize(row.action, row.resourceType)}</span>{" "}
187 <span className="text-muted-foreground">{formatRelativeTime(row.createdAt)}</span>
188 </div>
189 <ArrowUpRight
190 className={cn("size-4 shrink-0", link ? "text-muted-foreground" : "text-muted-foreground/30")}
191 aria-hidden
192 />
193 </a>
194 </li>
195 );
196 })}
197 </ol>
198 {nextCursor ? (
199 <button
200 type="button"
201 data-testid="activity-load-more"
202 onClick={() => void loadMore()}
203 disabled={loadingMore}
204 className="mt-1 flex w-full items-center justify-center gap-2 py-2 text-xs text-muted-foreground hover:text-foreground disabled:opacity-50"
205 >
206 {loadingMore ? <Loader2 className="size-3.5 animate-spin" aria-hidden /> : null}
207 Load more
208 </button>
209 ) : null}
210 </div>
211 );
212}
213