(READ ONLY) 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 { 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}