(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import React from "react";
2import { formatDistanceToNow } from "date-fns";
3import { useTranslation } from "react-i18next";
4import { MessageSquare, Trash2, Reply } from "lucide-react";
5import type { AnnotationItem, UserProfile } from "../../types";
6import { getAvatarUrl } from "../../api/client";
7import { clsx } from "clsx";
8
9interface ReplyListProps {
10 replies: AnnotationItem[];
11 rootUri: string;
12 user: UserProfile | null;
13 onReply: (reply: AnnotationItem) => void;
14 onDelete: (reply: AnnotationItem) => void;
15 isInline?: boolean;
16}
17
18interface ReplyItemProps {
19 reply: AnnotationItem & { children?: AnnotationItem[] };
20 depth: number;
21 user: UserProfile | null;
22 onReply: (reply: AnnotationItem) => void;
23 onDelete: (reply: AnnotationItem) => void;
24 isInline: boolean;
25}
26
27const ReplyItem: React.FC<ReplyItemProps> = ({
28 reply,
29 depth = 0,
30 user,
31 onReply,
32 onDelete,
33 isInline,
34}) => {
35 const author = reply.author || reply.creator || {};
36 const isReplyOwner = user?.did && author.did === user.did;
37
38 if (!author.handle && !author.did) return null;
39
40 return (
41 <div key={reply.uri || reply.id}>
42 <div
43 className={clsx(
44 "relative mb-2 transition-colors",
45 isInline ? "flex gap-3" : "rounded-lg",
46 depth > 0 &&
47 "ml-4 pl-3 border-l-2 border-surface-200 dark:border-surface-700",
48 )}
49 >
50 {isInline ? (
51 <>
52 <a href={`/profile/${author.handle}`} className="shrink-0">
53 {getAvatarUrl(author.did, author.avatar) ? (
54 <img
55 src={getAvatarUrl(author.did, author.avatar)}
56 alt=""
57 className={clsx(
58 "rounded-full object-cover bg-surface-200 dark:bg-surface-700",
59 depth > 0 ? "w-6 h-6" : "w-7 h-7",
60 )}
61 />
62 ) : (
63 <div
64 className={clsx(
65 "rounded-full bg-surface-200 dark:bg-surface-700 flex items-center justify-center text-surface-500 dark:text-surface-400 font-bold",
66 depth > 0 ? "w-6 h-6 text-[10px]" : "w-7 h-7 text-xs",
67 )}
68 >
69 {(author.displayName ||
70 author.handle ||
71 "?")[0]?.toUpperCase()}
72 </div>
73 )}
74 </a>
75 <div className="flex-1 min-w-0">
76 <div className="flex items-baseline gap-2 mb-0.5 flex-wrap">
77 <span
78 className={clsx(
79 "font-medium text-surface-900 dark:text-white",
80 depth > 0 ? "text-xs" : "text-sm",
81 )}
82 >
83 {author.displayName || author.handle}
84 </span>
85 <span className="text-surface-400 dark:text-surface-500 text-xs">
86 {reply.createdAt
87 ? formatDistanceToNow(new Date(reply.createdAt), {
88 addSuffix: false,
89 })
90 : ""}
91 </span>
92
93 <div className="ml-auto flex gap-2">
94 <button
95 onClick={() => onReply(reply)}
96 className="text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-300 transition-colors flex items-center gap-1 text-[10px] uppercase font-medium"
97 >
98 <MessageSquare size={12} />
99 </button>
100 {isReplyOwner && (
101 <button
102 onClick={() => onDelete(reply)}
103 className="text-surface-400 dark:text-surface-500 hover:text-red-500 dark:hover:text-red-400 transition-colors"
104 >
105 <Trash2 size={12} />
106 </button>
107 )}
108 </div>
109 </div>
110 <p
111 className={clsx(
112 "text-surface-800 dark:text-surface-200 whitespace-pre-wrap break-words leading-relaxed",
113 depth > 0 ? "text-sm" : "text-sm",
114 )}
115 >
116 {reply.text || reply.body?.value}
117 </p>
118 </div>
119 </>
120 ) : (
121 <div className="p-3 bg-white dark:bg-surface-900 rounded-lg ring-1 ring-black/5 dark:ring-white/5">
122 <div className="flex items-center gap-2 mb-2">
123 <a href={`/profile/${author.handle}`} className="shrink-0">
124 {getAvatarUrl(author.did, author.avatar) ? (
125 <img
126 src={getAvatarUrl(author.did, author.avatar)}
127 alt=""
128 className="w-7 h-7 rounded-full object-cover bg-surface-200 dark:bg-surface-700"
129 />
130 ) : (
131 <div className="w-7 h-7 rounded-full bg-surface-200 dark:bg-surface-700 flex items-center justify-center text-surface-500 dark:text-surface-400 font-bold text-xs">
132 {(author.displayName ||
133 author.handle ||
134 "?")[0]?.toUpperCase()}
135 </div>
136 )}
137 </a>
138 <div className="flex flex-col">
139 <span className="font-medium text-surface-900 dark:text-white text-sm">
140 {author.displayName || author.handle}
141 </span>
142 </div>
143 <span className="text-surface-400 dark:text-surface-500 text-xs ml-auto">
144 {reply.createdAt
145 ? formatDistanceToNow(new Date(reply.createdAt), {
146 addSuffix: false,
147 })
148 : ""}
149 </span>
150 </div>
151 <p className="text-surface-800 dark:text-surface-200 text-sm pl-9 mb-2 whitespace-pre-wrap break-words">
152 {reply.text || reply.body?.value}
153 </p>
154 <div className="flex items-center justify-end gap-2 pl-9">
155 <button
156 onClick={() => onReply(reply)}
157 className="text-surface-400 dark:text-surface-500 hover:text-primary-600 dark:hover:text-primary-400 transition-colors p-1"
158 >
159 <Reply size={14} />
160 </button>
161 {isReplyOwner && (
162 <button
163 onClick={() => onDelete(reply)}
164 className="text-surface-400 dark:text-surface-500 hover:text-red-500 dark:hover:text-red-400 transition-colors p-1"
165 >
166 <Trash2 size={14} />
167 </button>
168 )}
169 </div>
170 </div>
171 )}
172 </div>
173 {reply.children && reply.children.length > 0 && (
174 <div className="flex flex-col">
175 {reply.children.map((child) => (
176 <ReplyItem
177 key={child.uri || child.id}
178 reply={child}
179 depth={depth + 1}
180 user={user}
181 onReply={onReply}
182 onDelete={onDelete}
183 isInline={isInline}
184 />
185 ))}
186 </div>
187 )}
188 </div>
189 );
190};
191
192export default function ReplyList({
193 replies,
194 rootUri,
195 user,
196 onReply,
197 onDelete,
198 isInline = false,
199}: ReplyListProps) {
200 const { t } = useTranslation();
201 if (!replies || replies.length === 0) {
202 return (
203 <div className="py-8 text-center">
204 <p className="text-surface-500 dark:text-surface-400 text-sm">
205 {t("replyList.noReplies")}
206 </p>
207 </div>
208 );
209 }
210
211 const buildReplyTree = () => {
212 const replyMap: Record<
213 string,
214 AnnotationItem & { children: AnnotationItem[] }
215 > = {};
216 const rootReplies: (AnnotationItem & { children: AnnotationItem[] })[] = [];
217
218 replies.forEach((r) => {
219 replyMap[r.uri || r.id || ""] = { ...r, children: [] };
220 });
221
222 replies.forEach((r) => {
223 const parentUri = r.reply?.parent?.uri || r.parentUri;
224 if (parentUri === rootUri || !parentUri || !replyMap[parentUri]) {
225 rootReplies.push(replyMap[r.uri || r.id || ""]);
226 } else {
227 replyMap[parentUri].children.push(replyMap[r.uri || r.id || ""]);
228 }
229 });
230
231 return rootReplies;
232 };
233
234 const replyTree = buildReplyTree();
235
236 return (
237 <div className="flex flex-col gap-1">
238 {replyTree.map((reply) => (
239 <ReplyItem
240 key={reply.uri || reply.id}
241 reply={reply}
242 depth={0}
243 user={user}
244 onReply={onReply}
245 onDelete={onDelete}
246 isInline={isInline}
247 />
248 ))}
249 </div>
250 );
251}