(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import React, { useState } from "react";
2import { formatDistanceToNow } from "date-fns";
3import RichText from "./RichText";
4import MoreMenu from "./MoreMenu";
5import type { MoreMenuItem } from "./MoreMenu";
6import {
7 MessageSquare,
8 Heart,
9 ExternalLink,
10 FolderPlus,
11 Trash2,
12 Edit3,
13 Globe,
14 ShieldBan,
15 VolumeX,
16 Flag,
17 EyeOff,
18 Eye,
19} from "lucide-react";
20import ShareMenu from "../modals/ShareMenu";
21import AddToCollectionModal from "../modals/AddToCollectionModal";
22import ExternalLinkModal from "../modals/ExternalLinkModal";
23import ReportModal from "../modals/ReportModal";
24import EditItemModal from "../modals/EditItemModal";
25import { clsx } from "clsx";
26import {
27 likeItem,
28 unlikeItem,
29 deleteItem,
30 blockUser,
31 muteUser,
32} from "../../api/client";
33import { $user } from "../../store/auth";
34import { $preferences } from "../../store/preferences";
35import { useStore } from "@nanostores/react";
36import type {
37 AnnotationItem,
38 ContentLabel,
39 LabelVisibility,
40} from "../../types";
41import { Link } from "react-router-dom";
42import { Avatar } from "../ui";
43import CollectionIcon from "./CollectionIcon";
44import ProfileHoverCard from "./ProfileHoverCard";
45
46const LABEL_DESCRIPTIONS: Record<string, string> = {
47 sexual: "Sexual Content",
48 nudity: "Nudity",
49 violence: "Violence",
50 gore: "Graphic Content",
51 spam: "Spam",
52 misleading: "Misleading",
53};
54
55function getContentWarning(
56 labels?: ContentLabel[],
57 prefs?: {
58 labelPreferences: {
59 labelerDid: string;
60 label: string;
61 visibility: LabelVisibility;
62 }[];
63 },
64): {
65 label: string;
66 description: string;
67 visibility: LabelVisibility;
68 isAccountWide: boolean;
69} | null {
70 if (!labels || labels.length === 0) return null;
71 const priority = [
72 "gore",
73 "violence",
74 "nudity",
75 "sexual",
76 "misleading",
77 "spam",
78 ];
79 for (const p of priority) {
80 const match = labels.find((l) => l.val === p);
81 if (match) {
82 const pref = prefs?.labelPreferences.find(
83 (lp) => lp.label === p && lp.labelerDid === match.src,
84 );
85 const visibility: LabelVisibility = pref?.visibility || "warn";
86 if (visibility === "ignore") continue;
87 return {
88 label: p,
89 description: LABEL_DESCRIPTIONS[p] || p,
90 visibility,
91 isAccountWide: match.scope === "account",
92 };
93 }
94 }
95 return null;
96}
97
98interface CardProps {
99 item: AnnotationItem;
100 onDelete?: (uri: string) => void;
101 onUpdate?: (item: AnnotationItem) => void;
102 hideShare?: boolean;
103}
104
105export default function Card({
106 item: initialItem,
107 onDelete,
108 onUpdate,
109 hideShare,
110}: CardProps) {
111 const [item, setItem] = useState(initialItem);
112 const user = useStore($user);
113 const preferences = useStore($preferences);
114 const isAuthor = user && item.author?.did === user.did;
115
116 const [liked, setLiked] = useState(!!item.viewer?.like);
117 const [likes, setLikes] = useState(item.likeCount || 0);
118 const [showCollectionModal, setShowCollectionModal] = useState(false);
119 const [showExternalLinkModal, setShowExternalLinkModal] = useState(false);
120 const [externalLinkUrl, setExternalLinkUrl] = useState<string | null>(null);
121 const [showReportModal, setShowReportModal] = useState(false);
122 const [showEditModal, setShowEditModal] = useState(false);
123 const [contentRevealed, setContentRevealed] = useState(false);
124 const [ogData, setOgData] = useState<{
125 title?: string;
126 description?: string;
127 image?: string;
128 icon?: string;
129 } | null>(null);
130 const [imgError, setImgError] = useState(false);
131 const [iconError, setIconError] = useState(false);
132
133 const contentWarning = getContentWarning(item.labels, preferences);
134
135 React.useEffect(() => {
136 setItem(initialItem);
137 }, [initialItem]);
138
139 React.useEffect(() => {
140 setLiked(!!item.viewer?.like);
141 setLikes(item.likeCount || 0);
142 }, [item.viewer?.like, item.likeCount]);
143
144 const type =
145 item.motivation === "highlighting"
146 ? "highlight"
147 : item.motivation === "bookmarking"
148 ? "bookmark"
149 : "annotation";
150
151 const isSemble =
152 item.uri?.includes("network.cosmik") || item.uri?.includes("semble");
153
154 const safeUrlHostname = (url: string | null | undefined) => {
155 if (!url) return null;
156 try {
157 return new URL(url).hostname;
158 } catch {
159 return null;
160 }
161 };
162
163 const pageUrl = item.target?.source || item.source;
164 const isBookmark = type === "bookmark";
165
166 React.useEffect(() => {
167 if (isBookmark && item.uri && !ogData && pageUrl) {
168 const fetchMetadata = async () => {
169 try {
170 const res = await fetch(
171 `/api/url-metadata?url=${encodeURIComponent(pageUrl)}`,
172 );
173 if (res.ok) {
174 const data = await res.json();
175 setOgData(data);
176 }
177 } catch (e) {
178 console.error("Failed to fetch metadata", e);
179 }
180 };
181 fetchMetadata();
182 }
183 }, [isBookmark, item.uri, pageUrl, ogData]);
184
185 if (contentWarning?.visibility === "hide") return null;
186
187 const handleLike = async () => {
188 const prev = { liked, likes };
189 setLiked(!liked);
190 setLikes((l) => (liked ? l - 1 : l + 1));
191
192 const success = liked
193 ? await unlikeItem(item.uri)
194 : await likeItem(item.uri, item.cid);
195
196 if (!success) {
197 setLiked(prev.liked);
198 setLikes(prev.likes);
199 }
200 };
201
202 const handleDelete = async () => {
203 if (window.confirm("Delete this item?")) {
204 const success = await deleteItem(item.uri, type);
205 if (success && onDelete) onDelete(item.uri);
206 }
207 };
208
209 const handleExternalClick = (e: React.MouseEvent, url: string) => {
210 e.preventDefault();
211 e.stopPropagation();
212
213 try {
214 const hostname = safeUrlHostname(url);
215 if (hostname) {
216 if (
217 hostname === "margin.at" ||
218 hostname.endsWith(".margin.at") ||
219 hostname === "semble.so" ||
220 hostname.endsWith(".semble.so")
221 ) {
222 window.open(url, "_blank", "noopener,noreferrer");
223 return;
224 }
225 const skipped = $preferences.get().externalLinkSkippedHostnames;
226 if (skipped.includes(hostname)) {
227 window.open(url, "_blank", "noopener,noreferrer");
228 return;
229 }
230 }
231 } catch (err) {
232 if (err instanceof Error && err.name !== "TypeError") {
233 console.debug("Failed to check skipped hostname:", err);
234 }
235 }
236
237 setExternalLinkUrl(url);
238 setShowExternalLinkModal(true);
239 };
240
241 const timestamp = item.createdAt
242 ? formatDistanceToNow(new Date(item.createdAt), { addSuffix: false })
243 .replace("less than a minute", "just now")
244 .replace("about ", "")
245 .replace(" hours", "h")
246 .replace(" hour", "h")
247 .replace(" minutes", "m")
248 .replace(" minute", "m")
249 .replace(" days", "d")
250 .replace(" day", "d")
251 : "";
252
253 const detailUrl = `/${item.author?.handle || item.author?.did}/${type}/${(item.uri || "").split("/").pop()}`;
254
255 const pageTitle =
256 item.target?.title ||
257 item.title ||
258 (pageUrl ? safeUrlHostname(pageUrl) : null);
259 const displayUrl = pageUrl
260 ? (() => {
261 const clean = pageUrl
262 .replace(/^https?:\/\//, "")
263 .replace(/^www\./, "")
264 .replace(/\/$/, "");
265 return clean.length > 60 ? clean.slice(0, 57) + "..." : clean;
266 })()
267 : null;
268
269 const displayTitle =
270 item.title || ogData?.title || pageTitle || "Untitled Bookmark";
271 const displayDescription = item.description || ogData?.description;
272 const displayImage = ogData?.image;
273
274 return (
275 <article className="card p-4 hover:ring-black/10 dark:hover:ring-white/10 transition-all relative">
276 {item.collection && (
277 <div className="flex items-center gap-1.5 text-xs text-surface-400 dark:text-surface-500 mb-2">
278 {item.addedBy && item.addedBy.did !== item.author?.did ? (
279 <>
280 <ProfileHoverCard did={item.addedBy.did}>
281 <Link
282 to={`/profile/${item.addedBy.did}`}
283 className="flex items-center gap-1.5 font-medium hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
284 >
285 <Avatar
286 did={item.addedBy.did}
287 avatar={item.addedBy.avatar}
288 size="xs"
289 />
290 <span>
291 {item.addedBy.displayName || `@${item.addedBy.handle}`}
292 </span>
293 </Link>
294 </ProfileHoverCard>
295 <span>added to</span>
296 </>
297 ) : (
298 <span>Added to</span>
299 )}
300 <Link
301 to={`/${item.addedBy?.handle || ""}/collection/${(item.collection.uri || "").split("/").pop()}`}
302 className="inline-flex items-center gap-1 hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
303 >
304 <CollectionIcon icon={item.collection.icon} size={14} />
305 <span className="font-medium">{item.collection.name}</span>
306 </Link>
307 </div>
308 )}
309
310 <div className="flex items-start gap-3">
311 <ProfileHoverCard did={item.author?.did}>
312 <Link to={`/profile/${item.author?.did}`} className="shrink-0">
313 <div className="rounded-full overflow-hidden">
314 <div
315 className={clsx(
316 "transition-all",
317 contentWarning?.isAccountWide &&
318 !contentRevealed &&
319 "blur-md",
320 )}
321 >
322 <Avatar
323 did={item.author?.did}
324 avatar={item.author?.avatar}
325 size="md"
326 />
327 </div>
328 </div>
329 </Link>
330 </ProfileHoverCard>
331
332 <div className="flex-1 min-w-0">
333 <div className="flex items-center gap-1.5 flex-wrap">
334 <ProfileHoverCard did={item.author?.did}>
335 <Link
336 to={`/profile/${item.author?.did}`}
337 className="font-semibold text-surface-900 dark:text-white text-[15px] hover:underline"
338 >
339 {item.author?.displayName || item.author?.handle}
340 </Link>
341 </ProfileHoverCard>
342 <span className="text-surface-400 dark:text-surface-500 text-sm">
343 @{item.author?.handle}
344 </span>
345 <span className="text-surface-300 dark:text-surface-600">·</span>
346 <span className="text-surface-400 dark:text-surface-500 text-sm">
347 {timestamp}
348 </span>
349 {isSemble &&
350 (() => {
351 const uri = item.uri || "";
352 const parts = uri.replace("at://", "").split("/");
353 const userHandle = item.author?.handle || parts[0] || "";
354 const rkey = parts[2] || "";
355 const targetUrl = item.target?.source || item.source || "";
356 let sembleUrl = `https://semble.so/profile/${userHandle}`;
357 if (uri.includes("network.cosmik.collection"))
358 sembleUrl = `https://semble.so/profile/${userHandle}/collections/${rkey}`;
359 else if (uri.includes("network.cosmik.card") && targetUrl)
360 sembleUrl = `https://semble.so/url?id=${encodeURIComponent(targetUrl)}`;
361 return (
362 <span className="relative inline-flex items-center">
363 <span className="text-surface-300 dark:text-surface-600">
364 ·
365 </span>
366 <button
367 onClick={(e) => handleExternalClick(e, sembleUrl)}
368 className="group/semble relative inline-flex items-center ml-1 cursor-pointer"
369 >
370 <img
371 src="/semble-logo.svg"
372 alt="Semble"
373 className="h-3.5"
374 />
375 <span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2.5 py-1 rounded-lg bg-surface-800 dark:bg-surface-700 text-white text-[11px] font-medium whitespace-nowrap opacity-0 group-hover/semble:opacity-100 transition-opacity shadow-lg">
376 Open in Semble
377 <span className="absolute top-full left-1/2 -translate-x-1/2 -mt-px border-4 border-transparent border-t-surface-800 dark:border-t-surface-700" />
378 </span>
379 </button>
380 </span>
381 );
382 })()}
383 </div>
384
385 {pageUrl && !isBookmark && !(contentWarning && !contentRevealed) && (
386 <a
387 href={pageUrl}
388 target="_blank"
389 rel="noopener noreferrer"
390 onClick={(e) => handleExternalClick(e, pageUrl)}
391 className="inline-flex items-center gap-1 text-xs text-primary-600 dark:text-primary-400 hover:underline mt-0.5 max-w-full"
392 >
393 <ExternalLink size={10} className="flex-shrink-0" />
394 <span className="truncate">{displayUrl}</span>
395 </a>
396 )}
397 </div>
398 </div>
399
400 <div className="mt-3 ml-[52px] relative">
401 {contentWarning && !contentRevealed && (
402 <div className="absolute inset-0 z-10 rounded-lg bg-surface-100 dark:bg-surface-800 flex flex-col items-center justify-center gap-2 py-4">
403 <div className="flex items-center gap-2 text-surface-500 dark:text-surface-400">
404 <EyeOff size={16} />
405 <span className="text-sm font-medium">
406 {contentWarning.description}
407 </span>
408 </div>
409 <button
410 onClick={() => setContentRevealed(true)}
411 className="flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-lg bg-surface-200 dark:bg-surface-700 text-surface-600 dark:text-surface-300 hover:bg-surface-300 dark:hover:bg-surface-600 transition-colors"
412 >
413 <Eye size={12} />
414 Show
415 </button>
416 </div>
417 )}
418 {contentWarning && contentRevealed && (
419 <button
420 onClick={() => setContentRevealed(false)}
421 className="flex items-center gap-1.5 mb-2 px-2.5 py-1 text-xs font-medium rounded-lg bg-surface-100 dark:bg-surface-800 text-surface-500 dark:text-surface-400 hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors"
422 >
423 <EyeOff size={12} />
424 Hide Content
425 </button>
426 )}
427 {isBookmark && (
428 <a
429 href={pageUrl || "#"}
430 target={pageUrl ? "_blank" : undefined}
431 rel="noopener noreferrer"
432 onClick={(e) => pageUrl && handleExternalClick(e, pageUrl)}
433 className="block bg-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 hover:border-primary-300 dark:hover:border-primary-600 hover:bg-surface-100 dark:hover:bg-surface-700 transition-all group overflow-hidden"
434 >
435 {displayImage && !imgError && (
436 <div className="h-32 w-full overflow-hidden bg-surface-200 dark:bg-surface-700 border-b border-surface-200 dark:border-surface-700">
437 <img
438 src={displayImage}
439 alt=""
440 onError={() => setImgError(true)}
441 className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
442 />
443 </div>
444 )}
445 <div className="p-4">
446 <h3 className="font-semibold text-surface-900 dark:text-white text-base leading-snug group-hover:text-primary-600 dark:group-hover:text-primary-400 mb-2 transition-colors">
447 {displayTitle}
448 </h3>
449
450 {displayDescription && (
451 <p className="text-surface-600 dark:text-surface-400 text-sm leading-relaxed mb-3 line-clamp-2">
452 {displayDescription}
453 </p>
454 )}
455
456 <div className="flex items-center gap-2 text-xs text-surface-500 dark:text-surface-500">
457 <div className="w-5 h-5 rounded-full bg-surface-200 dark:bg-surface-700 flex items-center justify-center shrink-0 overflow-hidden">
458 {ogData?.icon && !iconError ? (
459 <img
460 src={ogData.icon}
461 alt=""
462 onError={() => setIconError(true)}
463 className="w-3.5 h-3.5 object-contain"
464 />
465 ) : (
466 <Globe size={10} />
467 )}
468 </div>
469 <span className="truncate max-w-[200px]">
470 {displayUrl || pageUrl}
471 </span>
472 </div>
473 </div>
474 </a>
475 )}
476
477 {item.target?.selector?.exact && (
478 <blockquote
479 className={clsx(
480 "pl-4 py-2 border-l-[3px] mb-3 text-[15px] italic text-surface-600 dark:text-surface-300 rounded-r-lg hover:bg-surface-50 dark:hover:bg-surface-800/50 transition-colors",
481 !item.color &&
482 type === "highlight" &&
483 "border-yellow-400 bg-yellow-50/50 dark:bg-yellow-900/20",
484 item.color === "yellow" &&
485 "border-yellow-400 bg-yellow-50/50 dark:bg-yellow-900/20",
486 item.color === "green" &&
487 "border-green-400 bg-green-50/50 dark:bg-green-900/20",
488 item.color === "red" &&
489 "border-red-400 bg-red-50/50 dark:bg-red-900/20",
490 item.color === "blue" &&
491 "border-blue-400 bg-blue-50/50 dark:bg-blue-900/20",
492 !item.color &&
493 type !== "highlight" &&
494 "border-surface-300 dark:border-surface-600",
495 )}
496 style={
497 item.color?.startsWith("#")
498 ? {
499 borderColor: item.color,
500 backgroundColor: `${item.color}15`,
501 }
502 : undefined
503 }
504 >
505 <a
506 href={`${pageUrl}#:~:text=${item.target.selector.prefix ? encodeURIComponent(item.target.selector.prefix) + "-," : ""}${encodeURIComponent(item.target.selector.exact)}${item.target.selector.suffix ? ",-" + encodeURIComponent(item.target.selector.suffix) : ""}`}
507 target="_blank"
508 rel="noopener noreferrer"
509 onClick={(e) => {
510 const sel = item.target?.selector;
511 if (!sel) return;
512 const url = `${pageUrl}#:~:text=${sel.prefix ? encodeURIComponent(sel.prefix) + "-," : ""}${encodeURIComponent(sel.exact)}${sel.suffix ? ",-" + encodeURIComponent(sel.suffix) : ""}`;
513 handleExternalClick(e, url);
514 }}
515 className="block"
516 >
517 "{item.target?.selector?.exact}"
518 </a>
519 </blockquote>
520 )}
521
522 {item.body?.value && (
523 <p className="text-surface-900 dark:text-surface-100 whitespace-pre-wrap leading-relaxed text-[15px]">
524 <RichText text={item.body.value} />
525 </p>
526 )}
527 </div>
528
529 <div className="flex items-center gap-1 mt-3 ml-[52px]">
530 <button
531 onClick={handleLike}
532 className={clsx(
533 "flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm transition-all",
534 liked
535 ? "text-red-500 bg-red-50 dark:bg-red-900/20"
536 : "text-surface-400 dark:text-surface-500 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20",
537 )}
538 >
539 <Heart size={16} className={clsx(liked && "fill-current")} />
540 {likes > 0 && <span className="text-xs font-medium">{likes}</span>}
541 </button>
542
543 {type === "annotation" && (
544 <Link
545 to={detailUrl}
546 className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm text-surface-400 dark:text-surface-500 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-all"
547 >
548 <MessageSquare size={16} />
549 {(item.replyCount || 0) > 0 && (
550 <span className="text-xs font-medium">{item.replyCount}</span>
551 )}
552 </Link>
553 )}
554
555 {user && (
556 <button
557 onClick={() => setShowCollectionModal(true)}
558 className="flex items-center px-2.5 py-1.5 rounded-lg text-surface-400 dark:text-surface-500 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-all"
559 title="Add to Collection"
560 >
561 <FolderPlus size={16} />
562 </button>
563 )}
564
565 {!hideShare && (
566 <ShareMenu
567 uri={item.uri}
568 text={item.body?.value || ""}
569 handle={item.author?.handle}
570 type={type}
571 url={pageUrl}
572 />
573 )}
574
575 {isAuthor && (
576 <>
577 <div className="flex-1" />
578 <button
579 onClick={() => setShowEditModal(true)}
580 className="flex items-center px-2.5 py-1.5 rounded-lg text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-300 hover:bg-surface-50 dark:hover:bg-surface-800 transition-all"
581 title="Edit"
582 >
583 <Edit3 size={14} />
584 </button>
585 <button
586 onClick={handleDelete}
587 className="flex items-center px-2.5 py-1.5 rounded-lg text-surface-400 dark:text-surface-500 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all"
588 title="Delete"
589 >
590 <Trash2 size={14} />
591 </button>
592 </>
593 )}
594
595 {!isAuthor && user && (
596 <>
597 <div className="flex-1" />
598 <MoreMenu
599 items={(() => {
600 const menuItems: MoreMenuItem[] = [
601 {
602 label: "Report",
603 icon: <Flag size={14} />,
604 onClick: () => setShowReportModal(true),
605 variant: "danger",
606 },
607 {
608 label: `Mute @${item.author?.handle || "user"}`,
609 icon: <VolumeX size={14} />,
610 onClick: async () => {
611 if (item.author?.did) {
612 await muteUser(item.author.did);
613 onDelete?.(item.uri);
614 }
615 },
616 },
617 {
618 label: `Block @${item.author?.handle || "user"}`,
619 icon: <ShieldBan size={14} />,
620 onClick: async () => {
621 if (item.author?.did) {
622 await blockUser(item.author.did);
623 onDelete?.(item.uri);
624 }
625 },
626 variant: "danger",
627 },
628 ];
629 return menuItems;
630 })()}
631 />
632 </>
633 )}
634 </div>
635
636 <AddToCollectionModal
637 isOpen={showCollectionModal}
638 onClose={() => setShowCollectionModal(false)}
639 annotationUri={item.uri}
640 />
641
642 <ExternalLinkModal
643 isOpen={showExternalLinkModal}
644 onClose={() => setShowExternalLinkModal(false)}
645 url={externalLinkUrl}
646 />
647
648 <ReportModal
649 isOpen={showReportModal}
650 onClose={() => setShowReportModal(false)}
651 subjectDid={item.author?.did || ""}
652 subjectUri={item.uri}
653 subjectHandle={item.author?.handle}
654 />
655
656 <EditItemModal
657 isOpen={showEditModal}
658 onClose={() => setShowEditModal(false)}
659 item={item}
660 type={type}
661 onSaved={(updated) => {
662 setItem(updated);
663 onUpdate?.(updated);
664 }}
665 />
666 </article>
667 );
668}