(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.

bug fixes + better ui/ux

scanash00 98cc9c91 fda4506d

+245 -58
+45 -2
web/src/api/client.ts
··· 139 139 name: string; 140 140 icon?: string; 141 141 }; 142 + context?: { 143 + uri: string; 144 + name: string; 145 + icon?: string; 146 + }[]; 142 147 created?: string; 143 148 createdAt?: string; 144 149 target?: string | { source?: string; title?: string; selector?: Selector }; ··· 171 176 icon: raw.collection.icon, 172 177 } 173 178 : undefined, 179 + context: raw.context 180 + ? raw.context.map((c: any) => ({ 181 + uri: c.uri, 182 + name: c.name, 183 + icon: c.icon, 184 + })) 185 + : undefined, 174 186 addedBy: raw.creator || raw.author, 175 - createdAt: raw.created || raw.createdAt || new Date().toISOString(), 187 + createdAt: 188 + normalizedInner.createdAt || 189 + raw.created || 190 + raw.createdAt || 191 + new Date().toISOString(), 176 192 collectionItemUri: raw.id || raw.uri, 177 193 }; 178 194 } ··· 248 264 }); 249 265 if (!res.ok) throw new Error("Failed to fetch feed"); 250 266 const data = await res.json(); 267 + const normalizedItems = (data.items || []).map(normalizeItem); 268 + 269 + const groupedItems: AnnotationItem[] = []; 270 + if (normalizedItems.length > 0) { 271 + groupedItems.push(normalizedItems[0]); 272 + 273 + for (let i = 1; i < normalizedItems.length; i++) { 274 + const prev = groupedItems[groupedItems.length - 1]; 275 + const curr = normalizedItems[i]; 276 + 277 + if (prev.collection && curr.collection) { 278 + if ( 279 + prev.uri === curr.uri && 280 + prev.addedBy?.did === curr.addedBy?.did 281 + ) { 282 + if (!prev.context) { 283 + prev.context = [prev.collection]; 284 + } 285 + prev.context.push(curr.collection); 286 + groupedItems[groupedItems.length - 1] = prev; 287 + continue; 288 + } 289 + } 290 + groupedItems.push(curr); 291 + } 292 + } 293 + 251 294 return { 252 295 cursor: data.cursor, 253 - items: (data.items || []).map(normalizeItem), 296 + items: groupedItems, 254 297 }; 255 298 } catch (e) { 256 299 console.error(e);
+57 -30
web/src/components/common/Card.tsx
··· 274 274 275 275 return ( 276 276 <article className="card p-4 hover:ring-black/10 dark:hover:ring-white/10 transition-all relative"> 277 - {item.collection && ( 278 - <div className="flex items-center gap-1.5 text-xs text-surface-400 dark:text-surface-500 mb-2"> 277 + {(item.collection || (item.context && item.context.length > 0)) && ( 278 + <div className="flex items-center gap-1.5 text-xs text-surface-400 dark:text-surface-500 mb-2 flex-wrap"> 279 279 {item.addedBy && item.addedBy.did !== item.author?.did ? ( 280 280 <> 281 281 <ProfileHoverCard did={item.addedBy.did}> ··· 298 298 ) : ( 299 299 <span>Added to</span> 300 300 )} 301 - <Link 302 - to={`/${item.addedBy?.handle || ""}/collection/${(item.collection.uri || "").split("/").pop()}`} 303 - className="inline-flex items-center gap-1 hover:text-primary-600 dark:hover:text-primary-400 transition-colors" 304 - > 305 - <CollectionIcon icon={item.collection.icon} size={14} /> 306 - <span className="font-medium">{item.collection.name}</span> 307 - </Link> 301 + 302 + {item.context && item.context.length > 0 ? ( 303 + item.context.map((col, index) => ( 304 + <React.Fragment key={col.uri}> 305 + {index > 0 && index < item.context!.length - 1 && ( 306 + <span className="text-surface-300 dark:text-surface-600"> 307 + , 308 + </span> 309 + )} 310 + {index > 0 && index === item.context!.length - 1 && ( 311 + <span>and</span> 312 + )} 313 + <Link 314 + to={`/${item.addedBy?.handle || ""}/collection/${(col.uri || "").split("/").pop()}`} 315 + className="inline-flex items-center gap-1 hover:text-primary-600 dark:hover:text-primary-400 transition-colors" 316 + > 317 + <CollectionIcon icon={col.icon} size={14} /> 318 + <span className="font-medium">{col.name}</span> 319 + </Link> 320 + </React.Fragment> 321 + )) 322 + ) : ( 323 + <Link 324 + to={`/${item.addedBy?.handle || ""}/collection/${(item.collection!.uri || "").split("/").pop()}`} 325 + className="inline-flex items-center gap-1 hover:text-primary-600 dark:hover:text-primary-400 transition-colors" 326 + > 327 + <CollectionIcon icon={item.collection!.icon} size={14} /> 328 + <span className="font-medium">{item.collection!.name}</span> 329 + </Link> 330 + )} 308 331 </div> 309 332 )} 310 333 ··· 426 449 </button> 427 450 )} 428 451 {isBookmark && ( 429 - <a 430 - href={pageUrl || "#"} 431 - target={pageUrl ? "_blank" : undefined} 432 - rel="noopener noreferrer" 433 - onClick={(e) => pageUrl && handleExternalClick(e, pageUrl)} 434 - className="block bg-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 hover:border-primary-300 dark:hover:border-primary-600 hover:bg-surface-100 dark:hover:bg-surface-700 transition-all group overflow-hidden" 452 + <div 453 + onClick={(e) => { 454 + e.preventDefault(); 455 + if (pageUrl) handleExternalClick(e, pageUrl); 456 + }} 457 + role="button" 458 + tabIndex={0} 459 + className="flex items-stretch bg-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 hover:border-primary-300 dark:hover:border-primary-600 hover:bg-surface-100 dark:hover:bg-surface-700 transition-all group overflow-hidden cursor-pointer" 435 460 > 436 461 {displayImage && !imgError && ( 437 - <div className="h-32 w-full overflow-hidden bg-surface-200 dark:bg-surface-700 border-b border-surface-200 dark:border-surface-700"> 438 - <img 439 - src={displayImage} 440 - alt="" 441 - onError={() => setImgError(true)} 442 - className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" 443 - /> 462 + <div className="w-[140px] sm:w-[180px] shrink-0 border-r border-surface-200 dark:border-surface-700 bg-surface-200 dark:bg-surface-700 relative"> 463 + <div className="absolute inset-0 flex items-center justify-center overflow-hidden"> 464 + <img 465 + src={displayImage} 466 + alt="" 467 + onError={() => setImgError(true)} 468 + className="w-full h-full object-cover" 469 + /> 470 + </div> 444 471 </div> 445 472 )} 446 - <div className="p-4"> 447 - <h3 className="font-semibold text-surface-900 dark:text-white text-base leading-snug group-hover:text-primary-600 dark:group-hover:text-primary-400 mb-2 transition-colors"> 473 + <div className="p-3 flex-1 min-w-0 flex flex-col justify-center font-sans"> 474 + <h3 className="font-semibold text-surface-900 dark:text-white text-sm leading-snug group-hover:text-primary-600 dark:group-hover:text-primary-400 mb-1.5 transition-colors line-clamp-2"> 448 475 {displayTitle} 449 476 </h3> 450 477 451 478 {displayDescription && ( 452 - <p className="text-surface-600 dark:text-surface-400 text-sm leading-relaxed mb-3 line-clamp-2"> 479 + <p className="text-surface-600 dark:text-surface-400 text-xs leading-relaxed mb-2 line-clamp-2"> 453 480 {displayDescription} 454 481 </p> 455 482 )} 456 483 457 - <div className="flex items-center gap-2 text-xs text-surface-500 dark:text-surface-500"> 458 - <div className="w-5 h-5 rounded-full bg-surface-200 dark:bg-surface-700 flex items-center justify-center shrink-0 overflow-hidden"> 484 + <div className="flex items-center gap-2 text-[11px] text-surface-500 dark:text-surface-500 mt-auto"> 485 + <div className="w-4 h-4 rounded-full bg-surface-200 dark:bg-surface-700 flex items-center justify-center shrink-0 overflow-hidden"> 459 486 {ogData?.icon && !iconError ? ( 460 487 <img 461 488 src={ogData.icon} 462 489 alt="" 463 490 onError={() => setIconError(true)} 464 - className="w-3.5 h-3.5 object-contain" 491 + className="w-3 h-3 object-contain" 465 492 /> 466 493 ) : ( 467 - <Globe size={10} /> 494 + <Globe size={9} /> 468 495 )} 469 496 </div> 470 497 <span className="truncate max-w-[200px]"> ··· 472 499 </span> 473 500 </div> 474 501 </div> 475 - </a> 502 + </div> 476 503 )} 477 504 478 505 {item.target?.selector?.exact && (
+137 -25
web/src/components/common/RichText.tsx
··· 1 1 import React from "react"; 2 2 import { Link } from "react-router-dom"; 3 + import ExternalLinkModal from "../modals/ExternalLinkModal"; 4 + import { useStore } from "@nanostores/react"; 5 + import { $preferences } from "../../store/preferences"; 3 6 4 7 interface RichTextProps { 5 8 text: string; ··· 9 12 const MENTION_REGEX = 10 13 /(^|[\s(])@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)/g; 11 14 15 + const URL_REGEX = /(^|[\s(])(https?:\/\/[^\s]+)/g; 16 + 12 17 export default function RichText({ text, className }: RichTextProps) { 13 - const parts: React.ReactNode[] = []; 14 - let lastIndex = 0; 18 + const urlParts: { text: string; isUrl: boolean }[] = []; 19 + let lastUrlIndex = 0; 15 20 16 - for (const match of text.matchAll(MENTION_REGEX)) { 21 + for (const match of text.matchAll(URL_REGEX)) { 17 22 const fullMatch = match[0]; 18 23 const prefix = match[1]; 19 - const handle = match[2]; 24 + const url = match[2]; 20 25 const startIndex = match.index!; 21 26 22 - if (startIndex > lastIndex) { 23 - parts.push(text.slice(lastIndex, startIndex)); 27 + if (startIndex > lastUrlIndex) { 28 + urlParts.push({ 29 + text: text.slice(lastUrlIndex, startIndex), 30 + isUrl: false, 31 + }); 24 32 } 25 - 26 33 if (prefix) { 27 - parts.push(prefix); 34 + urlParts.push({ text: prefix, isUrl: false }); 28 35 } 29 36 30 - parts.push( 31 - <Link 32 - key={startIndex} 33 - to={`/profile/${handle}`} 34 - className="text-primary-600 dark:text-primary-400 hover:underline" 35 - onClick={(e) => e.stopPropagation()} 36 - > 37 - @{handle} 38 - </Link>, 39 - ); 37 + urlParts.push({ text: url, isUrl: true }); 40 38 41 - lastIndex = startIndex + fullMatch.length; 39 + lastUrlIndex = startIndex + fullMatch.length; 40 + } 41 + if (lastUrlIndex < text.length) { 42 + urlParts.push({ text: text.slice(lastUrlIndex), isUrl: false }); 42 43 } 43 44 44 - if (lastIndex < text.length) { 45 - parts.push(text.slice(lastIndex)); 45 + if (urlParts.length === 0) { 46 + urlParts.push({ text, isUrl: false }); 46 47 } 47 48 48 - if (parts.length === 0) { 49 - return <span className={className}>{text}</span>; 50 - } 49 + const [showExternalLinkModal, setShowExternalLinkModal] = 50 + React.useState(false); 51 + const [externalLinkUrl, setExternalLinkUrl] = React.useState<string | null>( 52 + null, 53 + ); 54 + const preferences = useStore($preferences); 51 55 52 - return <span className={className}>{parts}</span>; 56 + const safeUrlHostname = (url: string | null | undefined) => { 57 + if (!url) return null; 58 + try { 59 + return new URL(url).hostname; 60 + } catch { 61 + return null; 62 + } 63 + }; 64 + 65 + const handleExternalClick = (e: React.MouseEvent, url: string) => { 66 + e.preventDefault(); 67 + e.stopPropagation(); 68 + 69 + try { 70 + const hostname = safeUrlHostname(url); 71 + if (hostname) { 72 + if ( 73 + hostname === "margin.at" || 74 + hostname.endsWith(".margin.at") || 75 + hostname === "semble.so" || 76 + hostname.endsWith(".semble.so") 77 + ) { 78 + window.open(url, "_blank", "noopener,noreferrer"); 79 + return; 80 + } 81 + const skipped = preferences.externalLinkSkippedHostnames || []; 82 + if (skipped.includes(hostname)) { 83 + window.open(url, "_blank", "noopener,noreferrer"); 84 + return; 85 + } 86 + } 87 + } catch (err) { 88 + if (err instanceof Error && err.name !== "TypeError") { 89 + console.debug("Failed to check skipped hostname:", err); 90 + } 91 + } 92 + 93 + setExternalLinkUrl(url); 94 + setShowExternalLinkModal(true); 95 + }; 96 + 97 + const finalParts: React.ReactNode[] = []; 98 + 99 + urlParts.forEach((part, partIndex) => { 100 + if (part.isUrl) { 101 + finalParts.push( 102 + <a 103 + key={`url-${partIndex}`} 104 + href={part.text} 105 + target="_blank" 106 + rel="noopener noreferrer" 107 + className="text-primary-600 dark:text-primary-400 hover:underline break-all cursor-pointer" 108 + onClick={(e) => handleExternalClick(e, part.text)} 109 + > 110 + {part.text} 111 + </a>, 112 + ); 113 + } else { 114 + let lastMentionIndex = 0; 115 + const mentionMatches = Array.from(part.text.matchAll(MENTION_REGEX)); 116 + 117 + if (mentionMatches.length === 0) { 118 + finalParts.push(part.text); 119 + } else { 120 + for (const match of mentionMatches) { 121 + const fullMatch = match[0]; 122 + const prefix = match[1]; 123 + const handle = match[2]; 124 + const startIndex = match.index!; 125 + 126 + if (startIndex > lastMentionIndex) { 127 + finalParts.push(part.text.slice(lastMentionIndex, startIndex)); 128 + } 129 + 130 + if (prefix) { 131 + finalParts.push(prefix); 132 + } 133 + 134 + finalParts.push( 135 + <Link 136 + key={`mention-${partIndex}-${startIndex}`} 137 + to={`/profile/${handle}`} 138 + className="text-primary-600 dark:text-primary-400 hover:underline" 139 + onClick={(e) => e.stopPropagation()} 140 + > 141 + @{handle} 142 + </Link>, 143 + ); 144 + 145 + lastMentionIndex = startIndex + fullMatch.length; 146 + } 147 + 148 + if (lastMentionIndex < part.text.length) { 149 + finalParts.push(part.text.slice(lastMentionIndex)); 150 + } 151 + } 152 + } 153 + }); 154 + 155 + return ( 156 + <> 157 + <span className={className}>{finalParts}</span> 158 + <ExternalLinkModal 159 + isOpen={showExternalLinkModal} 160 + onClose={() => setShowExternalLinkModal(false)} 161 + url={externalLinkUrl} 162 + /> 163 + </> 164 + ); 53 165 }
+1 -1
web/src/components/navigation/RightSidebar.tsx
··· 84 84 {tags.map((t) => ( 85 85 <a 86 86 key={t.tag} 87 - href={`/search?q=${t.tag}`} 87 + href={`/home?tag=${encodeURIComponent(t.tag)}`} 88 88 className="px-2 py-2.5 hover:bg-surface-100 dark:hover:bg-surface-800/60 rounded-lg transition-colors group" 89 89 > 90 90 <div className="font-semibold text-sm text-surface-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">
+5
web/src/types.ts
··· 68 68 name: string; 69 69 icon?: string; 70 70 }; 71 + context?: { 72 + uri: string; 73 + name: string; 74 + icon?: string; 75 + }[]; 71 76 addedBy?: UserProfile; 72 77 collectionItemUri?: string; 73 78 reply?: {