Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
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}