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 (
); } /** * 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; onClick: () => void; }) { const canonical = useCanonicalUserView(user); const displayName = canonical.displayName ?? canonical.username; return (
{displayName} {canonical.displayName && ( @{canonical.username} )}
); } /** * 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; 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 (
{item.isGroup ? (
{item.otherMembers.slice(0, 2).map((m, idx) => ( ))}
) : ( )} {item.displayName}
); } 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([]); 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(null); const floatingRef = useRef(null); const inputRef = useRef(null); const selectedRef = useRef(null); const searchTimer = useRef>(); 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(); 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(
0 ? dropdownWidth : undefined }} className="animate-fade-in" >
{error && (
{error}
)} {allItems.length === 0 && !isSearching && query.trim().length === 0 && dmItems.length === 0 && (
Search for a user to start chatting
)} {/* DM conversations section */} {dmItems.length > 0 && ( <> {query.trim().length >= 2 && (
Conversations
)} {dmItems.map((item, i) => { const globalIndex = i; const isSelected = globalIndex === selectedIndex; return ( selectItem(item)} /> ); })} )} {/* Users section */} {(filteredUserResults.length > 0 || (isSearching && query.trim().length >= 2)) && ( <>
Users
{isSearching && filteredUserResults.length === 0 && (
Searching...
)} {filteredUserResults.map((item, i) => { const globalIndex = dmItems.length + i; const isSelected = globalIndex === selectedIndex; return ( selectItem(item)} /> ); })} )} {/* No results */} {!isSearching && query.trim().length >= 2 && allItems.length === 0 && (
No results found
)}
, document.body, ) : null; return (
{active ? (
{ setQuery(e.target.value); setSelectedIndex(0); }} onKeyDown={handleKeyDown} placeholder="Search..." className="input-embedded flex-1 min-w-0 text-[13px] font-medium py-[5px]" />
) : ( )} {dropdown}
); }