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.
458 lines
16 KiB
TypeScript
458 lines
16 KiB
TypeScript
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import type { User, DmChannel } from '@backspace/shared';
|
|
import { Avatar } from '../ui/Avatar';
|
|
import { useSpaceStore } from '../../stores/spaceStore';
|
|
import { useAuthStore } from '../../stores/authStore';
|
|
import { useUIStore } from '../../stores/uiStore';
|
|
import { api } from '../../api/client';
|
|
import { isSelf, parseFederatedUsername } from '../../utils/identity';
|
|
import { useCanonicalUserView } from '../../utils/userViewLookup';
|
|
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
|
|
|
|
/**
|
|
* Single slot in the group-avatar stack for the DM search bar dropdown.
|
|
* Extracted as a component so useCanonicalUserView is called per-slot (hooks
|
|
* must not be called inside a variable-length .map()).
|
|
*/
|
|
function DmSearchGroupAvatarSlot({ member, index }: { member: User; index: number }) {
|
|
const canonical = useCanonicalUserView(member);
|
|
const displayName = canonical.displayName ?? parseFederatedUsername(canonical.username).baseName;
|
|
return (
|
|
<div
|
|
className="absolute rounded-full overflow-hidden border-[1.5px] border-surface-channel"
|
|
style={{
|
|
width: 18, height: 18,
|
|
left: index * 8,
|
|
top: index * 4,
|
|
zIndex: 2 - index,
|
|
}}
|
|
>
|
|
<Avatar src={canonical.avatar} name={displayName} size={18} userId={canonical.homeUserId ?? canonical.id} user={canonical} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Avatar + display-name row for a user result in the DM search bar.
|
|
* Extracted as a component so useCanonicalUserView can be called at the top
|
|
* of a stable component rather than inside a variable-length .map().
|
|
*/
|
|
function DmSearchUserRow({ user, isSelected, selectedRef, onClick }: {
|
|
user: User;
|
|
isSelected: boolean;
|
|
selectedRef?: React.Ref<HTMLDivElement>;
|
|
onClick: () => void;
|
|
}) {
|
|
const canonical = useCanonicalUserView(user);
|
|
const displayName = canonical.displayName ?? canonical.username;
|
|
return (
|
|
<div
|
|
ref={isSelected ? selectedRef : undefined}
|
|
onClick={onClick}
|
|
className={`flex items-center gap-2.5 px-2 py-1.5 mx-1 rounded cursor-pointer transition-colors ${
|
|
isSelected ? 'bg-interactive-selected' : 'hover:bg-interactive-hover'
|
|
}`}
|
|
>
|
|
<Avatar
|
|
src={canonical.avatar}
|
|
name={displayName}
|
|
size={24}
|
|
status={canonical.status as 'online' | 'idle' | 'dnd' | 'offline' | undefined}
|
|
userId={canonical.homeUserId ?? canonical.id}
|
|
/>
|
|
<div className="flex-1 min-w-0 flex items-center gap-1.5">
|
|
<span className="text-[14px] text-txt-primary truncate">
|
|
{displayName}
|
|
</span>
|
|
{canonical.displayName && (
|
|
<span className="text-[12px] text-txt-tertiary truncate">
|
|
@{canonical.username}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Full row for a DM conversation item in the DM search bar dropdown.
|
|
* For 1-on-1 DMs, routes the partner through useCanonicalUserView.
|
|
* For group DMs, delegates per-slot to DmSearchGroupAvatarSlot.
|
|
*/
|
|
function DmSearchDmRow({ item, isSelected, selectedRef, onClick }: {
|
|
item: DmItem;
|
|
isSelected: boolean;
|
|
selectedRef?: React.Ref<HTMLDivElement>;
|
|
onClick: () => void;
|
|
}) {
|
|
// For 1-on-1 DMs: canonicalize the single partner. For groups: pass through
|
|
// unchanged (DmSearchGroupAvatarSlot handles per-slot canonicalization).
|
|
const rawPartner = !item.isGroup ? (item.otherMembers[0] ?? null) : null;
|
|
// Call the hook unconditionally — pass a stable fallback (empty User shape)
|
|
// for group DMs so hooks are always called the same number of times.
|
|
const FALLBACK_USER = { id: '', username: '', createdAt: 0, isAdmin: false, replicatedInstances: [] } as unknown as User;
|
|
const canonicalPartner = useCanonicalUserView(rawPartner ?? FALLBACK_USER);
|
|
const partner = rawPartner ? canonicalPartner : null;
|
|
|
|
return (
|
|
<div
|
|
ref={isSelected ? selectedRef : undefined}
|
|
onClick={onClick}
|
|
className={`flex items-center gap-2.5 px-2 py-1.5 mx-1 rounded cursor-pointer transition-colors ${
|
|
isSelected ? 'bg-interactive-selected' : 'hover:bg-interactive-hover'
|
|
}`}
|
|
>
|
|
{item.isGroup ? (
|
|
<div className="relative w-6 h-6 flex-shrink-0">
|
|
{item.otherMembers.slice(0, 2).map((m, idx) => (
|
|
<DmSearchGroupAvatarSlot key={m.id} member={m} index={idx} />
|
|
))}
|
|
</div>
|
|
) : (
|
|
<Avatar
|
|
src={partner?.avatar}
|
|
name={partner?.displayName ?? parseFederatedUsername(partner?.username ?? '').baseName}
|
|
size={24}
|
|
status={partner?.status as 'online' | 'idle' | 'dnd' | 'offline' | undefined}
|
|
userId={partner?.homeUserId ?? partner?.id}
|
|
user={partner ?? undefined}
|
|
/>
|
|
)}
|
|
<span className="text-[14px] text-txt-primary truncate">{item.displayName}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const MAX_RECENT = 8;
|
|
const SEARCH_DEBOUNCE = 300;
|
|
|
|
interface DmItem {
|
|
type: 'dm';
|
|
dm: DmChannel;
|
|
displayName: string;
|
|
otherMembers: DmChannel['members'];
|
|
isGroup: boolean;
|
|
}
|
|
|
|
interface UserItem {
|
|
type: 'user';
|
|
user: User;
|
|
}
|
|
|
|
type ResultItem = DmItem | UserItem;
|
|
|
|
export function DmSearchBar() {
|
|
const [active, setActive] = useState(false);
|
|
const [query, setQuery] = useState('');
|
|
const [userResults, setUserResults] = useState<User[]>([]);
|
|
const [isSearching, setIsSearching] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
|
|
const dmChannels = useSpaceStore((s) => s.dmChannels);
|
|
const addDmChannel = useSpaceStore((s) => s.addDmChannel);
|
|
const user = useAuthStore((s) => s.user);
|
|
const navigate = useNavigate();
|
|
|
|
const anchorRef = useRef<HTMLDivElement>(null);
|
|
const floatingRef = useRef<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const selectedRef = useRef<HTMLDivElement>(null);
|
|
const searchTimer = useRef<ReturnType<typeof setTimeout>>();
|
|
|
|
const { style } = useFloatingPosition(anchorRef, floatingRef, {
|
|
placement: 'bottom',
|
|
offset: 4,
|
|
enabled: active,
|
|
});
|
|
|
|
// Build filtered DM items
|
|
const dmItems = useMemo((): DmItem[] => {
|
|
const q = query.toLowerCase().trim();
|
|
return dmChannels
|
|
.map((dm): DmItem | null => {
|
|
const otherMembers = dm.members.filter(m => !isSelf(m, user));
|
|
const isGroup = !!dm.ownerId;
|
|
if (otherMembers.length === 0 && !isGroup) return null;
|
|
const displayName = isGroup
|
|
? (otherMembers.length > 0
|
|
? otherMembers.map(m => m.displayName ?? parseFederatedUsername(m.username).baseName).join(', ')
|
|
: 'Empty Group')
|
|
: otherMembers[0]?.displayName ?? otherMembers[0]?.username ?? '';
|
|
return { type: 'dm', dm, displayName, otherMembers, isGroup };
|
|
})
|
|
.filter((item): item is DmItem => {
|
|
if (!item) return false;
|
|
if (!q) return true;
|
|
// Match against display name or any member username
|
|
if (item.displayName.toLowerCase().includes(q)) return true;
|
|
return item.otherMembers.some(m =>
|
|
m.username.toLowerCase().includes(q) ||
|
|
(m.displayName?.toLowerCase().includes(q) ?? false)
|
|
);
|
|
})
|
|
.slice(0, MAX_RECENT);
|
|
}, [dmChannels, query, user]);
|
|
|
|
// De-duplicate user results against shown 1-on-1 DMs
|
|
const filteredUserResults = useMemo((): UserItem[] => {
|
|
const dmUserIds = new Set<string>();
|
|
for (const item of dmItems) {
|
|
if (!item.isGroup && item.otherMembers.length === 1) {
|
|
const m = item.otherMembers[0]!;
|
|
dmUserIds.add(m.homeUserId ?? m.id);
|
|
}
|
|
}
|
|
return userResults
|
|
.filter(u => {
|
|
if (isSelf(u, user)) return false;
|
|
const homeId = u.homeUserId ?? u.id;
|
|
return !dmUserIds.has(homeId);
|
|
})
|
|
.map(u => ({ type: 'user' as const, user: u }));
|
|
}, [userResults, dmItems, user]);
|
|
|
|
// Flat unified list
|
|
const allItems = useMemo((): ResultItem[] => {
|
|
return [...dmItems, ...filteredUserResults];
|
|
}, [dmItems, filteredUserResults]);
|
|
|
|
// Clamp selectedIndex when items change
|
|
useEffect(() => {
|
|
setSelectedIndex(prev => Math.min(prev, Math.max(0, allItems.length - 1)));
|
|
}, [allItems.length]);
|
|
|
|
// Scroll selected item into view
|
|
useEffect(() => {
|
|
selectedRef.current?.scrollIntoView({ block: 'nearest' });
|
|
}, [selectedIndex]);
|
|
|
|
// Debounced user search
|
|
useEffect(() => {
|
|
if (searchTimer.current) clearTimeout(searchTimer.current);
|
|
const trimmed = query.trim();
|
|
if (trimmed.length < 2) {
|
|
setUserResults([]);
|
|
setIsSearching(false);
|
|
return;
|
|
}
|
|
setIsSearching(true);
|
|
searchTimer.current = setTimeout(async () => {
|
|
try {
|
|
const users = await api.social.search(trimmed);
|
|
setUserResults(users);
|
|
} catch {
|
|
setUserResults([]);
|
|
} finally {
|
|
setIsSearching(false);
|
|
}
|
|
}, SEARCH_DEBOUNCE);
|
|
return () => { if (searchTimer.current) clearTimeout(searchTimer.current); };
|
|
}, [query]);
|
|
|
|
// Click outside handler
|
|
useEffect(() => {
|
|
if (!active) return;
|
|
const handleClick = (e: MouseEvent) => {
|
|
const anchor = anchorRef.current;
|
|
const floating = floatingRef.current;
|
|
if (anchor?.contains(e.target as Node)) return;
|
|
if (floating?.contains(e.target as Node)) return;
|
|
close();
|
|
};
|
|
document.addEventListener('mousedown', handleClick);
|
|
return () => document.removeEventListener('mousedown', handleClick);
|
|
}, [active]);
|
|
|
|
const open = useCallback(() => {
|
|
setActive(true);
|
|
setQuery('');
|
|
setUserResults([]);
|
|
setError('');
|
|
setSelectedIndex(0);
|
|
setTimeout(() => inputRef.current?.focus(), 0);
|
|
}, []);
|
|
|
|
const close = useCallback(() => {
|
|
setActive(false);
|
|
setQuery('');
|
|
setUserResults([]);
|
|
setError('');
|
|
}, []);
|
|
|
|
const selectItem = useCallback(async (item: ResultItem) => {
|
|
setError('');
|
|
if (item.type === 'dm') {
|
|
close();
|
|
useUIStore.getState().setShowDms(true);
|
|
navigate(`/channels/@me/${item.dm.id}`);
|
|
} else {
|
|
try {
|
|
const existing = useSpaceStore.getState().findExistingDmForUser(item.user);
|
|
if (existing) {
|
|
close();
|
|
useUIStore.getState().setShowDms(true);
|
|
navigate(`/channels/@me/${existing.dm.id}`);
|
|
return;
|
|
}
|
|
const channel = await api.dm.create({
|
|
userId: item.user.homeInstance ? undefined : item.user.id,
|
|
homeUserId: item.user.homeUserId ?? undefined,
|
|
homeInstance: item.user.homeInstance ?? undefined,
|
|
});
|
|
addDmChannel(channel);
|
|
close();
|
|
useUIStore.getState().setShowDms(true);
|
|
navigate(`/channels/@me/${channel.id}`);
|
|
} catch (err) {
|
|
setError((err as Error).message || 'Failed to create DM');
|
|
}
|
|
}
|
|
}, [close, navigate, addDmChannel]);
|
|
|
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
close();
|
|
return;
|
|
}
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
setSelectedIndex(prev => Math.min(prev + 1, allItems.length - 1));
|
|
return;
|
|
}
|
|
if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
setSelectedIndex(prev => Math.max(prev - 1, 0));
|
|
return;
|
|
}
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
const item = allItems[selectedIndex];
|
|
if (item) selectItem(item);
|
|
}
|
|
}, [close, allItems, selectedIndex, selectItem]);
|
|
|
|
// Compute dropdown width to match anchor
|
|
const [dropdownWidth, setDropdownWidth] = useState(0);
|
|
useEffect(() => {
|
|
if (!active || !anchorRef.current) return;
|
|
const update = () => {
|
|
const rect = anchorRef.current?.getBoundingClientRect();
|
|
if (rect) setDropdownWidth(rect.width);
|
|
};
|
|
update();
|
|
const ro = new ResizeObserver(update);
|
|
ro.observe(anchorRef.current);
|
|
return () => ro.disconnect();
|
|
}, [active]);
|
|
|
|
const showSections = allItems.length > 0 || isSearching || (query.trim().length >= 2 && !isSearching);
|
|
|
|
const dropdown = active ? createPortal(
|
|
<div
|
|
ref={floatingRef}
|
|
style={{ ...style, width: dropdownWidth > 0 ? dropdownWidth : undefined }}
|
|
className="animate-fade-in"
|
|
>
|
|
<div className="glass rounded-lg shadow-xl max-h-[360px] overflow-y-auto scrollbar-thin py-1">
|
|
{error && (
|
|
<div className="px-3 py-2 text-txt-danger text-[13px]">{error}</div>
|
|
)}
|
|
|
|
{allItems.length === 0 && !isSearching && query.trim().length === 0 && dmItems.length === 0 && (
|
|
<div className="px-3 py-4 text-center text-txt-tertiary text-[13px]">
|
|
Search for a user to start chatting
|
|
</div>
|
|
)}
|
|
|
|
{/* DM conversations section */}
|
|
{dmItems.length > 0 && (
|
|
<>
|
|
{query.trim().length >= 2 && (
|
|
<div className="px-3 pt-1.5 pb-1 text-[11px] font-bold text-txt-tertiary uppercase tracking-wider">
|
|
Conversations
|
|
</div>
|
|
)}
|
|
{dmItems.map((item, i) => {
|
|
const globalIndex = i;
|
|
const isSelected = globalIndex === selectedIndex;
|
|
return (
|
|
<DmSearchDmRow
|
|
key={item.dm.id}
|
|
item={item}
|
|
isSelected={isSelected}
|
|
selectedRef={isSelected ? selectedRef : undefined}
|
|
onClick={() => selectItem(item)}
|
|
/>
|
|
);
|
|
})}
|
|
</>
|
|
)}
|
|
|
|
{/* Users section */}
|
|
{(filteredUserResults.length > 0 || (isSearching && query.trim().length >= 2)) && (
|
|
<>
|
|
<div className="px-3 pt-1.5 pb-1 text-[11px] font-bold text-txt-tertiary uppercase tracking-wider">
|
|
Users
|
|
</div>
|
|
{isSearching && filteredUserResults.length === 0 && (
|
|
<div className="px-3 py-2 text-center text-txt-tertiary text-[13px]">Searching...</div>
|
|
)}
|
|
{filteredUserResults.map((item, i) => {
|
|
const globalIndex = dmItems.length + i;
|
|
const isSelected = globalIndex === selectedIndex;
|
|
return (
|
|
<DmSearchUserRow
|
|
key={item.user.id}
|
|
user={item.user}
|
|
isSelected={isSelected}
|
|
selectedRef={isSelected ? selectedRef : undefined}
|
|
onClick={() => selectItem(item)}
|
|
/>
|
|
);
|
|
})}
|
|
</>
|
|
)}
|
|
|
|
{/* No results */}
|
|
{!isSearching && query.trim().length >= 2 && allItems.length === 0 && (
|
|
<div className="px-3 py-4 text-center text-txt-tertiary text-[13px]">No results found</div>
|
|
)}
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
) : null;
|
|
|
|
return (
|
|
<div ref={anchorRef} className="flex-1 min-w-0">
|
|
{active ? (
|
|
<div className="flex-1 min-h-8 bg-surface-input rounded-[4px] flex items-center px-2 gap-1.5 border border-white/[0.06] shadow-input">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary flex-shrink-0">
|
|
<path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0016 9.5 6.5 6.5 0 109.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" />
|
|
</svg>
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={query}
|
|
onChange={(e) => { setQuery(e.target.value); setSelectedIndex(0); }}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="Search..."
|
|
className="input-embedded flex-1 min-w-0 text-[13px] font-medium py-[5px]"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<button
|
|
onClick={open}
|
|
className="w-full min-h-8 bg-surface-input text-txt-tertiary text-[13px] font-medium py-[5px] px-2 rounded-[4px] text-left border border-white/[0.06] shadow-input hover:border-white/[0.1] transition-colors"
|
|
>
|
|
Find or start a conversation
|
|
</button>
|
|
)}
|
|
{dropdown}
|
|
</div>
|
|
);
|
|
}
|