feat: inline DM search bar — find or start conversations from sidebar header

Replace the static placeholder button with a fully functional search bar
that filters existing DM conversations instantly and searches for users
via the API with debounce. Supports keyboard navigation, federation-aware
DM creation, group DM display, and portal-based glass dropdown.
This commit is contained in:
Jannis Braun
2026-03-13 04:47:18 +01:00
parent 06aa3bb9aa
commit 12b450b7b9
2 changed files with 389 additions and 3 deletions
@@ -18,6 +18,7 @@ import { parseFederatedUsername, isSelf } from '../../utils/identity';
import { joinVoiceChannel, broadcastVoiceStatus, broadcastDeafenViaLiveKit } from '../../utils/voice'; import { joinVoiceChannel, broadcastVoiceStatus, broadcastDeafenViaLiveKit } from '../../utils/voice';
import { ContextMenu } from '../ui/ContextMenu'; import { ContextMenu } from '../ui/ContextMenu';
import { ConfirmDialog } from '../ui/ConfirmDialog'; import { ConfirmDialog } from '../ui/ConfirmDialog';
import { DmSearchBar } from './DmSearchBar';
export function ChannelSidebar() { export function ChannelSidebar() {
const spaces = useSpaceStore((s) => s.spaces); const spaces = useSpaceStore((s) => s.spaces);
@@ -399,9 +400,7 @@ export function ChannelSidebar() {
<> <>
<div className="w-60 md:w-full bg-surface-channel flex flex-col flex-shrink-0 select-none md:pl-[72px] border-r border-border-hard"> <div className="w-60 md:w-full bg-surface-channel flex flex-col flex-shrink-0 select-none md:pl-[72px] border-r border-border-hard">
<div className="h-14 px-[10px] flex items-center border-b border-border-hard z-10"> <div className="h-14 px-[10px] flex items-center border-b border-border-hard z-10">
<button className="flex-1 min-h-8 bg-surface-base text-txt-tertiary text-[13px] font-medium py-[5px] px-2 rounded-[4px] text-left hover:bg-surface-base transition-colors"> <DmSearchBar />
Find or start a conversation
</button>
</div> </div>
<div className="flex-1 overflow-y-auto pt-4 px-2 no-scrollbar" style={{ paddingBottom: floatingPanelHeight + 24 }}> <div className="flex-1 overflow-y-auto pt-4 px-2 no-scrollbar" style={{ paddingBottom: floatingPanelHeight + 24 }}>
<div <div
@@ -0,0 +1,387 @@
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, getApiForOrigin, resolveUserOrigin } 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 { useFloatingPosition } from '../../hooks/useFloatingPosition';
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));
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<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 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(
<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 (
<div
key={item.dm.id}
ref={isSelected ? selectedRef : undefined}
onClick={() => 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 ? (
<div className="relative w-6 h-6 flex-shrink-0">
{item.otherMembers.slice(0, 2).map((m, idx) => (
<div
key={m.id}
className="absolute rounded-full overflow-hidden border-[1.5px] border-surface-channel"
style={{
width: 18, height: 18,
left: idx * 8,
top: idx * 4,
zIndex: 2 - idx,
}}
>
<Avatar src={m.avatar} name={m.displayName ?? parseFederatedUsername(m.username).baseName} size={18} userId={m.homeUserId ?? m.id} user={m} />
</div>
))}
</div>
) : (
<Avatar
src={item.otherMembers[0]?.avatar}
name={item.otherMembers[0]?.displayName ?? parseFederatedUsername(item.otherMembers[0]?.username ?? '').baseName}
size={24}
status={item.otherMembers[0]?.status as 'online' | 'idle' | 'dnd' | 'offline' | undefined}
userId={item.otherMembers[0]?.homeUserId ?? item.otherMembers[0]?.id}
user={item.otherMembers[0]}
/>
)}
<span className="text-[14px] text-txt-primary truncate">{item.displayName}</span>
</div>
);
})}
</>
)}
{/* 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 (
<div
key={item.user.id}
ref={isSelected ? selectedRef : undefined}
onClick={() => 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'
}`}
>
<Avatar
src={item.user.avatar}
name={item.user.displayName ?? item.user.username}
size={24}
status={item.user.status as 'online' | 'idle' | 'dnd' | 'offline' | undefined}
userId={item.user.homeUserId ?? item.user.id}
/>
<div className="flex-1 min-w-0 flex items-center gap-1.5">
<span className="text-[14px] text-txt-primary truncate">
{item.user.displayName ?? item.user.username}
</span>
{item.user.displayName && (
<span className="text-[12px] text-txt-tertiary truncate">
@{item.user.username}
</span>
)}
</div>
</div>
);
})}
</>
)}
{/* 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-base rounded-[4px] flex items-center px-2 gap-1.5">
<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="flex-1 min-w-0 bg-transparent text-txt-primary placeholder-txt-tertiary/60 text-[13px] font-medium outline-none py-[5px]"
/>
</div>
) : (
<button
onClick={open}
className="w-full min-h-8 bg-surface-base text-txt-tertiary text-[13px] font-medium py-[5px] px-2 rounded-[4px] text-left hover:bg-surface-base transition-colors"
>
Find or start a conversation
</button>
)}
{dropdown}
</div>
);
}