import React, { useEffect, 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'; interface UserProfilePopoutProps { user: User; onClose: () => void; position?: { top: number; left: number }; } export function UserProfilePopout({ user: propUser, onClose, position }: 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]); const top = position ? Math.min(Math.max(8, position.top), window.innerHeight - 460) : undefined; const left = position ? Math.min(Math.max(8, position.left), window.innerWidth - 356) : undefined; 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 }); }; // 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); })(); return (