1import { Link } from "react-router-dom";
2import { MessageSquare, Trash2, Reply } from "lucide-react";
3
4function formatDate(dateString) {
5 if (!dateString) return "";
6 const date = new Date(dateString);
7 const now = new Date();
8 const diff = now - date;
9 const minutes = Math.floor(diff / 60000);
10 const hours = Math.floor(diff / 3600000);
11 const days = Math.floor(diff / 86400000);
12 if (minutes < 1) return "just now";
13 if (minutes < 60) return `${minutes}m`;
14 if (hours < 24) return `${hours}h`;
15 if (days < 7) return `${days}d`;
16 return date.toLocaleDateString();
17}
18
19function ReplyItem({ reply, depth = 0, user, onReply, onDelete, isInline }) {
20 const author = reply.creator || reply.author || {};
21 const isReplyOwner = user?.did && author.did === user.did;
22
23 const containerStyle = isInline
24 ? {
25 display: "flex",
26 gap: "10px",
27 padding: depth > 0 ? "10px 12px 10px 16px" : "12px 16px",
28 marginLeft: depth * 20,
29 borderLeft: depth > 0 ? "2px solid var(--accent-subtle)" : "none",
30 background: depth > 0 ? "rgba(168, 85, 247, 0.03)" : "transparent",
31 }
32 : {
33 marginLeft: depth * 24,
34 borderLeft: depth > 0 ? "2px solid var(--accent-subtle)" : "none",
35 paddingLeft: depth > 0 ? "16px" : "0",
36 background: depth > 0 ? "rgba(168, 85, 247, 0.02)" : "transparent",
37 marginBottom: "12px",
38 };
39
40 const avatarSize = isInline ? (depth > 0 ? 28 : 32) : depth > 0 ? 28 : 36;
41
42 return (
43 <div key={reply.id || reply.uri}>
44 <div
45 className={isInline ? "inline-reply" : "reply-card-threaded"}
46 style={containerStyle}
47 >
48 {isInline ? (
49 <>
50 <Link
51 to={`/profile/${author.handle}`}
52 className="inline-reply-avatar"
53 style={{
54 width: avatarSize,
55 height: avatarSize,
56 minWidth: avatarSize,
57 }}
58 >
59 {author.avatar ? (
60 <img
61 src={author.avatar}
62 alt=""
63 style={{
64 width: "100%",
65 height: "100%",
66 borderRadius: "50%",
67 objectFit: "cover",
68 }}
69 />
70 ) : (
71 <span
72 style={{
73 width: "100%",
74 height: "100%",
75 borderRadius: "50%",
76 background:
77 "linear-gradient(135deg, var(--accent), #a855f7)",
78 display: "flex",
79 alignItems: "center",
80 justifyContent: "center",
81 fontSize: depth > 0 ? "0.65rem" : "0.75rem",
82 fontWeight: 600,
83 color: "white",
84 }}
85 >
86 {(author.displayName ||
87 author.handle ||
88 "?")[0].toUpperCase()}
89 </span>
90 )}
91 </Link>
92 <div style={{ flex: 1, minWidth: 0 }}>
93 <div
94 style={{
95 display: "flex",
96 alignItems: "center",
97 gap: "6px",
98 flexWrap: "wrap",
99 marginBottom: "4px",
100 }}
101 >
102 <span
103 style={{
104 fontWeight: 600,
105 fontSize: depth > 0 ? "0.8rem" : "0.85rem",
106 color: "var(--text-primary)",
107 }}
108 >
109 {author.displayName || author.handle}
110 </span>
111 <Link
112 to={`/profile/${author.handle}`}
113 style={{
114 color: "var(--text-tertiary)",
115 fontSize: depth > 0 ? "0.75rem" : "0.8rem",
116 textDecoration: "none",
117 }}
118 >
119 @{author.handle}
120 </Link>
121 <span
122 style={{ color: "var(--text-tertiary)", fontSize: "0.7rem" }}
123 >
124 ·
125 </span>
126 <span
127 style={{ color: "var(--text-tertiary)", fontSize: "0.7rem" }}
128 >
129 {formatDate(reply.created || reply.createdAt)}
130 </span>
131
132 <div
133 style={{ marginLeft: "auto", display: "flex", gap: "4px" }}
134 >
135 <button
136 onClick={() => onReply(reply)}
137 style={{
138 background: "none",
139 border: "none",
140 color: "var(--text-tertiary)",
141 cursor: "pointer",
142 padding: "2px 6px",
143 fontSize: "0.7rem",
144 display: "flex",
145 alignItems: "center",
146 gap: "3px",
147 borderRadius: "4px",
148 }}
149 >
150 <MessageSquare size={11} />
151 </button>
152 {isReplyOwner && (
153 <button
154 onClick={() => onDelete(reply)}
155 style={{
156 background: "none",
157 border: "none",
158 color: "var(--text-tertiary)",
159 cursor: "pointer",
160 padding: "2px 6px",
161 fontSize: "0.7rem",
162 display: "flex",
163 alignItems: "center",
164 gap: "3px",
165 borderRadius: "4px",
166 }}
167 >
168 <Trash2 size={11} />
169 </button>
170 )}
171 </div>
172 </div>
173 <p
174 style={{
175 margin: 0,
176 fontSize: depth > 0 ? "0.85rem" : "0.9rem",
177 lineHeight: 1.5,
178 color: "var(--text-primary)",
179 }}
180 >
181 {reply.text || reply.body?.value}
182 </p>
183 </div>
184 </>
185 ) : (
186 <>
187 <div className="reply-header">
188 <Link
189 to={`/profile/${author.handle}`}
190 className="reply-avatar-link"
191 >
192 <div
193 className="reply-avatar"
194 style={{ width: avatarSize, height: avatarSize }}
195 >
196 {author.avatar ? (
197 <img
198 src={author.avatar}
199 alt={author.displayName || author.handle}
200 />
201 ) : (
202 <span>
203 {(author.displayName ||
204 author.handle ||
205 "?")[0].toUpperCase()}
206 </span>
207 )}
208 </div>
209 </Link>
210 <div className="reply-meta">
211 <span className="reply-author">
212 {author.displayName || author.handle}
213 </span>
214 {author.handle && (
215 <Link
216 to={`/profile/${author.handle}`}
217 className="reply-handle"
218 >
219 @{author.handle}
220 </Link>
221 )}
222 <span className="reply-dot">·</span>
223 <span className="reply-time">
224 {formatDate(reply.created || reply.createdAt)}
225 </span>
226 </div>
227 <div className="reply-actions">
228 <button
229 className="reply-action-btn"
230 onClick={() => onReply(reply)}
231 title="Reply"
232 >
233 <Reply size={14} />
234 </button>
235 {isReplyOwner && (
236 <button
237 className="reply-action-btn reply-action-delete"
238 onClick={() => onDelete(reply)}
239 title="Delete"
240 >
241 <Trash2 size={14} />
242 </button>
243 )}
244 </div>
245 </div>
246 <p className="reply-text">{reply.text || reply.body?.value}</p>
247 </>
248 )}
249 </div>
250 {reply.children &&
251 reply.children.map((child) => (
252 <ReplyItem
253 key={child.id || child.uri}
254 reply={child}
255 depth={depth + 1}
256 user={user}
257 onReply={onReply}
258 onDelete={onDelete}
259 isInline={isInline}
260 />
261 ))}
262 </div>
263 );
264}
265
266export default function ReplyList({
267 replies,
268 rootUri,
269 user,
270 onReply,
271 onDelete,
272 isInline = false,
273}) {
274 if (!replies || replies.length === 0) {
275 if (isInline) {
276 return (
277 <div
278 style={{
279 padding: "16px",
280 textAlign: "center",
281 fontSize: "0.9rem",
282 color: "var(--text-secondary)",
283 }}
284 >
285 No replies yet
286 </div>
287 );
288 }
289 return (
290 <div className="empty-state" style={{ padding: "32px" }}>
291 <p className="empty-state-text">
292 No replies yet. Be the first to reply!
293 </p>
294 </div>
295 );
296 }
297
298 const buildReplyTree = () => {
299 const replyMap = {};
300 const rootReplies = [];
301
302 replies.forEach((r) => {
303 replyMap[r.id || r.uri] = { ...r, children: [] };
304 });
305
306 replies.forEach((r) => {
307 const parentUri = r.inReplyTo || r.parentUri;
308 if (parentUri === rootUri) {
309 rootReplies.push(replyMap[r.id || r.uri]);
310 } else if (replyMap[parentUri]) {
311 replyMap[parentUri].children.push(replyMap[r.id || r.uri]);
312 } else {
313 rootReplies.push(replyMap[r.id || r.uri]);
314 }
315 });
316
317 return rootReplies;
318 };
319
320 const replyTree = buildReplyTree();
321
322 return (
323 <div className={isInline ? "replies-list" : "replies-list-threaded"}>
324 {replyTree.map((reply) => (
325 <ReplyItem
326 key={reply.id || reply.uri}
327 reply={reply}
328 depth={0}
329 user={user}
330 onReply={onReply}
331 onDelete={onDelete}
332 isInline={isInline}
333 />
334 ))}
335 </div>
336 );
337}