(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 ui-refactor 337 lines 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}