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