import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import ReactMarkdown from 'react-markdown'; import type { User } from '@backspace/shared'; import { Avatar } from '../ui/Avatar'; import { Username } from '../ui/Username'; import { useSpaceStore, getApiForOrigin, resolveUserOrigin } from '../../stores/spaceStore'; import { api } from '../../api/client'; import { useUIStore } from '../../stores/uiStore'; import { getAvatarGradient, adjustColor, mutedGradient } from '../../utils/gradients'; import { parseFederatedUsername } from '../../utils/identity'; import { useCanonicalUserView } from '../../utils/userViewLookup'; import { loadFederatedMutuals } from '../../utils/mutuals'; import { computeFloatingPosition, type AnchorRect, type Placement } from '../../hooks/useFloatingPosition'; /** Gap between the card and the element it was opened from. */ const ANCHOR_OFFSET = 8; interface UserProfilePopoutProps { user: User; onClose: () => void; /** Rect of the element the card was opened from. */ anchor: AnchorRect; placement?: Placement; } export function UserProfilePopout({ user: propUser, onClose, anchor, placement = 'right' }: UserProfilePopoutProps) { const navigate = useNavigate(); const addDmChannel = useSpaceStore((s) => s.addDmChannel); const openModal = useUIStore((s) => s.openModal); // Resolve to the best-known view of this user from the userViews cache. // The prop frequently arrives as a federated stub (when the carrying DM // came from a sibling instance that won the populateFromReady dedup race); // routing through the cache surfaces the home view when one is loaded. // Identity fields (id, homeUserId, homeInstance) are preserved across // canonicalization, so write-payload code paths below remain correct. const user = useCanonicalUserView(propUser); const { baseName, domain } = parseFederatedUsername(user.username); const displayName = user.displayName ?? baseName; const origin = resolveUserOrigin(user); const userApi = getApiForOrigin(origin); const [mutualCounts, setMutualCounts] = useState<{ friends: number; spaces: number } | null>(null); useEffect(() => { loadFederatedMutuals(user.id, user.homeUserId) .then((data) => setMutualCounts({ friends: data.mutualFriends.length, spaces: data.mutualSpaces.length })) .catch(() => {}); }, [user.id, user.homeUserId]); // Placed off the card's *measured* size rather than a guessed height: the card // grows with the bio, the custom status and the mutuals row, so any constant // here would cut tall cards off at the bottom of the viewport. const cardRef = useRef(null); const [placed, setPlaced] = useState<{ top: number; left: number } | null>(null); useLayoutEffect(() => { const card = cardRef.current; if (!card) return; const place = () => { const { width, height } = card.getBoundingClientRect(); // 'start': the card's top edge lines up with the row it came from, the // way it always has — centring a tall card on a 32px avatar would drag it // up over unrelated content. const next = computeFloatingPosition(anchor, width, height, placement, ANCHOR_OFFSET, 'start'); setPlaced((prev) => prev && prev.top === next.top && prev.left === next.left ? prev : { top: next.top, left: next.left }, ); }; place(); const observer = new ResizeObserver(place); observer.observe(card); window.addEventListener('resize', place); return () => { observer.disconnect(); window.removeEventListener('resize', place); }; }, [anchor, placement]); const handleSendMessage = async () => { try { const existing = useSpaceStore.getState().findExistingDmForUser(user); if (existing) { useUIStore.getState().setShowDms(true); onClose(); navigate(`/channels/@me/${existing.dm.id}`); return; } const channel = await api.dm.create({ userId: user.homeInstance ? undefined : user.id, homeUserId: user.homeUserId ?? undefined, homeInstance: user.homeInstance ?? undefined, }); addDmChannel(channel); useUIStore.getState().setShowDms(true); onClose(); navigate(`/channels/@me/${channel.id}`); } catch (err) { console.error('Failed to create DM channel:', err); } }; const handleViewFullProfile = () => { onClose(); openModal('userProfile', { userId: user.id, user, origin }); }; const handleAvatarClick = (event: React.MouseEvent) => { event.stopPropagation(); handleViewFullProfile(); }; // Banner display const bannerSrc = user.banner ? (user.banner.startsWith('http') || user.banner.startsWith('/') ? user.banner : userApi.uploads.url(user.banner)) : null; const bannerFallback = user.accentColor ? mutedGradient(user.accentColor, adjustColor(user.accentColor, -40)) : (() => { const g = getAvatarGradient(user.homeUserId ?? user.id, displayName, user.avatarColor); return mutedGradient(g.from, g.to); })(); // Parked off-screen for the one layout pass before the card knows how tall it // is; `useLayoutEffect` places it before the browser paints, so it never // renders visibly in the wrong spot. const cardStyle = placed ?? { top: -9999, left: -9999 }; return (
{/* Banner */}
{/* Body */}
{/* Avatar */} {/* The picture escalates to the full profile — the card is a preview, and clicking the face is the obvious way to ask for the whole thing. It deliberately does NOT reopen the card (see issue #37). */} {/* Name & info */}
{user.customStatus && (
{user.customStatus}
)}
{/* Bio */} {user.bio && ( <>
About Me
( {children} ), }} > {user.bio}
)}
{/* Member since + Mutuals */}
Member Since {new Date(user.createdAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
{mutualCounts && (mutualCounts.friends > 0 || mutualCounts.spaces > 0) && (
{mutualCounts.friends > 0 && ( {mutualCounts.friends} mutual friend{mutualCounts.friends !== 1 ? 's' : ''} )} {mutualCounts.friends > 0 && mutualCounts.spaces > 0 && ( · )} {mutualCounts.spaces > 0 && ( {mutualCounts.spaces} mutual space{mutualCounts.spaces !== 1 ? 's' : ''} )}
)}
{/* Actions */}
); }