diff --git a/packages/web/src/components/layout/ChannelSidebar.tsx b/packages/web/src/components/layout/ChannelSidebar.tsx index ef9c4e45..379f5682 100644 --- a/packages/web/src/components/layout/ChannelSidebar.tsx +++ b/packages/web/src/components/layout/ChannelSidebar.tsx @@ -18,6 +18,7 @@ import { parseFederatedUsername, isSelf } from '../../utils/identity'; import { joinVoiceChannel, broadcastVoiceStatus, broadcastDeafenViaLiveKit } from '../../utils/voice'; import { ContextMenu } from '../ui/ContextMenu'; import { ConfirmDialog } from '../ui/ConfirmDialog'; +import { DmSearchBar } from './DmSearchBar'; export function ChannelSidebar() { const spaces = useSpaceStore((s) => s.spaces); @@ -399,9 +400,7 @@ export function ChannelSidebar() { <>
- +
([]); + 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)); + if (otherMembers.length === 0) return null; + const isGroup = dm.members.length > 2; + const displayName = isGroup + ? otherMembers.map(m => m.displayName ?? parseFederatedUsername(m.username).baseName).join(', ') + : 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 origin = resolveUserOrigin(item.user); + const dmApi = getApiForOrigin(origin); + const channel = await dmApi.dm.create({ userId: item.user.id }); + addDmChannel(channel, origin); + 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)} + 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 ? ( +
+ {item.otherMembers.slice(0, 2).map((m, idx) => ( +
+ +
+ ))} +
+ ) : ( + + )} + {item.displayName} +
+ ); + })} + + )} + + {/* 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)} + 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.user.displayName ?? item.user.username} + + {item.user.displayName && ( + + @{item.user.username} + + )} +
+
+ ); + })} + + )} + + {/* 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="flex-1 min-w-0 bg-transparent text-txt-primary placeholder-txt-tertiary/60 text-[13px] font-medium outline-none py-[5px]" + /> +
+ ) : ( + + )} + {dropdown} +
+ ); +}