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

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