import { useState, useEffect } from "react"; import { useAuth } from "../context/AuthContext"; import ReplyList from "./ReplyList"; import { Link } from "react-router-dom"; import { normalizeAnnotation, normalizeHighlight, likeAnnotation, unlikeAnnotation, getReplies, createReply, deleteReply, updateAnnotation, updateHighlight, getEditHistory, deleteAnnotation, } from "../api/client"; import { MessageSquare, Heart, Trash2, Folder, Edit2, Save, X, Clock, } from "lucide-react"; import { HighlightIcon, TrashIcon } from "./Icons"; import ShareMenu from "./ShareMenu"; function buildTextFragmentUrl(baseUrl, selector) { if (!selector || selector.type !== "TextQuoteSelector" || !selector.exact) { return baseUrl; } let fragment = ":~:text="; if (selector.prefix) { fragment += encodeURIComponent(selector.prefix) + "-,"; } fragment += encodeURIComponent(selector.exact); if (selector.suffix) { fragment += ",-" + encodeURIComponent(selector.suffix); } return baseUrl + "#" + fragment; } const truncateUrl = (url, maxLength = 60) => { if (!url) return ""; try { const parsed = new URL(url); const fullPath = parsed.host + parsed.pathname; if (fullPath.length > maxLength) return fullPath.substring(0, maxLength) + "..."; return fullPath; } catch { return url.length > maxLength ? url.substring(0, maxLength) + "..." : url; } }; export default function AnnotationCard({ annotation, onDelete, onAddToCollection, }) { const { user, login } = useAuth(); const data = normalizeAnnotation(annotation); const [likeCount, setLikeCount] = useState(data.likeCount || 0); const [isLiked, setIsLiked] = useState(data.viewerHasLiked || false); const [deleting, setDeleting] = useState(false); const [isEditing, setIsEditing] = useState(false); const [editText, setEditText] = useState(data.text || ""); const [editTags, setEditTags] = useState(data.tags?.join(", ") || ""); const [saving, setSaving] = useState(false); const [showHistory, setShowHistory] = useState(false); const [editHistory, setEditHistory] = useState([]); const [loadingHistory, setLoadingHistory] = useState(false); const [replies, setReplies] = useState([]); const [replyCount, setReplyCount] = useState(data.replyCount || 0); const [showReplies, setShowReplies] = useState(false); const [replyingTo, setReplyingTo] = useState(null); const [replyText, setReplyText] = useState(""); const [posting, setPosting] = useState(false); const isOwner = user?.did && data.author?.did === user.did; const [hasEditHistory, setHasEditHistory] = useState(false); useEffect(() => { if (data.uri && !data.color && !data.description) { getEditHistory(data.uri) .then((history) => { if (history && history.length > 0) { setHasEditHistory(true); } }) .catch(() => {}); } }, [data.uri, data.color, data.description]); const fetchHistory = async () => { if (showHistory) { setShowHistory(false); return; } try { setLoadingHistory(true); setShowHistory(true); const history = await getEditHistory(data.uri); setEditHistory(history); } catch (err) { console.error("Failed to fetch history:", err); } finally { setLoadingHistory(false); } }; const handlePostReply = async (parentReply) => { if (!replyText.trim()) return; try { setPosting(true); const parentUri = parentReply ? parentReply.id || parentReply.uri : data.uri; const parentCid = parentReply ? parentReply.cid : annotation.cid || data.cid; await createReply({ parentUri, parentCid: parentCid || "", rootUri: data.uri, rootCid: annotation.cid || data.cid || "", text: replyText, }); setReplyText(""); setReplyingTo(null); const res = await getReplies(data.uri); if (res.items) { setReplies(res.items); setReplyCount(res.items.length); } } catch (err) { alert("Failed to post reply: " + err.message); } finally { setPosting(false); } }; const handleSaveEdit = async () => { try { setSaving(true); const tagList = editTags .split(",") .map((t) => t.trim()) .filter(Boolean); await updateAnnotation(data.uri, editText, tagList); setIsEditing(false); if (annotation.body) annotation.body.value = editText; else if (annotation.text) annotation.text = editText; if (annotation.tags) annotation.tags = tagList; data.tags = tagList; } catch (err) { alert("Failed to update: " + err.message); } finally { setSaving(false); } }; const formatDate = (dateString, simple = true) => { if (!dateString) return ""; const date = new Date(dateString); const now = new Date(); const diff = now - date; const minutes = Math.floor(diff / 60000); const hours = Math.floor(diff / 3600000); const days = Math.floor(diff / 86400000); if (minutes < 1) return "just now"; if (minutes < 60) return `${minutes}m`; if (hours < 24) return `${hours}h`; if (days < 7) return `${days}d`; if (simple) return date.toLocaleDateString("en-US", { month: "short", day: "numeric", }); return date.toLocaleString(); }; const authorDisplayName = data.author?.displayName || data.author?.handle; const authorHandle = data.author?.handle; const authorAvatar = data.author?.avatar; const authorDid = data.author?.did; const marginProfileUrl = authorDid ? `/profile/${authorDid}` : null; const highlightedText = data.selector?.type === "TextQuoteSelector" ? data.selector.exact : null; const fragmentUrl = buildTextFragmentUrl(data.url, data.selector); const handleLike = async () => { if (!user) { login(); return; } try { if (isLiked) { setIsLiked(false); setLikeCount((prev) => Math.max(0, prev - 1)); await unlikeAnnotation(data.uri); } else { setIsLiked(true); setLikeCount((prev) => prev + 1); const cid = annotation.cid || data.cid || ""; if (data.uri && cid) await likeAnnotation(data.uri, cid); } } catch (err) { setIsLiked(!isLiked); setLikeCount((prev) => (isLiked ? prev + 1 : prev - 1)); console.error("Failed to toggle like:", err); } }; const handleDelete = async () => { if (!confirm("Delete this annotation? This cannot be undone.")) return; try { setDeleting(true); const parts = data.uri.split("/"); const rkey = parts[parts.length - 1]; await deleteAnnotation(rkey); if (onDelete) onDelete(data.uri); else window.location.reload(); } catch (err) { alert("Failed to delete: " + err.message); } finally { setDeleting(false); } }; return (
{authorAvatar ? ( {authorDisplayName} ) : ( {(authorDisplayName || authorHandle || "??") ?.substring(0, 2) .toUpperCase()} )}
{authorDisplayName} {authorHandle && ( @{authorHandle} )}
{formatDate(data.createdAt)}
{hasEditHistory && !data.color && !data.description && ( )} {isOwner && ( <> {!data.color && !data.description && ( )} )}
{showHistory && (

Edit History

{loadingHistory ? (
Loading history...
) : editHistory.length === 0 ? (
No edit history found.
) : ( )}
)}
{truncateUrl(data.url)} {data.title && ( • {data.title} )} {highlightedText && ( "{highlightedText}" )} {isEditing ? (