Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments

various better additions

+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