Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import React, { useEffect, useState } from "react";
2import { Link } from "react-router-dom";
3import { getNotifications, markNotificationsRead } from "../../api/client";
4import type { NotificationItem, AnnotationItem } from "../../types";
5import {
6 Heart,
7 MessageCircle,
8 Bell,
9 PenTool,
10 Bookmark,
11 UserPlus,
12 AtSign,
13 ExternalLink,
14} from "lucide-react";
15import { formatDistanceToNow } from "date-fns";
16import { clsx } from "clsx";
17import { Avatar, EmptyState, Skeleton } from "../../components/ui";
18
19function getContentType(
20 uri: string,
21): "annotation" | "highlight" | "bookmark" | "reply" | "unknown" {
22 if (uri.includes("/at.margin.annotation/")) return "annotation";
23 if (uri.includes("/at.margin.highlight/")) return "highlight";
24 if (uri.includes("/at.margin.bookmark/")) return "bookmark";
25 if (uri.includes("/at.margin.reply/")) return "reply";
26 return "unknown";
27}
28
29function getNotificationVerb(
30 notifType: string,
31 contentType: string,
32 subject?: AnnotationItem,
33): string {
34 switch (notifType) {
35 case "like":
36 switch (contentType) {
37 case "annotation":
38 return "liked your annotation";
39 case "highlight":
40 return "liked your highlight";
41 case "bookmark":
42 return "liked your bookmark";
43 case "reply":
44 return "liked your reply";
45 default:
46 return "liked your post";
47 }
48 case "reply": {
49 const parentUri = subject?.inReplyTo;
50 const parentIsReply = parentUri
51 ? getContentType(parentUri) === "reply"
52 : false;
53 return parentIsReply
54 ? "replied to your reply"
55 : "replied to your annotation";
56 }
57 case "mention":
58 return "mentioned you in an annotation";
59 case "follow":
60 return "followed you";
61 case "highlight":
62 return "highlighted your page";
63 default:
64 return notifType;
65 }
66}
67
68const NotificationIcon = ({ type }: { type: string }) => {
69 const base = "p-2 rounded-full";
70 switch (type) {
71 case "like":
72 return (
73 <div className={clsx(base, "bg-red-100 dark:bg-red-900/30")}>
74 <Heart size={15} className="text-red-500" />
75 </div>
76 );
77 case "reply":
78 return (
79 <div className={clsx(base, "bg-blue-100 dark:bg-blue-900/30")}>
80 <MessageCircle size={15} className="text-blue-500" />
81 </div>
82 );
83 case "highlight":
84 return (
85 <div className={clsx(base, "bg-yellow-100 dark:bg-yellow-900/30")}>
86 <PenTool size={15} className="text-yellow-600" />
87 </div>
88 );
89 case "bookmark":
90 return (
91 <div className={clsx(base, "bg-green-100 dark:bg-green-900/30")}>
92 <Bookmark size={15} className="text-green-600" />
93 </div>
94 );
95 case "follow":
96 return (
97 <div className={clsx(base, "bg-purple-100 dark:bg-purple-900/30")}>
98 <UserPlus size={15} className="text-purple-500" />
99 </div>
100 );
101 case "mention":
102 return (
103 <div className={clsx(base, "bg-indigo-100 dark:bg-indigo-900/30")}>
104 <AtSign size={15} className="text-indigo-500" />
105 </div>
106 );
107 default:
108 return (
109 <div className={clsx(base, "bg-surface-100 dark:bg-surface-800")}>
110 <Bell size={15} className="text-surface-500" />
111 </div>
112 );
113 }
114};
115
116function SubjectPreview({
117 subject,
118 subjectUri,
119}: {
120 subject: AnnotationItem | unknown;
121 subjectUri: string;
122}) {
123 const item = subject as AnnotationItem | undefined;
124 if (!item?.uri && !subjectUri) return null;
125
126 const contentType = getContentType(subjectUri);
127 const href = `/annotation/${encodeURIComponent(subjectUri)}`;
128
129 let preview: React.ReactNode = null;
130
131 if (contentType === "annotation") {
132 const quote = item?.target?.selector?.exact;
133 const body = item?.text || item?.body?.value;
134 preview = (
135 <>
136 {quote && (
137 <p className="text-surface-500 dark:text-surface-400 text-xs italic line-clamp-2 mb-1">
138 “{quote}”
139 </p>
140 )}
141 {body && (
142 <p className="text-surface-700 dark:text-surface-300 text-sm line-clamp-2">
143 {body}
144 </p>
145 )}
146 </>
147 );
148 } else if (contentType === "highlight") {
149 const quote = item?.target?.selector?.exact;
150 preview = quote ? (
151 <p className="text-surface-500 dark:text-surface-400 text-xs italic line-clamp-2">
152 “{quote}”
153 </p>
154 ) : null;
155 } else if (contentType === "bookmark") {
156 const title = item?.title || item?.target?.title;
157 const source = item?.source || item?.target?.source;
158 preview = (
159 <>
160 {title && (
161 <p className="text-surface-700 dark:text-surface-300 text-sm font-medium line-clamp-1">
162 {title}
163 </p>
164 )}
165 {source && (
166 <p className="text-surface-400 dark:text-surface-500 text-xs line-clamp-1 mt-0.5 flex items-center gap-1">
167 <ExternalLink size={10} className="shrink-0" />
168 {(() => {
169 try {
170 return new URL(source).hostname;
171 } catch {
172 return source;
173 }
174 })()}
175 </p>
176 )}
177 </>
178 );
179 } else if (contentType === "reply") {
180 const text = item?.text;
181 const parentUri = item?.inReplyTo;
182 const parentIsReply = parentUri
183 ? getContentType(parentUri) === "reply"
184 : false;
185 preview = (
186 <>
187 {text && (
188 <p className="text-surface-700 dark:text-surface-300 text-sm line-clamp-2">
189 {text}
190 </p>
191 )}
192 {parentUri && (
193 <p className="text-surface-400 dark:text-surface-500 text-xs mt-1">
194 in reply to{" "}
195 <Link
196 to={`/annotation/${encodeURIComponent(parentUri)}`}
197 className="hover:underline text-primary-500"
198 onClick={(e) => e.stopPropagation()}
199 >
200 {parentIsReply ? "a reply" : "an annotation"}
201 </Link>
202 </p>
203 )}
204 </>
205 );
206 }
207
208 if (!preview) return null;
209
210 return (
211 <Link
212 to={href}
213 className="block mt-2 pl-3 border-l-2 border-surface-200 dark:border-surface-700 hover:border-primary-400 dark:hover:border-primary-500 transition-colors group"
214 >
215 {preview}
216 </Link>
217 );
218}
219
220export default function Notifications() {
221 const [notifications, setNotifications] = useState<NotificationItem[]>([]);
222 const [loading, setLoading] = useState(true);
223
224 useEffect(() => {
225 const load = async () => {
226 setLoading(true);
227 const data = await getNotifications();
228 setNotifications(data);
229 setLoading(false);
230 markNotificationsRead();
231 };
232 load();
233 }, []);
234
235 if (loading) {
236 return (
237 <div className="max-w-2xl mx-auto animate-fade-in">
238 <h1 className="text-3xl font-display font-bold text-surface-900 dark:text-white mb-6">
239 Activity
240 </h1>
241 <div className="space-y-3">
242 {[1, 2, 3].map((i) => (
243 <div key={i} className="card p-4 flex gap-3">
244 <Skeleton variant="circular" className="w-10 h-10" />
245 <div className="flex-1 space-y-2">
246 <Skeleton width="60%" />
247 <Skeleton width="40%" />
248 </div>
249 </div>
250 ))}
251 </div>
252 </div>
253 );
254 }
255
256 if (notifications.length === 0) {
257 return (
258 <div className="max-w-2xl mx-auto animate-fade-in">
259 <h1 className="text-3xl font-display font-bold text-surface-900 dark:text-white mb-6">
260 Activity
261 </h1>
262 <EmptyState
263 icon={<Bell size={48} />}
264 title="No activity yet"
265 message="Interactions with your content will appear here."
266 />
267 </div>
268 );
269 }
270
271 return (
272 <div className="max-w-2xl mx-auto animate-slide-up">
273 <h1 className="text-3xl font-display font-bold text-surface-900 dark:text-white mb-6">
274 Activity
275 </h1>
276 <div className="space-y-2">
277 {notifications.map((n) => {
278 const contentType = getContentType(n.subjectUri || "");
279 const verb = getNotificationVerb(
280 n.type,
281 contentType,
282 n.subject as AnnotationItem,
283 );
284 const timeAgo = formatDistanceToNow(new Date(n.createdAt), {
285 addSuffix: false,
286 });
287
288 return (
289 <div
290 key={n.id}
291 className={clsx(
292 "card p-4 transition-all",
293 !n.readAt &&
294 "ring-2 ring-primary-500/20 dark:ring-primary-400/20 bg-primary-50/30 dark:bg-primary-900/10",
295 )}
296 >
297 <div className="flex gap-3">
298 <div className="shrink-0 mt-0.5">
299 <NotificationIcon type={n.type} />
300 </div>
301 <div className="flex-1 min-w-0">
302 <div className="flex items-start gap-2 flex-wrap">
303 <Link to={`/profile/${n.actor.did}`} className="shrink-0">
304 <Avatar src={n.actor.avatar} size="xs" />
305 </Link>
306 <div className="flex-1 min-w-0">
307 <span className="text-surface-500 dark:text-surface-400 text-sm">
308 <Link
309 to={`/profile/${n.actor.did}`}
310 className="font-semibold text-surface-900 dark:text-white hover:underline"
311 >
312 {n.actor.displayName || `@${n.actor.handle}`}
313 </Link>{" "}
314 {n.type !== "follow" && n.subjectUri ? (
315 <Link
316 to={`/annotation/${encodeURIComponent(n.subjectUri)}`}
317 className="hover:underline"
318 >
319 {verb}
320 </Link>
321 ) : (
322 verb
323 )}
324 </span>
325 <span className="text-surface-400 dark:text-surface-500 text-xs ml-1.5">
326 {timeAgo}
327 </span>
328 </div>
329 </div>
330
331 {n.subject !== undefined && n.subject !== null && (
332 <SubjectPreview
333 subject={n.subject}
334 subjectUri={n.subjectUri || ""}
335 />
336 )}
337 </div>
338 </div>
339 </div>
340 );
341 })}
342 </div>
343 </div>
344 );
345}