Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 249 lines 8.5 kB view raw
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}