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

various better additions

scanash00 8e648f47 a226bc2e

+78 -7
+10 -3
web/src/components/common/Card.tsx
··· 1 1 import React, { useState } from "react"; 2 2 import { formatDistanceToNow } from "date-fns"; 3 + import RichText from "./RichText"; 3 4 import { 4 5 MessageSquare, 5 6 Heart, ··· 138 139 (pageUrl ? safeUrlHostname(pageUrl) : null); 139 140 const pageHostname = pageUrl 140 141 ? safeUrlHostname(pageUrl)?.replace("www.", "") 142 + : null; 143 + const displayUrl = pageUrl 144 + ? pageUrl 145 + .replace(/^https?:\/\//, "") 146 + .replace(/^www\./, "") 147 + .replace(/\/$/, "") 141 148 : null; 142 149 const isBookmark = type === "bookmark"; 143 150 ··· 284 291 className="inline-flex items-center gap-1 text-xs text-primary-600 dark:text-primary-400 hover:underline mt-0.5" 285 292 > 286 293 <ExternalLink size={10} /> 287 - {pageHostname} 294 + {displayUrl} 288 295 </a> 289 296 )} 290 297 </div> ··· 334 341 )} 335 342 </div> 336 343 <span className="truncate max-w-[200px]"> 337 - {pageHostname || pageUrl} 344 + {displayUrl || pageUrl} 338 345 </span> 339 346 </div> 340 347 </div> ··· 388 395 389 396 {item.body?.value && ( 390 397 <p className="text-surface-900 dark:text-surface-100 whitespace-pre-wrap leading-relaxed text-[15px]"> 391 - {item.body.value} 398 + <RichText text={item.body.value} /> 392 399 </p> 393 400 )} 394 401 </div>
+3 -2
web/src/components/common/ProfileHoverCard.tsx
··· 1 1 import React, { useState, useEffect, useRef } from "react"; 2 2 import { Link } from "react-router-dom"; 3 3 import Avatar from "../ui/Avatar"; 4 + import RichText from "./RichText"; 4 5 import { getProfile } from "../../api/client"; 5 6 import type { UserProfile } from "../../types"; 6 7 import { Loader2 } from "lucide-react"; ··· 134 135 </Link> 135 136 136 137 {profile.description && ( 137 - <p className="text-sm text-surface-600 dark:text-surface-300 line-clamp-3"> 138 - {profile.description} 138 + <p className="text-sm text-surface-600 dark:text-surface-300 whitespace-pre-line line-clamp-3"> 139 + <RichText text={profile.description} /> 139 140 </p> 140 141 )} 141 142
+53
web/src/components/common/RichText.tsx
··· 1 + import React from "react"; 2 + import { Link } from "react-router-dom"; 3 + 4 + interface RichTextProps { 5 + text: string; 6 + className?: string; 7 + } 8 + 9 + const MENTION_REGEX = 10 + /(^|[\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 + 12 + export default function RichText({ text, className }: RichTextProps) { 13 + const parts: React.ReactNode[] = []; 14 + let lastIndex = 0; 15 + 16 + for (const match of text.matchAll(MENTION_REGEX)) { 17 + const fullMatch = match[0]; 18 + const prefix = match[1]; 19 + const handle = match[2]; 20 + const startIndex = match.index!; 21 + 22 + if (startIndex > lastIndex) { 23 + parts.push(text.slice(lastIndex, startIndex)); 24 + } 25 + 26 + if (prefix) { 27 + parts.push(prefix); 28 + } 29 + 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 + ); 40 + 41 + lastIndex = startIndex + fullMatch.length; 42 + } 43 + 44 + if (lastIndex < text.length) { 45 + parts.push(text.slice(lastIndex)); 46 + } 47 + 48 + if (parts.length === 0) { 49 + return <span className={className}>{text}</span>; 50 + } 51 + 52 + return <span className={className}>{parts}</span>; 53 + }
+12 -2
web/src/views/profile/Profile.tsx
··· 1 1 import React, { useEffect, useState } from "react"; 2 2 import { getProfile, getFeed, getCollections } from "../../api/client"; 3 3 import Card from "../../components/common/Card"; 4 + import RichText from "../../components/common/RichText"; 4 5 import { 5 6 Edit2, 6 7 Github, ··· 123 124 }, []); 124 125 125 126 useEffect(() => { 127 + setProfile(null); 128 + setAnnotations([]); 129 + setHighlights([]); 130 + setBookmarks([]); 131 + setCollections([]); 132 + setActiveTab("annotations"); 133 + }, [did]); 134 + 135 + useEffect(() => { 126 136 const loadTabContent = async () => { 127 137 const isHandle = !did.startsWith("did:"); 128 138 const resolvedDid = isHandle ? profile?.did : did; ··· 244 254 </div> 245 255 246 256 {profile.description && ( 247 - <p className="text-surface-600 dark:text-surface-300 text-sm mt-3 line-clamp-2"> 248 - {profile.description} 257 + <p className="text-surface-600 dark:text-surface-300 text-sm mt-3 whitespace-pre-line"> 258 + <RichText text={profile.description} /> 249 259 </p> 250 260 )} 251 261