Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 345 lines 11 kB view raw
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 &ldquo;{quote}&rdquo; 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 &ldquo;{quote}&rdquo; 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}