Fixes a render bug where a federated user (e.g. axel@nova) appeared with the federation globe icon and a broken avatar when viewed on his own home instance. Root cause: `populateFromReady` is first-wins by federatedId and discards the entire skipped DM payload — including its `members` array — so when a sibling instance's ready arrived first, the home instance's view of every shared user was dropped on the floor. Adds a render-only `userViews` cache that mirrors the `dmAlternatives` philosophy: information from skipped ready payloads is preserved for rendering. Every wire surface that delivers a User upserts into the cache regardless of dedup outcome; render sites read through a Zustand selector hook to surface the home view when one is loaded. The DM channel ingestion race is left untouched — the existing no-flapping invariant on origin reconnect is intentional and load-bearing for failover. Layered changes: - `identity.ts`: `normalizeOriginToHost`, `canonicalUserKey`, `isDeliveryFromHome`, `isFederationGlobeApplicable` — single helpers for origin/host normalization and the home/stub tier decision. - `spaceStore.ts`: `userViews` Map, `UserViewEntry` type, `upsertUserView` action with the home-wins preference rule, prune by `deliveredBy` in `removeInstanceSpaces` (mirrors `dmAlternatives` cleanup), `reset` clears. - `userViewLookup.ts`: `useCanonicalUserView` (Zustand selector hook for React) + `getCanonicalUserView` (sync getter for non-React paths). Render reactivity is structural via the selector, not coincidence on legacy update paths. - `populateFromReady` upsert pass runs BEFORE the federatedId dedup so members of skipped DMs still reach the cache. - WS handlers (dm_message_*, message_*, user_updated, member_joined, friend_request_*, dm_channel_created, dm_member_added) and REST hydrators (socialStore, discoverStore, mutuals) feed the cache with their delivering origin. - Render-site routing through `useCanonicalUserView` at every audited user-rendering site (sidebar, header, search, message bubble, reply chips, profile popout/modal, group settings, voice tiles, mention chips, member lists, friends, invites). Self-rendering sites compose alongside via existing `isSelf`/`resolveDisplayIdentity`. - Globe predicate hoisted to `isFederationGlobeApplicable` and applied at three sites, gating on `domain !== window.location.host` so we never show the globe for users whose home IS our own. Tests: 31 new unit tests across `identity`, `userViews` store, and `userViewLookup`. Full suite 276/276. Docs: `client-federation.md` §3 gains a "User View Cache" section parallel to "DM Origin Failover"; `dm-system.md` notes the new store action and WS handler upserts. Bug 3 (federation profile-sync gap — orbit's stale profile data on nova-Axel after a clear/color-change on nova never propagated) remains open. The user-view cache routes around it for the common case (home instance is connected), but the underlying S2S relay gap is its own diagnosis and follows in a separate branch.
207 lines
7.9 KiB
TypeScript
207 lines
7.9 KiB
TypeScript
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 (
|
|
<div
|
|
className="fixed z-[200] w-[340px] rounded-[12px] overflow-hidden animate-fade-in select-none glass-modal"
|
|
style={position
|
|
? { top, left }
|
|
: { top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }}
|
|
>
|
|
{/* Banner */}
|
|
<div
|
|
className="h-[80px] rounded-t-[12px]"
|
|
style={bannerSrc
|
|
? { backgroundImage: `url(${bannerSrc})`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
|
: { background: bannerFallback }
|
|
}
|
|
/>
|
|
|
|
{/* Body */}
|
|
<div className="px-4 pb-4 relative">
|
|
{/* Avatar */}
|
|
<Avatar
|
|
src={user.avatar}
|
|
name={displayName}
|
|
size={80}
|
|
status={user.status as 'online' | 'idle' | 'dnd' | 'offline' | null}
|
|
userId={user.homeUserId ?? user.id}
|
|
user={user}
|
|
ring={{ width: 4, color: 'rgba(20,20,26,0.85)' }}
|
|
className="mt-[-44px] mb-3"
|
|
/>
|
|
|
|
{/* Name & info */}
|
|
<div>
|
|
<Username
|
|
username={user.displayName ?? baseName}
|
|
className="text-[16px] font-semibold leading-tight"
|
|
/>
|
|
<div className="text-[13px] text-txt-tertiary">
|
|
<Username username={user.username} showAt className="text-[13px] text-txt-tertiary" />
|
|
</div>
|
|
{user.customStatus && (
|
|
<div className="text-[13px] text-txt-secondary italic mt-1">
|
|
{user.customStatus}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Bio */}
|
|
{user.bio && (
|
|
<>
|
|
<div className="border-t border-white/[0.06] my-3" />
|
|
<div>
|
|
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
|
|
About Me
|
|
</span>
|
|
<div className="text-[13px] text-txt-secondary mt-1 whitespace-pre-wrap break-words leading-relaxed [&_strong]:font-semibold [&_strong]:text-txt-primary [&_em]:italic [&_a]:text-accent-primary [&_a]:underline">
|
|
<ReactMarkdown
|
|
allowedElements={['p', 'strong', 'em', 'a', 'br']}
|
|
unwrapDisallowed
|
|
components={{
|
|
a: ({ href, children }) => (
|
|
<a href={href} target="_blank" rel="noopener noreferrer">{children}</a>
|
|
),
|
|
}}
|
|
>
|
|
{user.bio}
|
|
</ReactMarkdown>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<div className="border-t border-white/[0.06] my-3" />
|
|
|
|
{/* Member since + Mutuals */}
|
|
<div className="space-y-1.5">
|
|
<div>
|
|
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
|
|
Member Since
|
|
</span>
|
|
<span className="text-[12px] text-txt-secondary ml-2">
|
|
{new Date(user.createdAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
|
</span>
|
|
</div>
|
|
{mutualCounts && (mutualCounts.friends > 0 || mutualCounts.spaces > 0) && (
|
|
<div className="text-[12px] text-txt-tertiary">
|
|
{mutualCounts.friends > 0 && (
|
|
<span>{mutualCounts.friends} mutual friend{mutualCounts.friends !== 1 ? 's' : ''}</span>
|
|
)}
|
|
{mutualCounts.friends > 0 && mutualCounts.spaces > 0 && (
|
|
<span className="mx-1">·</span>
|
|
)}
|
|
{mutualCounts.spaces > 0 && (
|
|
<span>{mutualCounts.spaces} mutual space{mutualCounts.spaces !== 1 ? 's' : ''}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<button
|
|
onClick={handleSendMessage}
|
|
className="w-full mt-3 py-2 rounded-lg text-[13px] font-medium text-txt-primary bg-white/[0.06] hover:bg-white/[0.10] border border-white/[0.08] transition-colors"
|
|
>
|
|
Send Message
|
|
</button>
|
|
<button
|
|
onClick={handleViewFullProfile}
|
|
className="w-full mt-1.5 py-2 rounded-lg text-[13px] font-medium text-txt-tertiary hover:text-txt-secondary bg-transparent hover:bg-white/[0.04] transition-colors"
|
|
>
|
|
View Full Profile
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|