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