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

Configure Feed

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

at main 251 lines 8.7 kB view raw
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}