Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 669 lines 24 kB view raw
1import { useStore } from "@nanostores/react"; 2import { clsx } from "clsx"; 3import { 4 Edit2, 5 Eye, 6 EyeOff, 7 Flag, 8 Folder, 9 Github, 10 Link2, 11 Linkedin, 12 Loader2, 13 ShieldBan, 14 ShieldOff, 15 Volume2, 16 VolumeX, 17} from "lucide-react"; 18import { useEffect, useRef, useState } from "react"; 19import { Link } from "react-router-dom"; 20import { 21 blockUser, 22 getCollections, 23 getModerationRelationship, 24 getProfile, 25 muteUser, 26 unblockUser, 27 unmuteUser, 28} from "../../api/client"; 29import CollectionIcon from "../../components/common/CollectionIcon"; 30import { BlueskyIcon, TangledIcon } from "../../components/common/Icons"; 31import type { MoreMenuItem } from "../../components/common/MoreMenu"; 32import MoreMenu from "../../components/common/MoreMenu"; 33import RichText from "../../components/common/RichText"; 34import FeedItems from "../../components/feed/FeedItems"; 35import EditProfileModal from "../../components/modals/EditProfileModal"; 36import ExternalLinkModal from "../../components/modals/ExternalLinkModal"; 37import ReportModal from "../../components/modals/ReportModal"; 38import { 39 Avatar, 40 Button, 41 EmptyState, 42 Skeleton, 43 Tabs, 44} from "../../components/ui"; 45import { $user } from "../../store/auth"; 46import { $preferences, loadPreferences } from "../../store/preferences"; 47import type { 48 Collection, 49 ContentLabel, 50 ModerationRelationship, 51 UserProfile, 52} from "../../types"; 53 54interface ProfileProps { 55 did: string; 56} 57 58type Tab = "all" | "annotations" | "highlights" | "bookmarks" | "collections"; 59 60const motivationMap: Record<Tab, string | undefined> = { 61 all: undefined, 62 annotations: "commenting", 63 highlights: "highlighting", 64 bookmarks: "bookmarking", 65 collections: undefined, 66}; 67 68export default function Profile({ did }: ProfileProps) { 69 const [profile, setProfile] = useState<UserProfile | null>(null); 70 const [loading, setLoading] = useState(true); 71 const [activeTab, setActiveTab] = useState<Tab>("all"); 72 73 const [collections, setCollections] = useState<Collection[]>([]); 74 const [dataLoading, setDataLoading] = useState(false); 75 76 const user = useStore($user); 77 const isOwner = user?.did === did; 78 const [showEdit, setShowEdit] = useState(false); 79 const [externalLink, setExternalLink] = useState<string | null>(null); 80 const [showReportModal, setShowReportModal] = useState(false); 81 const loadMoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); 82 const [modRelation, setModRelation] = useState<ModerationRelationship>({ 83 blocking: false, 84 muting: false, 85 blockedBy: false, 86 }); 87 const [accountLabels, setAccountLabels] = useState<ContentLabel[]>([]); 88 const [profileRevealed, setProfileRevealed] = useState(false); 89 const preferences = useStore($preferences); 90 91 const formatLinkText = (url: string) => { 92 try { 93 const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`); 94 const domain = urlObj.hostname.replace(/^www\./, ""); 95 const path = urlObj.pathname.replace(/^\/|\/$/g, ""); 96 97 if ( 98 domain.includes("github.com") || 99 domain.includes("twitter.com") || 100 domain.includes("x.com") 101 ) { 102 return path ? `${domain}/${path}` : domain; 103 } 104 if (domain.includes("linkedin.com") && path.includes("in/")) { 105 return `linkedin.com/${path.split("in/")[1]}`; 106 } 107 if (domain.includes("tangled")) { 108 return path ? `${domain}/${path}` : domain; 109 } 110 111 return domain + (path && path.length < 20 ? `/${path}` : ""); 112 } catch { 113 return url; 114 } 115 }; 116 117 useEffect(() => { 118 setProfile(null); 119 setCollections([]); 120 setActiveTab("all"); 121 setLoading(true); 122 123 const loadProfile = async () => { 124 try { 125 const marginPromise = getProfile(did); 126 const bskyPromise = fetch( 127 `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`, 128 ) 129 .then((res) => (res.ok ? res.json() : null)) 130 .catch(() => null); 131 132 const [marginData, bskyData] = await Promise.all([ 133 marginPromise, 134 bskyPromise, 135 ]); 136 137 const merged: UserProfile = { 138 did: marginData?.did || bskyData?.did || did, 139 handle: marginData?.handle || bskyData?.handle || "", 140 displayName: marginData?.displayName || bskyData?.displayName, 141 avatar: marginData?.avatar || bskyData?.avatar, 142 description: marginData?.description || bskyData?.description, 143 banner: marginData?.banner || bskyData?.banner, 144 website: marginData?.website, 145 links: marginData?.links || [], 146 followersCount: 147 bskyData?.followersCount || marginData?.followersCount, 148 followsCount: bskyData?.followsCount || marginData?.followsCount, 149 postsCount: bskyData?.postsCount || marginData?.postsCount, 150 }; 151 152 if (marginData?.labels && Array.isArray(marginData.labels)) { 153 setAccountLabels(marginData.labels); 154 } 155 156 setProfile(merged); 157 158 if (user && user.did !== did) { 159 try { 160 const rel = await getModerationRelationship(did); 161 setModRelation(rel); 162 } catch { 163 // ignore 164 } 165 } 166 } catch (e) { 167 console.error("Profile load failed", e); 168 } finally { 169 setLoading(false); 170 } 171 }; 172 if (did) loadProfile(); 173 }, [did, user]); 174 175 useEffect(() => { 176 loadPreferences(); 177 }, []); 178 179 useEffect(() => { 180 const timer = loadMoreTimerRef.current; 181 return () => { 182 if (timer) clearTimeout(timer); 183 }; 184 }, []); 185 186 const isHandle = !did.startsWith("did:"); 187 const resolvedDid = isHandle ? profile?.did : did; 188 189 useEffect(() => { 190 const loadTabContent = async () => { 191 const isHandle = !did.startsWith("did:"); 192 const resolvedDid = isHandle ? profile?.did : did; 193 194 if (!resolvedDid) return; 195 196 setDataLoading(true); 197 try { 198 if (activeTab === "collections") { 199 const res = await getCollections(resolvedDid); 200 setCollections(res); 201 } 202 } catch (e) { 203 console.error(e); 204 } finally { 205 setDataLoading(false); 206 } 207 }; 208 loadTabContent(); 209 }, [profile?.did, did, activeTab]); 210 211 if (loading) { 212 return ( 213 <div className="max-w-2xl mx-auto animate-fade-in"> 214 <div className="card p-5 mb-4"> 215 <div className="flex items-start gap-4"> 216 <Skeleton variant="circular" className="w-16 h-16" /> 217 <div className="flex-1 space-y-2"> 218 <Skeleton width="40%" className="h-6" /> 219 <Skeleton width="25%" className="h-4" /> 220 <Skeleton width="60%" className="h-4" /> 221 </div> 222 </div> 223 </div> 224 <Skeleton className="h-10 mb-4" /> 225 <div className="space-y-3"> 226 <Skeleton className="h-32 rounded-lg" /> 227 <Skeleton className="h-32 rounded-lg" /> 228 </div> 229 </div> 230 ); 231 } 232 233 if (!profile) { 234 return ( 235 <EmptyState 236 title="User not found" 237 message="This profile doesn't exist or couldn't be loaded." 238 /> 239 ); 240 } 241 242 const tabs = [ 243 { id: "all", label: "All" }, 244 { id: "annotations", label: "Annotations" }, 245 { id: "highlights", label: "Highlights" }, 246 { id: "bookmarks", label: "Bookmarks" }, 247 { id: "collections", label: "Collections" }, 248 ]; 249 250 const LABEL_DESCRIPTIONS: Record<string, string> = { 251 sexual: "Sexual Content", 252 nudity: "Nudity", 253 violence: "Violence", 254 gore: "Graphic Content", 255 spam: "Spam", 256 misleading: "Misleading", 257 }; 258 259 const accountWarning = (() => { 260 if (!accountLabels.length) return null; 261 const priority = [ 262 "gore", 263 "violence", 264 "nudity", 265 "sexual", 266 "misleading", 267 "spam", 268 ]; 269 for (const p of priority) { 270 const match = accountLabels.find((l) => l.val === p); 271 if (match) { 272 const pref = preferences.labelPreferences.find( 273 (lp) => lp.label === p && lp.labelerDid === match.src, 274 ); 275 const visibility = pref?.visibility || "warn"; 276 if (visibility === "ignore") continue; 277 return { 278 label: p, 279 description: LABEL_DESCRIPTIONS[p] || p, 280 visibility, 281 }; 282 } 283 } 284 return null; 285 })(); 286 287 const shouldBlurAvatar = accountWarning && !profileRevealed; 288 289 return ( 290 <div className="max-w-2xl mx-auto animate-slide-up"> 291 <div className="card p-5 mb-4"> 292 <div className="flex items-start gap-4"> 293 <div className="relative"> 294 <div className="rounded-full overflow-hidden"> 295 <div 296 className={clsx( 297 "transition-all", 298 shouldBlurAvatar && "blur-lg", 299 )} 300 > 301 <Avatar 302 did={profile.did} 303 avatar={profile.avatar} 304 size="xl" 305 className="ring-4 ring-surface-100 dark:ring-surface-800" 306 /> 307 </div> 308 </div> 309 </div> 310 311 <div className="flex-1 min-w-0"> 312 <div className="flex items-start justify-between gap-3"> 313 <div className="min-w-0"> 314 <h1 className="text-xl font-bold text-surface-900 dark:text-white truncate"> 315 {profile.displayName || profile.handle} 316 </h1> 317 <p className="text-surface-500 dark:text-surface-400"> 318 @{profile.handle} 319 </p> 320 </div> 321 <div className="flex items-center gap-2"> 322 {isOwner && ( 323 <Button 324 variant="secondary" 325 size="sm" 326 onClick={() => setShowEdit(true)} 327 icon={<Edit2 size={14} />} 328 > 329 <span className="hidden sm:inline">Edit</span> 330 </Button> 331 )} 332 {!isOwner && user && ( 333 <MoreMenu 334 items={(() => { 335 const items: MoreMenuItem[] = []; 336 items.push({ 337 label: "View profile in Bluesky", 338 icon: <BlueskyIcon size={16} />, 339 onClick: () => { 340 const handle = profile.handle || did; 341 window.open( 342 `https://bsky.app/profile/${encodeURIComponent(handle)}`, 343 "_blank", 344 ); 345 }, 346 }); 347 if (modRelation.blocking) { 348 items.push({ 349 label: `Unblock @${profile.handle || "user"}`, 350 icon: <ShieldOff size={14} />, 351 onClick: async () => { 352 await unblockUser(did); 353 setModRelation((prev) => ({ 354 ...prev, 355 blocking: false, 356 })); 357 }, 358 }); 359 } else { 360 items.push({ 361 label: `Block @${profile.handle || "user"}`, 362 icon: <ShieldBan size={14} />, 363 onClick: async () => { 364 await blockUser(did); 365 setModRelation((prev) => ({ 366 ...prev, 367 blocking: true, 368 })); 369 }, 370 variant: "danger", 371 }); 372 } 373 if (modRelation.muting) { 374 items.push({ 375 label: `Unmute @${profile.handle || "user"}`, 376 icon: <Volume2 size={14} />, 377 onClick: async () => { 378 await unmuteUser(did); 379 setModRelation((prev) => ({ 380 ...prev, 381 muting: false, 382 })); 383 }, 384 }); 385 } else { 386 items.push({ 387 label: `Mute @${profile.handle || "user"}`, 388 icon: <VolumeX size={14} />, 389 onClick: async () => { 390 await muteUser(did); 391 setModRelation((prev) => ({ 392 ...prev, 393 muting: true, 394 })); 395 }, 396 }); 397 } 398 items.push({ 399 label: "Report", 400 icon: <Flag size={14} />, 401 onClick: () => setShowReportModal(true), 402 variant: "danger", 403 }); 404 return items; 405 })()} 406 /> 407 )} 408 </div> 409 </div> 410 411 {profile.description && ( 412 <p className="text-surface-600 dark:text-surface-300 text-sm mt-3 whitespace-pre-line break-words"> 413 <RichText text={profile.description} /> 414 </p> 415 )} 416 417 <div className="flex flex-wrap gap-3 mt-3"> 418 {[ 419 ...(profile.website ? [profile.website] : []), 420 ...(profile.links || []), 421 ] 422 .filter((link, index, self) => self.indexOf(link) === index) 423 .map((link) => { 424 let icon; 425 if (link.includes("github.com")) { 426 icon = <Github size={16} />; 427 } else if (link.includes("linkedin.com")) { 428 icon = <Linkedin size={16} />; 429 } else if ( 430 link.includes("tangled.sh") || 431 link.includes("tangled.org") 432 ) { 433 icon = <TangledIcon size={16} />; 434 } else { 435 icon = <Link2 size={16} />; 436 } 437 438 return ( 439 <button 440 key={link} 441 onClick={() => { 442 const fullUrl = link.startsWith("http") 443 ? link 444 : `https://${link}`; 445 try { 446 const prefs = $preferences.get(); 447 if (prefs.disableExternalLinkWarning) { 448 window.open( 449 fullUrl, 450 "_blank", 451 "noopener,noreferrer", 452 ); 453 return; 454 } 455 const hostname = new URL(fullUrl).hostname; 456 const skipped = prefs.externalLinkSkippedHostnames; 457 if (skipped.includes(hostname)) { 458 window.open( 459 fullUrl, 460 "_blank", 461 "noopener,noreferrer", 462 ); 463 } else { 464 setExternalLink(fullUrl); 465 } 466 } catch { 467 setExternalLink(fullUrl); 468 } 469 }} 470 className="flex items-center gap-1.5 text-sm text-surface-500 dark:text-surface-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors" 471 > 472 {icon} 473 <span className="truncate max-w-[200px]"> 474 {formatLinkText(link)} 475 </span> 476 </button> 477 ); 478 })} 479 </div> 480 </div> 481 </div> 482 </div> 483 484 {accountWarning && ( 485 <div className="card p-4 mb-4 border-amber-200 dark:border-amber-800/50 bg-amber-50/50 dark:bg-amber-900/10"> 486 <div className="flex items-center gap-3"> 487 <EyeOff size={18} className="text-amber-500 flex-shrink-0" /> 488 <div className="flex-1"> 489 <p className="text-sm font-medium text-amber-700 dark:text-amber-400"> 490 Account labeled: {accountWarning.description} 491 </p> 492 <p className="text-xs text-amber-600/70 dark:text-amber-400/60 mt-0.5"> 493 This label was applied by a moderation service you subscribe to. 494 </p> 495 </div> 496 {!profileRevealed ? ( 497 <button 498 onClick={() => setProfileRevealed(true)} 499 className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-amber-600 dark:text-amber-400 hover:bg-amber-100 dark:hover:bg-amber-900/30 rounded-lg transition-colors" 500 > 501 <Eye size={12} /> 502 Show 503 </button> 504 ) : ( 505 <button 506 onClick={() => setProfileRevealed(false)} 507 className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-amber-600 dark:text-amber-400 hover:bg-amber-100 dark:hover:bg-amber-900/30 rounded-lg transition-colors" 508 > 509 <EyeOff size={12} /> 510 Hide 511 </button> 512 )} 513 </div> 514 </div> 515 )} 516 517 {modRelation.blocking && ( 518 <div className="card p-4 mb-4 border-red-200 dark:border-red-800/50 bg-red-50/50 dark:bg-red-900/10"> 519 <div className="flex items-center gap-3"> 520 <ShieldBan size={18} className="text-red-500 flex-shrink-0" /> 521 <div className="flex-1"> 522 <p className="text-sm font-medium text-red-700 dark:text-red-400"> 523 You have blocked @{profile.handle} 524 </p> 525 <p className="text-xs text-red-600/70 dark:text-red-400/60 mt-0.5"> 526 Their content is hidden from your feeds. 527 </p> 528 </div> 529 <button 530 onClick={async () => { 531 await unblockUser(did); 532 setModRelation((prev) => ({ ...prev, blocking: false })); 533 }} 534 className="px-3 py-1.5 text-xs font-medium text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 rounded-lg transition-colors" 535 > 536 Unblock 537 </button> 538 </div> 539 </div> 540 )} 541 542 {modRelation.muting && !modRelation.blocking && ( 543 <div className="card p-4 mb-4 border-amber-200 dark:border-amber-800/50 bg-amber-50/50 dark:bg-amber-900/10"> 544 <div className="flex items-center gap-3"> 545 <VolumeX size={18} className="text-amber-500 flex-shrink-0" /> 546 <div className="flex-1"> 547 <p className="text-sm font-medium text-amber-700 dark:text-amber-400"> 548 You have muted @{profile.handle} 549 </p> 550 <p className="text-xs text-amber-600/70 dark:text-amber-400/60 mt-0.5"> 551 Their content is hidden from your feeds. 552 </p> 553 </div> 554 <button 555 onClick={async () => { 556 await unmuteUser(did); 557 setModRelation((prev) => ({ ...prev, muting: false })); 558 }} 559 className="px-3 py-1.5 text-xs font-medium text-amber-600 dark:text-amber-400 hover:bg-amber-100 dark:hover:bg-amber-900/30 rounded-lg transition-colors" 560 > 561 Unmute 562 </button> 563 </div> 564 </div> 565 )} 566 567 {modRelation.blockedBy && !modRelation.blocking && ( 568 <div className="card p-4 mb-4 border-surface-200 dark:border-surface-700"> 569 <div className="flex items-center gap-3"> 570 <ShieldBan size={18} className="text-surface-400 flex-shrink-0" /> 571 <p className="text-sm text-surface-500 dark:text-surface-400"> 572 @{profile.handle} has blocked you. You cannot interact with their 573 content. 574 </p> 575 </div> 576 </div> 577 )} 578 579 <Tabs 580 tabs={tabs} 581 activeTab={activeTab} 582 onChange={(id) => setActiveTab(id as Tab)} 583 className="mb-4" 584 /> 585 586 <div className="min-h-[200px]"> 587 {dataLoading ? ( 588 <div className="flex flex-col items-center justify-center py-12 gap-3"> 589 <Loader2 590 className="animate-spin text-primary-600 dark:text-primary-400" 591 size={24} 592 /> 593 <p className="text-sm text-surface-400 dark:text-surface-500"> 594 Loading... 595 </p> 596 </div> 597 ) : activeTab === "collections" ? ( 598 collections.length === 0 ? ( 599 <EmptyState 600 icon={<Folder size={40} />} 601 message={ 602 isOwner 603 ? "You haven't created any collections yet." 604 : "No collections" 605 } 606 /> 607 ) : ( 608 <div className="grid grid-cols-1 gap-2"> 609 {collections.map((collection) => ( 610 <Link 611 key={collection.id} 612 to={`/${collection.creator?.handle || profile.handle}/collection/${(collection.uri || "").split("/").pop()}`} 613 className="group card p-4 hover:ring-primary-300 dark:hover:ring-primary-600 transition-all flex items-center gap-4" 614 > 615 <div className="p-2.5 bg-primary-50 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 rounded-xl"> 616 <CollectionIcon icon={collection.icon} size={20} /> 617 </div> 618 <div className="flex-1 min-w-0"> 619 <h3 className="font-semibold text-surface-900 dark:text-white truncate group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors"> 620 {collection.name} 621 </h3> 622 <p className="text-sm text-surface-500 dark:text-surface-400"> 623 {collection.itemCount}{" "} 624 {collection.itemCount === 1 ? "item" : "items"} 625 </p> 626 </div> 627 </Link> 628 ))} 629 </div> 630 ) 631 ) : ( 632 <FeedItems 633 key={activeTab} 634 type="all" 635 motivation={motivationMap[activeTab]} 636 creator={resolvedDid} 637 layout="list" 638 emptyMessage={ 639 isOwner 640 ? `You haven't added any ${activeTab} yet.` 641 : `No ${activeTab}` 642 } 643 /> 644 )} 645 </div> 646 647 {showEdit && profile && ( 648 <EditProfileModal 649 profile={profile} 650 onClose={() => setShowEdit(false)} 651 onUpdate={(updated) => setProfile(updated)} 652 /> 653 )} 654 655 <ExternalLinkModal 656 isOpen={!!externalLink} 657 onClose={() => setExternalLink(null)} 658 url={externalLink} 659 /> 660 661 <ReportModal 662 isOpen={showReportModal} 663 onClose={() => setShowReportModal(false)} 664 subjectDid={did} 665 subjectHandle={profile?.handle} 666 /> 667 </div> 668 ); 669}