(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
99
fork

Configure Feed

Select the types of activity you want to include in your feed.

at frontend-rewrite 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}