Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments

users: Extract user meta from cards

User meta data in cards was duplicated across annotations, highlights,
and bookmarks, some with varying behavior. This change consolidates all
of that into one shared component.

isaaccorbrey.com 79f139bc a8ece103

verified
+68 -165
+3 -109
web/src/components/AnnotationCard.jsx
··· 27 27 } from "lucide-react"; 28 28 import { HighlightIcon, TrashIcon } from "./Icons"; 29 29 import ShareMenu from "./ShareMenu"; 30 + import UserMeta from "./UserMeta"; 30 31 31 32 function buildTextFragmentUrl(baseUrl, selector) { 32 33 if (!selector || selector.type !== "TextQuoteSelector" || !selector.exact) { ··· 173 174 } 174 175 }; 175 176 176 - const formatDate = (dateString, simple = true) => { 177 - if (!dateString) return ""; 178 - const date = new Date(dateString); 179 - const now = new Date(); 180 - const diff = now - date; 181 - const minutes = Math.floor(diff / 60000); 182 - const hours = Math.floor(diff / 3600000); 183 - const days = Math.floor(diff / 86400000); 184 - if (minutes < 1) return "just now"; 185 - if (minutes < 60) return `${minutes}m`; 186 - if (hours < 24) return `${hours}h`; 187 - if (days < 7) return `${days}d`; 188 - if (simple) 189 - return date.toLocaleDateString("en-US", { 190 - month: "short", 191 - day: "numeric", 192 - }); 193 - return date.toLocaleString(); 194 - }; 195 - 196 - const authorDisplayName = data.author?.displayName || data.author?.handle; 197 - const authorHandle = data.author?.handle; 198 - const authorAvatar = data.author?.avatar; 199 - const authorDid = data.author?.did; 200 - const marginProfileUrl = authorDid ? `/profile/${authorDid}` : null; 201 177 const highlightedText = 202 178 data.selector?.type === "TextQuoteSelector" ? data.selector.exact : null; 203 179 const fragmentUrl = buildTextFragmentUrl(data.url, data.selector); ··· 245 221 <article className="card annotation-card"> 246 222 <header className="annotation-header"> 247 223 <div className="annotation-header-left"> 248 - <Link to={marginProfileUrl || "#"} className="annotation-avatar-link"> 249 - <div className="annotation-avatar"> 250 - {authorAvatar ? ( 251 - <img src={authorAvatar} alt={authorDisplayName} /> 252 - ) : ( 253 - <span> 254 - {(authorDisplayName || authorHandle || "??") 255 - ?.substring(0, 2) 256 - .toUpperCase()} 257 - </span> 258 - )} 259 - </div> 260 - </Link> 261 - <div className="annotation-meta"> 262 - <div className="annotation-author-row"> 263 - <Link 264 - to={marginProfileUrl || "#"} 265 - className="annotation-author-link" 266 - > 267 - <span className="annotation-author">{authorDisplayName}</span> 268 - </Link> 269 - {authorHandle && ( 270 - <a 271 - href={`https://bsky.app/profile/${authorHandle}`} 272 - target="_blank" 273 - rel="noopener noreferrer" 274 - className="annotation-handle" 275 - > 276 - @{authorHandle} 277 - </a> 278 - )} 279 - </div> 280 - <div className="annotation-time">{formatDate(data.createdAt)}</div> 281 - </div> 224 + <UserMeta author={data.author} createdAt={data.createdAt} /> 282 225 </div> 283 226 <div className="annotation-header-right"> 284 227 <div style={{ display: "flex", gap: "4px" }}> ··· 606 549 } 607 550 }; 608 551 609 - const formatDate = (dateString, simple = true) => { 610 - if (!dateString) return ""; 611 - const date = new Date(dateString); 612 - const now = new Date(); 613 - const diff = now - date; 614 - const minutes = Math.floor(diff / 60000); 615 - const hours = Math.floor(diff / 3600000); 616 - const days = Math.floor(diff / 86400000); 617 - if (minutes < 1) return "just now"; 618 - if (minutes < 60) return `${minutes}m`; 619 - if (hours < 24) return `${hours}h`; 620 - if (days < 7) return `${days}d`; 621 - if (simple) 622 - return date.toLocaleDateString("en-US", { 623 - month: "short", 624 - day: "numeric", 625 - }); 626 - return date.toLocaleString(); 627 - }; 628 - 629 552 return ( 630 553 <article className="card annotation-card"> 631 554 <header className="annotation-header"> 632 555 <div className="annotation-header-left"> 633 - <Link 634 - to={data.author?.did ? `/profile/${data.author.did}` : "#"} 635 - className="annotation-avatar-link" 636 - > 637 - <div className="annotation-avatar"> 638 - {data.author?.avatar ? ( 639 - <img src={data.author.avatar} alt="avatar" /> 640 - ) : ( 641 - <span>??</span> 642 - )} 643 - </div> 644 - </Link> 645 - <div className="annotation-meta"> 646 - <Link to="#" className="annotation-author-link"> 647 - <span className="annotation-author"> 648 - {data.author?.displayName || "Unknown"} 649 - </span> 650 - </Link> 651 - <div className="annotation-time">{formatDate(data.createdAt)}</div> 652 - {data.author?.handle && ( 653 - <a 654 - href={`https://bsky.app/profile/${data.author.handle}`} 655 - target="_blank" 656 - rel="noopener noreferrer" 657 - className="annotation-handle" 658 - > 659 - @{data.author.handle} 660 - </a> 661 - )} 662 - </div> 556 + <UserMeta author={data.author} createdAt={data.createdAt} /> 663 557 </div> 664 558 665 559 <div className="annotation-header-right">
+2 -56
web/src/components/BookmarkCard.jsx
··· 1 1 import { useState, useEffect } from "react"; 2 2 import { useAuth } from "../context/AuthContext"; 3 - import { Link } from "react-router-dom"; 4 3 import { 5 4 normalizeAnnotation, 6 5 normalizeBookmark, ··· 12 11 import { HeartIcon, TrashIcon, BookmarkIcon } from "./Icons"; 13 12 import { Folder } from "lucide-react"; 14 13 import ShareMenu from "./ShareMenu"; 14 + import UserMeta from "./UserMeta"; 15 15 16 16 export default function BookmarkCard({ 17 17 bookmark, ··· 90 90 } 91 91 }; 92 92 93 - const formatDate = (dateString) => { 94 - if (!dateString) return ""; 95 - const date = new Date(dateString); 96 - const now = new Date(); 97 - const diff = now - date; 98 - const minutes = Math.floor(diff / 60000); 99 - const hours = Math.floor(diff / 3600000); 100 - const days = Math.floor(diff / 86400000); 101 - if (minutes < 1) return "just now"; 102 - if (minutes < 60) return `${minutes}m`; 103 - if (hours < 24) return `${hours}h`; 104 - if (days < 7) return `${days}d`; 105 - return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); 106 - }; 107 - 108 93 let domain = ""; 109 94 try { 110 95 if (data.url) domain = new URL(data.url).hostname.replace("www.", ""); 111 96 } catch { 112 97 /* ignore */ 113 98 } 114 - 115 - const authorDisplayName = data.author?.displayName || data.author?.handle; 116 - const authorHandle = data.author?.handle; 117 - const authorAvatar = data.author?.avatar; 118 - const authorDid = data.author?.did; 119 - const marginProfileUrl = authorDid ? `/profile/${authorDid}` : null; 120 99 121 100 return ( 122 101 <article className="card annotation-card bookmark-card"> 123 102 <header className="annotation-header"> 124 103 <div className="annotation-header-left"> 125 - <Link to={marginProfileUrl || "#"} className="annotation-avatar-link"> 126 - <div className="annotation-avatar"> 127 - {authorAvatar ? ( 128 - <img src={authorAvatar} alt={authorDisplayName} /> 129 - ) : ( 130 - <span> 131 - {(authorDisplayName || authorHandle || "??") 132 - ?.substring(0, 2) 133 - .toUpperCase()} 134 - </span> 135 - )} 136 - </div> 137 - </Link> 138 - <div className="annotation-meta"> 139 - <div className="annotation-author-row"> 140 - <Link 141 - to={marginProfileUrl || "#"} 142 - className="annotation-author-link" 143 - > 144 - <span className="annotation-author">{authorDisplayName}</span> 145 - </Link> 146 - {authorHandle && ( 147 - <a 148 - href={`https://bsky.app/profile/${authorHandle}`} 149 - target="_blank" 150 - rel="noopener noreferrer" 151 - className="annotation-handle" 152 - > 153 - @{authorHandle} 154 - </a> 155 - )} 156 - </div> 157 - <div className="annotation-time">{formatDate(data.createdAt)}</div> 158 - </div> 104 + <UserMeta author={data.author} createdAt={data.createdAt} /> 159 105 </div> 160 106 161 107 <div className="annotation-header-right">
+63
web/src/components/UserMeta.jsx
··· 1 + import { Link } from "react-router-dom"; 2 + 3 + const formatDate = (dateString, simple = true) => { 4 + if (!dateString) return ""; 5 + const date = new Date(dateString); 6 + const now = new Date(); 7 + const diff = now - date; 8 + const minutes = Math.floor(diff / 60000); 9 + const hours = Math.floor(diff / 3600000); 10 + const days = Math.floor(diff / 86400000); 11 + if (minutes < 1) return "just now"; 12 + if (minutes < 60) return `${minutes}m`; 13 + if (hours < 24) return `${hours}h`; 14 + if (days < 7) return `${days}d`; 15 + if (simple) 16 + return date.toLocaleDateString("en-US", { 17 + month: "short", 18 + day: "numeric", 19 + }); 20 + return date.toLocaleString(); 21 + }; 22 + 23 + export default function UserMeta({ author, createdAt }) { 24 + const authorDisplayName = author?.displayName || author?.handle || "Unknown"; 25 + const authorHandle = author?.handle; 26 + const authorAvatar = author?.avatar; 27 + const authorDid = author?.did; 28 + const marginProfileUrl = authorDid ? `/profile/${authorDid}` : "#"; 29 + 30 + return ( 31 + <> 32 + <Link to={marginProfileUrl} className="annotation-avatar-link"> 33 + <div className="annotation-avatar"> 34 + {authorAvatar ? ( 35 + <img src={authorAvatar} alt={authorDisplayName} /> 36 + ) : ( 37 + <span> 38 + {authorDisplayName?.substring(0, 2).toUpperCase() || "??"} 39 + </span> 40 + )} 41 + </div> 42 + </Link> 43 + <div className="annotation-meta"> 44 + <div className="annotation-author-row"> 45 + <Link to={marginProfileUrl} className="annotation-author-link"> 46 + <span className="annotation-author">{authorDisplayName}</span> 47 + </Link> 48 + {authorHandle && ( 49 + <a 50 + href={`https://bsky.app/profile/${authorHandle}`} 51 + target="_blank" 52 + rel="noopener noreferrer" 53 + className="annotation-handle" 54 + > 55 + @{authorHandle} 56 + </a> 57 + )} 58 + </div> 59 + <div className="annotation-time">{formatDate(createdAt)}</div> 60 + </div> 61 + </> 62 + ); 63 + }