(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import React, { useState, useEffect, useRef } from "react";
2import Avatar from "../ui/Avatar";
3import RichText from "./RichText";
4import { getProfile } from "../../api/client";
5import type { UserProfile } from "../../types";
6import { Loader2 } from "lucide-react";
7import { useTranslation } from "react-i18next";
8
9interface ProfileHoverCardProps {
10 did?: string;
11 handle?: string;
12 children: React.ReactNode;
13 className?: string;
14}
15
16export default function ProfileHoverCard({
17 did,
18 handle,
19 children,
20 className,
21}: ProfileHoverCardProps) {
22 const { t } = useTranslation();
23 const [isOpen, setIsOpen] = useState(false);
24 const [profile, setProfile] = useState<UserProfile | null>(null);
25 const [loading, setLoading] = useState(false);
26 const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
27 const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
28 const cardRef = useRef<HTMLDivElement>(null);
29
30 const handleMouseEnter = () => {
31 timeoutRef.current = setTimeout(async () => {
32 setIsOpen(true);
33 if (!profile && (did || handle)) {
34 setLoading(true);
35 try {
36 const identifier = did || handle || "";
37
38 const [marginData, bskyData] = await Promise.all([
39 getProfile(identifier).catch(() => null),
40 fetch(
41 `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(identifier)}`,
42 )
43 .then((res) => (res.ok ? res.json() : null))
44 .catch(() => null),
45 ]);
46
47 const merged: UserProfile = {
48 did: marginData?.did || bskyData?.did || identifier,
49 handle: marginData?.handle || bskyData?.handle || "",
50 displayName: marginData?.displayName || bskyData?.displayName,
51 avatar: marginData?.avatar || bskyData?.avatar,
52 description: marginData?.description || bskyData?.description,
53 };
54
55 setProfile(merged);
56 } catch (e) {
57 console.error("Failed to load profile", e);
58 } finally {
59 setLoading(false);
60 }
61 }
62 }, 400);
63 };
64
65 const handleMouseLeave = () => {
66 if (timeoutRef.current) {
67 clearTimeout(timeoutRef.current);
68 timeoutRef.current = null;
69 }
70 closeTimeoutRef.current = setTimeout(() => {
71 setIsOpen(false);
72 }, 300);
73 };
74
75 const handleCardMouseEnter = () => {
76 if (closeTimeoutRef.current) {
77 clearTimeout(closeTimeoutRef.current);
78 closeTimeoutRef.current = null;
79 }
80 };
81
82 const handleCardMouseLeave = () => {
83 setIsOpen(false);
84 };
85
86 useEffect(() => {
87 return () => {
88 if (timeoutRef.current) {
89 clearTimeout(timeoutRef.current);
90 }
91 if (closeTimeoutRef.current) {
92 clearTimeout(closeTimeoutRef.current);
93 }
94 };
95 }, []);
96
97 return (
98 <div
99 className={`relative inline-block ${className || ""}`}
100 onMouseEnter={handleMouseEnter}
101 onMouseLeave={handleMouseLeave}
102 ref={cardRef}
103 >
104 {children}
105
106 {isOpen && (
107 <div
108 className="absolute z-50 left-0 top-full mt-2 w-72 bg-white dark:bg-surface-800 rounded-xl shadow-xl border border-surface-200 dark:border-surface-700 p-4 animate-in fade-in slide-in-from-top-1 duration-150"
109 onMouseEnter={handleCardMouseEnter}
110 onMouseLeave={handleCardMouseLeave}
111 >
112 {loading ? (
113 <div className="flex items-center justify-center py-4">
114 <Loader2 size={20} className="animate-spin text-primary-600" />
115 </div>
116 ) : profile ? (
117 <div className="space-y-3">
118 <a
119 href={`/profile/${profile.did}`}
120 className="flex items-start gap-3 group"
121 >
122 <Avatar
123 did={profile.did}
124 avatar={profile.avatar}
125 size="lg"
126 className="shrink-0"
127 />
128 <div className="flex-1 min-w-0">
129 <p className="font-semibold text-surface-900 dark:text-white truncate group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">
130 {profile.displayName || profile.handle}
131 </p>
132 <p className="text-sm text-surface-500 dark:text-surface-400 truncate">
133 @{profile.handle}
134 </p>
135 </div>
136 </a>
137
138 {profile.description && (
139 <p className="text-sm text-surface-600 dark:text-surface-300 whitespace-pre-line line-clamp-3">
140 <RichText text={profile.description} />
141 </p>
142 )}
143
144 <a
145 href={`/profile/${profile.did}`}
146 className="block w-full text-center py-2 bg-primary-600 hover:bg-primary-700 text-white text-sm font-medium rounded-lg transition-colors"
147 >
148 {t("profileHoverCard.viewProfile")}
149 </a>
150 </div>
151 ) : (
152 <p className="text-sm text-surface-500 text-center py-2">
153 {t("profileHoverCard.notFound")}
154 </p>
155 )}
156 </div>
157 )}
158 </div>
159 );
160}