Write on the margins of 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 { 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}