(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348import React, { useEffect, useRef, useState } from "react";import { useNavigate } from "react-router-dom";import { useStore } from "@nanostores/react";import { useTranslation } from "react-i18next";import { $user } from "../../store/auth";import { getAnnotation, getReplies, resolveHandle, createReply, deleteReply,} from "../../api/client";import type { AnnotationItem } from "../../types";import Card from "../../components/common/Card";import ReplyList from "../../components/feed/ReplyList";import { Loader2, MessageSquare, ArrowLeft, X, AlertTriangle,} from "lucide-react";import { getAvatarUrl } from "../../api/client";import { analytics } from "../../lib/analytics";
interface AnnotationDetailProps { handle?: string; rkey?: string; type?: string; uri?: string; did?: string; initialAnnotation?: AnnotationItem | null; initialReplies?: AnnotationItem[]; resolvedUri?: string;}
export default function AnnotationDetail({ handle, rkey, type, uri, did, initialAnnotation, initialReplies, resolvedUri,}: AnnotationDetailProps) { const { t } = useTranslation(); const user = useStore($user); const navigate = useNavigate();
const [annotation, setAnnotation] = useState<AnnotationItem | null>( initialAnnotation || null, ); const [replies, setReplies] = useState<AnnotationItem[]>( initialReplies || [], ); const [loading, setLoading] = useState(!initialAnnotation); const [error, setError] = useState<string | null>(null);
const [replyText, setReplyText] = useState(""); const [posting, setPosting] = useState(false); const [replyingTo, setReplyingTo] = useState<AnnotationItem | null>(null);
const [targetUri, setTargetUri] = useState<string | null>( resolvedUri || uri || null, ); const skipInitialFetch = useRef(!!initialAnnotation);
useEffect(() => { if (resolvedUri) return;
async function resolve() { if (uri) { setTargetUri(decodeURIComponent(uri)); return; }
if (handle && rkey) { let collection = "at.margin.note"; if (type === "annotation") collection = "at.margin.annotation"; if (type === "highlight") collection = "at.margin.highlight"; if (type === "bookmark") collection = "at.margin.bookmark";
try { const resolvedDid = await resolveHandle(handle); if (resolvedDid) { setTargetUri(`at://${resolvedDid}/${collection}/${rkey}`); } else { throw new Error("Could not resolve handle"); } } catch (e) { setError( t("annotationDetail.failedResolve", { message: e instanceof Error ? e.message : "Unknown error", }), ); setLoading(false); } } else if (did && rkey) { setTargetUri(`at://${did}/at.margin.note/${rkey}`); } } resolve(); }, [uri, did, rkey, handle, type, resolvedUri, t]);
const refreshReplies = async () => { if (!targetUri) return; const repliesData = await getReplies(targetUri); setReplies(repliesData.items || []); };
useEffect(() => { if (skipInitialFetch.current) { skipInitialFetch.current = false; return; }
async function fetchData() { if (!targetUri) return;
try { setLoading(true); const [annData, repliesData] = await Promise.all([ getAnnotation(targetUri), getReplies(targetUri).catch(() => ({ items: [] as AnnotationItem[], })), ]);
if (!annData) { setError(t("annotationDetail.notFound")); } else { setAnnotation(annData); setReplies(repliesData.items || []); } } catch (err) { setError(err instanceof Error ? err.message : "Unknown error"); } finally { setLoading(false); } } fetchData(); }, [targetUri, t]);
const handleReply = async (e?: React.FormEvent) => { if (e) e.preventDefault(); if (!replyText.trim() || !annotation || !targetUri) return;
try { setPosting(true); const parentUri = replyingTo ? replyingTo.uri || replyingTo.id : targetUri; const parentCid = replyingTo ? replyingTo.cid : annotation.cid;
if (!parentUri) throw new Error("Missing parent info");
await createReply( parentUri, parentCid || "", targetUri, annotation.cid || "", replyText, );
analytics.capture("reply_created", { parent_uri: parentUri, root_uri: targetUri, }); setReplyText(""); setReplyingTo(null); await refreshReplies(); } catch (err) { alert( t("annotationDetail.failedReply", { message: err instanceof Error ? err.message : "Unknown error", }), ); } finally { setPosting(false); } };
const handleDeleteReply = async (reply: AnnotationItem) => { if (!window.confirm(t("annotationDetail.deleteReplyConfirm"))) return; try { await deleteReply(reply.uri || reply.id!); await refreshReplies(); } catch (err) { alert( t("annotationDetail.failedDelete", { message: err instanceof Error ? err.message : "Unknown error", }), ); } };
if (loading) { return ( <div className="flex justify-center py-20"> <Loader2 className="animate-spin text-primary-600 dark:text-primary-400" size={32} /> </div> ); }
if (error || !annotation) { return ( <div className="max-w-md mx-auto py-12 px-4 text-center"> <div className="w-14 h-14 bg-surface-100 dark:bg-surface-800 rounded-full flex items-center justify-center mx-auto mb-4 text-surface-400 dark:text-surface-500"> <AlertTriangle size={28} /> </div> <h3 className="text-xl font-bold text-surface-900 dark:text-white mb-2"> {t("annotationDetail.notFound")} </h3> <p className="text-surface-500 dark:text-surface-400 text-sm mb-6"> {error || t("annotationDetail.mayBeDeleted")} </p> <a href="/home" onClick={(e) => { e.preventDefault(); navigate("/home"); }} className="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors" > {t("annotationDetail.backToFeed")} </a> </div> ); }
return ( <div className="max-w-2xl mx-auto pb-20"> <div className="mb-4"> <a href="/home" onClick={(e) => { e.preventDefault(); navigate(-1); }} className="inline-flex items-center gap-1.5 text-sm font-medium text-surface-500 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white transition-colors" > <ArrowLeft size={16} /> {t("annotationDetail.back")} </a> </div>
<Card item={annotation} onDelete={() => { navigate("/home"); }} />
{annotation.type !== "Bookmark" && annotation.type !== "Highlight" && !annotation.motivation?.includes("bookmark") && !annotation.motivation?.includes("highlight") && ( <div className="mt-6"> <h3 className="flex items-center gap-2 text-sm font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wider mb-4"> <MessageSquare size={16} /> {t("annotationDetail.replies", { count: replies.length })} </h3>
{user ? ( <div className="bg-white dark:bg-surface-900 rounded-xl ring-1 ring-black/5 dark:ring-white/5 p-4 mb-4"> {replyingTo && ( <div className="flex items-center justify-between bg-surface-50 dark:bg-surface-800 px-3 py-2 rounded-lg mb-3 border border-surface-200 dark:border-surface-700"> <span className="text-sm text-surface-600 dark:text-surface-300"> {t("annotationDetail.replyingTo")}{" "} <span className="font-medium text-surface-900 dark:text-white"> @ {(replyingTo.author || replyingTo.creator)?.handle || "unknown"} </span> </span> <button onClick={() => setReplyingTo(null)} className="text-surface-400 dark:text-surface-500 hover:text-surface-900 dark:hover:text-white p-1" > <X size={14} /> </button> </div> )} <div className="flex gap-3"> {getAvatarUrl(user.did, user.avatar) ? ( <img src={getAvatarUrl(user.did, user.avatar)} alt="" className="w-8 h-8 rounded-full object-cover bg-surface-100 dark:bg-surface-800" /> ) : ( <div className="w-8 h-8 rounded-full bg-surface-100 dark:bg-surface-800 flex items-center justify-center text-xs font-bold text-surface-400 dark:text-surface-500"> {user.handle?.[0]?.toUpperCase()} </div> )} <div className="flex-1"> <textarea value={replyText} onChange={(e) => setReplyText(e.target.value)} placeholder={t("annotationDetail.replyPlaceholder")} className="w-full p-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400 outline-none resize-none min-h-[80px]" rows={2} disabled={posting} /> <div className="flex justify-end mt-2 pt-2 border-t border-surface-100 dark:border-surface-800"> <button className="px-4 py-1.5 bg-primary-600 hover:bg-primary-700 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50" disabled={posting || !replyText.trim()} onClick={() => handleReply()} > {posting ? "..." : t("annotationDetail.reply")} </button> </div> </div> </div> </div> ) : ( <div className="bg-surface-50 dark:bg-surface-800/50 rounded-xl p-5 text-center mb-4 border border-dashed border-surface-200 dark:border-surface-700"> <p className="text-surface-500 dark:text-surface-400 text-sm mb-2"> {t("annotationDetail.signInToReply")} </p> <a href="/login" className="text-primary-600 dark:text-primary-400 font-medium hover:underline text-sm" > {t("annotationDetail.logIn")} </a> </div> )}
<ReplyList replies={replies} rootUri={targetUri || ""} user={user} onReply={(reply) => setReplyingTo(reply)} onDelete={handleDeleteReply} isInline={false} /> </div> )} </div> );}