import { useState, useEffect } from "react";
import { useAuth } from "../context/AuthContext";
import ReplyList from "./ReplyList";
import { Link } from "react-router-dom";
import RichText from "./RichText";
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";
import UserMeta from "./UserMeta";
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 = 50) => {
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;
}
};
function SembleBadge() {
return (
via Semble
);
}
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 [hasEditHistory, setHasEditHistory] = useState(false);
const isOwner = user?.did && data.author?.did === user.did;
const isSemble = data.uri?.includes("network.cosmik");
const highlightedText =
data.selector?.type === "TextQuoteSelector" ? data.selector.exact : null;
const fragmentUrl = buildTextFragmentUrl(data.url, data.selector);
useEffect(() => {
if (data.uri && !data.color && !data.description) {
getEditHistory(data.uri)
.then((history) => {
if (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 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 {
setIsLiked(!isLiked);
setLikeCount((prev) => (isLiked ? prev + 1 : prev - 1));
}
};
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);
}
};
const loadReplies = async () => {
if (!showReplies && replies.length === 0) {
try {
const res = await getReplies(data.uri);
if (res.items) setReplies(res.items);
} catch (err) {
console.error("Failed to load replies:", err);
}
}
setShowReplies(!showReplies);
};
const handleCollect = () => {
if (!user) {
login();
return;
}
if (onAddToCollection) onAddToCollection();
};
return (
{showHistory && (
Edit History
{loadingHistory ? (
Loading history...
) : editHistory.length === 0 ? (
No edit history found.
) : (
)}
)}
{showReplies && (
setReplyingTo(reply)}
onDelete={async (reply) => {
if (!confirm("Delete this reply?")) return;
try {
await deleteReply(reply.id || reply.uri);
const res = await getReplies(data.uri);
if (res.items) {
setReplies(res.items);
setReplyCount(res.items.length);
}
} catch (err) {
alert("Failed to delete: " + err.message);
}
}}
isInline={true}
/>
{replyingTo && (
Replying to @
{(replyingTo.creator || replyingTo.author)?.handle ||
"unknown"}
)}
)}
);
}
export function HighlightCard({
highlight,
onDelete,
onAddToCollection,
onUpdate,
}) {
const { user, login } = useAuth();
const data = normalizeHighlight(highlight);
const highlightedText =
data.selector?.type === "TextQuoteSelector" ? data.selector.exact : null;
const fragmentUrl = buildTextFragmentUrl(data.url, data.selector);
const isOwner = user?.did && data.author?.did === user.did;
const isSemble = data.uri?.includes("network.cosmik");
const [isEditing, setIsEditing] = useState(false);
const [editColor, setEditColor] = useState(data.color || "#f59e0b");
const [editTags, setEditTags] = useState(data.tags?.join(", ") || "");
const handleSaveEdit = async () => {
try {
const tagList = editTags
.split(",")
.map((t) => t.trim())
.filter(Boolean);
await updateHighlight(data.uri, editColor, tagList);
setIsEditing(false);
if (typeof onUpdate === "function") {
onUpdate({ ...highlight, color: editColor, tags: tagList });
}
} catch (err) {
alert("Failed to update: " + err.message);
}
};
const handleCollect = () => {
if (!user) {
login();
return;
}
if (onAddToCollection) onAddToCollection();
};
return (
{isSemble && (
via Semble
)}
{isOwner && (
<>
>
)}
);
}