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