import React, { useState } from "react"; import { formatDistanceToNow } from "date-fns"; import RichText from "./RichText"; import MoreMenu from "./MoreMenu"; import type { MoreMenuItem } from "./MoreMenu"; import { MessageSquare, Heart, ExternalLink, FolderPlus, Trash2, Edit3, Globe, ShieldBan, VolumeX, Flag, EyeOff, Eye, Tag, Send, X, } from "lucide-react"; import ShareMenu from "../modals/ShareMenu"; import AddToCollectionModal from "../modals/AddToCollectionModal"; import ExternalLinkModal from "../modals/ExternalLinkModal"; import ReportModal from "../modals/ReportModal"; import EditItemModal from "../modals/EditItemModal"; import EditHistoryModal from "../modals/EditHistoryModal"; import { clsx } from "clsx"; import { likeItem, unlikeItem, deleteItem, blockUser, muteUser, convertHighlightToAnnotation, } from "../../api/client"; import { $user } from "../../store/auth"; import { $preferences } from "../../store/preferences"; import { useStore } from "@nanostores/react"; import type { AnnotationItem, ContentLabel, LabelVisibility, } from "../../types"; import { Link } from "react-router-dom"; import { Avatar } from "../ui"; import CollectionIcon from "./CollectionIcon"; import ProfileHoverCard from "./ProfileHoverCard"; const LABEL_DESCRIPTIONS: Record = { sexual: "Sexual Content", nudity: "Nudity", violence: "Violence", gore: "Graphic Content", spam: "Spam", misleading: "Misleading", }; function getContentWarning( labels?: ContentLabel[], prefs?: { labelPreferences: { labelerDid: string; label: string; visibility: LabelVisibility; }[]; }, ): { label: string; description: string; visibility: LabelVisibility; isAccountWide: boolean; } | null { if (!labels || labels.length === 0) return null; const priority = [ "gore", "violence", "nudity", "sexual", "misleading", "spam", ]; for (const p of priority) { const match = labels.find((l) => l.val === p); if (match) { const pref = prefs?.labelPreferences.find( (lp) => lp.label === p && lp.labelerDid === match.src, ); const visibility: LabelVisibility = pref?.visibility || "warn"; if (visibility === "ignore") continue; return { label: p, description: LABEL_DESCRIPTIONS[p] || p, visibility, isAccountWide: match.scope === "account", }; } } return null; } interface CardProps { item: AnnotationItem; onDelete?: (uri: string) => void; onUpdate?: (item: AnnotationItem) => void; hideShare?: boolean; layout?: "list" | "mosaic"; } export default function Card({ item: initialItem, onDelete, onUpdate, hideShare, layout = "list", }: CardProps) { const [item, setItem] = useState(initialItem); const user = useStore($user); const preferences = useStore($preferences); const isAuthor = user && item.author?.did === user.did; const [liked, setLiked] = useState(!!item.viewer?.like); const [likes, setLikes] = useState(item.likeCount || 0); const [showCollectionModal, setShowCollectionModal] = useState(false); const [showExternalLinkModal, setShowExternalLinkModal] = useState(false); const [externalLinkUrl, setExternalLinkUrl] = useState(null); const [showReportModal, setShowReportModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); const [showEditHistory, setShowEditHistory] = useState(false); const [contentRevealed, setContentRevealed] = useState(false); const [showConvertInput, setShowConvertInput] = useState(false); const [convertText, setConvertText] = useState(""); const [converting, setConverting] = useState(false); const [ogData, setOgData] = useState<{ title?: string; description?: string; image?: string; icon?: string; } | null>(() => { if (initialItem.motivation !== "bookmarking") return null; const url = initialItem.target?.source || initialItem.source; if (!url) return null; try { const cached = sessionStorage.getItem(`og:${url}`); return cached ? JSON.parse(cached) : null; } catch { return null; } }); const [imgError, setImgError] = useState(false); const [iconError, setIconError] = useState(false); const contentWarning = getContentWarning(item.labels, preferences); React.useEffect(() => { setItem(initialItem); }, [initialItem]); React.useEffect(() => { setLiked(!!item.viewer?.like); setLikes(item.likeCount || 0); }, [item.viewer?.like, item.likeCount]); const type = item.motivation === "highlighting" ? "highlight" : item.motivation === "bookmarking" ? "bookmark" : "annotation"; const isSemble = item.uri?.includes("network.cosmik") || item.uri?.includes("semble"); const safeUrlHostname = (url: string | null | undefined) => { if (!url) return null; try { return new URL(url).hostname; } catch { return null; } }; const pageUrl = item.target?.source || item.source; const isBookmark = type === "bookmark"; React.useEffect(() => { if (isBookmark && item.uri && !ogData && pageUrl) { const fetchMetadata = async () => { try { const res = await fetch( `/api/url-metadata?url=${encodeURIComponent(pageUrl)}`, ); if (res.ok) { const data = await res.json(); setOgData(data); try { sessionStorage.setItem(`og:${pageUrl}`, JSON.stringify(data)); } catch { /* quota exceeded */ } } } catch (e) { console.error("Failed to fetch metadata", e); } }; fetchMetadata(); } }, [isBookmark, item.uri, pageUrl, ogData]); if (contentWarning?.visibility === "hide") return null; const handleLike = async () => { const prev = { liked, likes }; setLiked(!liked); setLikes((l) => (liked ? l - 1 : l + 1)); const success = liked ? await unlikeItem(item.uri) : await likeItem(item.uri, item.cid); if (!success) { setLiked(prev.liked); setLikes(prev.likes); } }; const handleDelete = async () => { if (window.confirm("Delete this item?")) { const success = await deleteItem(item.uri, type); if (success && onDelete) onDelete(item.uri); } }; const handleConvert = async () => { if (!convertText.trim() || converting) return; setConverting(true); const pageUrl = item.target?.source || item.source || ""; const res = await convertHighlightToAnnotation( item.uri, pageUrl, convertText.trim(), item.target?.selector, item.target?.title, ); setConverting(false); if (res.success) { setShowConvertInput(false); setConvertText(""); if (onDelete) onDelete(item.uri); } }; const handleExternalClick = (e: React.MouseEvent, url: string) => { e.preventDefault(); e.stopPropagation(); try { const hostname = safeUrlHostname(url); if (hostname) { if ( hostname === "margin.at" || hostname.endsWith(".margin.at") || hostname === "semble.so" || hostname.endsWith(".semble.so") ) { window.open(url, "_blank", "noopener,noreferrer"); return; } if ($preferences.get().disableExternalLinkWarning) { window.open(url, "_blank", "noopener,noreferrer"); return; } const skipped = $preferences.get().externalLinkSkippedHostnames; if (skipped.includes(hostname)) { window.open(url, "_blank", "noopener,noreferrer"); return; } } } catch (err) { if (err instanceof Error && err.name !== "TypeError") { console.debug("Failed to check skipped hostname:", err); } } setExternalLinkUrl(url); setShowExternalLinkModal(true); }; const timestamp = item.createdAt ? formatDistanceToNow(new Date(item.createdAt), { addSuffix: false }) .replace("less than a minute", "just now") .replace("about ", "") .replace(" hours", "h") .replace(" hour", "h") .replace(" minutes", "m") .replace(" minute", "m") .replace(" days", "d") .replace(" day", "d") : ""; const detailUrl = `/${item.author?.handle || item.author?.did}/${type}/${(item.uri || "").split("/").pop()}`; const pageTitle = item.target?.title || item.title || (pageUrl ? safeUrlHostname(pageUrl) : null); const displayUrl = pageUrl ? (() => { const clean = pageUrl .replace(/^https?:\/\//, "") .replace(/^www\./, "") .replace(/\/$/, ""); return clean.length > 60 ? clean.slice(0, 57) + "..." : clean; })() : null; const decodeHTMLEntities = (text: string) => { const textarea = document.createElement("textarea"); textarea.innerHTML = text; return textarea.value; }; const displayTitle = decodeHTMLEntities( item.title || ogData?.title || pageTitle || "Untitled Bookmark", ); const displayDescription = item.description || ogData?.description ? decodeHTMLEntities(item.description || ogData?.description || "") : undefined; const displayImage = ogData?.image; return (
{(item.collection || (item.context && item.context.length > 0)) && (
{item.addedBy && item.addedBy.did !== item.author?.did ? ( <> {item.addedBy.displayName || `@${item.addedBy.handle}`} added to ) : ( Added to )} {item.context && item.context.length > 0 ? ( item.context.map((col, index) => ( {index > 0 && index < item.context!.length - 1 && ( , )} {index > 0 && index === item.context!.length - 1 && ( and )} {col.name} )) ) : ( {item.collection!.name} )}
)}
{item.author?.displayName || item.author?.handle} @{item.author?.handle} · {timestamp} {item.editedAt && ( )} {isSemble && (() => { const uri = item.uri || ""; const parts = uri.replace("at://", "").split("/"); const userHandle = item.author?.handle || parts[0] || ""; const rkey = parts[2] || ""; const targetUrl = item.target?.source || item.source || ""; let sembleUrl = `https://semble.so/profile/${userHandle}`; if (uri.includes("network.cosmik.collection")) sembleUrl = `https://semble.so/profile/${userHandle}/collections/${rkey}`; else if (uri.includes("network.cosmik.card") && targetUrl) sembleUrl = `https://semble.so/url?id=${encodeURIComponent(targetUrl)}`; return ( · ); })()}
{pageUrl && !isBookmark && !(contentWarning && !contentRevealed) && ( handleExternalClick(e, pageUrl)} className="inline-flex items-center gap-1 text-xs text-primary-600 dark:text-primary-400 hover:underline mt-0.5 max-w-full" > {displayUrl} )}
{contentWarning && !contentRevealed && (
{contentWarning.description}
)} {contentWarning && contentRevealed && ( )} {isBookmark && (
{ e.preventDefault(); if (pageUrl) handleExternalClick(e, pageUrl); }} role="button" tabIndex={0} className={clsx( "flex 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 cursor-pointer", layout === "mosaic" ? "flex-col items-stretch" : "flex-row items-stretch", )} > {displayImage && !imgError && (
{displayTitle setImgError(true)} />
)}

{displayTitle}

{displayDescription && (

{displayDescription}

)}
{ogData?.icon && !iconError ? ( setIconError(true)} className="w-3 h-3 object-contain" /> ) : ( )}
{displayUrl || pageUrl}
)} {item.target?.selector?.exact && (
{ const sel = item.target?.selector; if (!sel) return; const url = `${pageUrl}#:~:text=${sel.prefix ? encodeURIComponent(sel.prefix) + "-," : ""}${encodeURIComponent(sel.exact)}${sel.suffix ? ",-" + encodeURIComponent(sel.suffix) : ""}`; handleExternalClick(e, url); }} className="block" > "{item.target?.selector?.exact}"
)} {item.body?.value && (

)} {item.tags && item.tags.length > 0 && (
{item.tags.map((tag) => ( e.stopPropagation()} > {tag} ))}
)}
{type === "annotation" && ( {(item.replyCount || 0) > 0 && ( {item.replyCount} )} )} {user && ( )} {!hideShare && ( )} {isAuthor && ( <>
{type === "highlight" && !showConvertInput && ( )} )} {!isAuthor && user && ( <>
{ const menuItems: MoreMenuItem[] = [ { label: "Report", icon: , onClick: () => setShowReportModal(true), variant: "danger", }, { label: `Mute @${item.author?.handle || "user"}`, icon: , onClick: async () => { if (item.author?.did) { await muteUser(item.author.did); onDelete?.(item.uri); } }, }, { label: `Block @${item.author?.handle || "user"}`, icon: , onClick: async () => { if (item.author?.did) { await blockUser(item.author.did); onDelete?.(item.uri); } }, variant: "danger", }, ]; return menuItems; })()} /> )}
{showConvertInput && (