import React, { useEffect, useRef, useState, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { useFloatingPosition } from '../../hooks/useFloatingPosition'; import { isDmChannel, getChannelOrigin, getApiForOrigin } from '../../stores/spaceStore'; import { Avatar } from '../ui/Avatar'; import { useCanonicalUserView } from '../../utils/userViewLookup'; import type { MessageWithUser, DmMessageWithUser, User } from '@backspace/shared'; type AnyMessage = MessageWithUser | DmMessageWithUser; interface SearchPopoverProps { open: boolean; onClose: () => void; anchorRef: React.RefObject; channelId: string; isDm: boolean; onJumpToMessage: (messageId: string) => void; } function formatTime(timestamp: number): string { const date = new Date(timestamp); const now = new Date(); const isToday = date.toDateString() === now.toDateString(); const yesterday = new Date(now); yesterday.setDate(yesterday.getDate() - 1); const isYesterday = date.toDateString() === yesterday.toDateString(); const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); if (isToday) return `Today at ${time}`; if (isYesterday) return `Yesterday at ${time}`; return `${date.toLocaleDateString()} ${time}`; } function highlightMatch(text: string, query: string): React.ReactNode { if (!query.trim() || !text) return text; const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const parts = text.split(new RegExp(`(${escaped})`, 'gi')); return parts.map((part, i) => part.toLowerCase() === query.toLowerCase() ? {part} : part ); } const _FALLBACK_USER = { id: '', username: '', createdAt: 0, isAdmin: false, replicatedInstances: [] } as unknown as User; function SearchResultRow({ msg, query, onJumpToMessage, }: { msg: AnyMessage; query: string; onJumpToMessage: (id: string) => void; }) { const canonical = useCanonicalUserView(msg.user ?? _FALLBACK_USER); const displayName = canonical.displayName ?? canonical.username ?? '?'; const avatar = msg.user ? canonical.avatar : undefined; return ( ); } export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJumpToMessage }: SearchPopoverProps) { const popoverRef = useRef(null); const inputRef = useRef(null); const { style } = useFloatingPosition(anchorRef, popoverRef, { placement: 'bottom', offset: 8, enabled: open, }); const [query, setQuery] = useState(''); const [fromFilter, setFromFilter] = useState(''); const [hasFilter, setHasFilter] = useState(''); const [beforeFilter, setBeforeFilter] = useState(''); const [afterFilter, setAfterFilter] = useState(''); const [showFilters, setShowFilters] = useState(false); const [results, setResults] = useState([]); const [totalCount, setTotalCount] = useState(0); const [isSearching, setIsSearching] = useState(false); const [offset, setOffset] = useState(0); const debounceRef = useRef>(); // Reset state when channel changes or popover opens useEffect(() => { if (open) { setQuery(''); setFromFilter(''); setHasFilter(''); setBeforeFilter(''); setAfterFilter(''); setResults([]); setTotalCount(0); setOffset(0); setShowFilters(false); setTimeout(() => inputRef.current?.focus(), 50); } }, [open, channelId]); // Click-outside handler useEffect(() => { if (!open) return; const handleClick = (e: MouseEvent) => { if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { onClose(); } }; document.addEventListener('mousedown', handleClick); return () => document.removeEventListener('mousedown', handleClick); }, [open, onClose]); // Escape handler useEffect(() => { if (!open) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [open, onClose]); const doSearch = useCallback(async (searchOffset = 0) => { const trimmed = query.trim(); if (!trimmed && !fromFilter && !hasFilter && !beforeFilter && !afterFilter) { setResults([]); setTotalCount(0); return; } setIsSearching(true); try { const origin = getChannelOrigin(channelId); const client = getApiForOrigin(origin); const params = { q: trimmed || undefined, from: fromFilter || undefined, has: hasFilter || undefined, before: beforeFilter || undefined, after: afterFilter || undefined, offset: searchOffset, limit: 25, }; const data = isDm ? await client.search.dm(channelId, params) : await client.search.channel(channelId, params); if (searchOffset === 0) { setResults(data.results as AnyMessage[]); } else { setResults(prev => [...prev, ...(data.results as AnyMessage[])]); } setTotalCount(data.totalCount); setOffset(searchOffset + data.results.length); } catch (err) { console.error('Search failed:', err); } finally { setIsSearching(false); } }, [query, fromFilter, hasFilter, beforeFilter, afterFilter, channelId, isDm]); // Debounced search on query/filter change useEffect(() => { clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { doSearch(0); }, 300); return () => clearTimeout(debounceRef.current); }, [doSearch]); const handleLoadMore = () => { doSearch(offset); }; if (!open) return null; return createPortal(
{/* Search input */}
setQuery(e.target.value)} placeholder="Search messages..." className="input-embedded flex-1 text-[14px]" /> {query && ( )}
{/* Filter toggle */} {/* Filters row */} {showFilters && (
setFromFilter(e.target.value)} placeholder="username" className="input-search w-full px-2 py-1 text-[13px]" />
setBeforeFilter(e.target.value)} className="input-search w-full px-2 py-1 text-[13px]" />
setAfterFilter(e.target.value)} className="input-search w-full px-2 py-1 text-[13px]" />
)}
{/* Results */}
{isSearching && results.length === 0 && (
)} {!isSearching && results.length === 0 && (query || fromFilter || hasFilter || beforeFilter || afterFilter) && (
No results found
)} {!query && !fromFilter && !hasFilter && !beforeFilter && !afterFilter && results.length === 0 && (
Type to search messages in this channel
)} {results.length > 0 && ( <>
{totalCount} result{totalCount !== 1 ? 's' : ''}
{results.map((msg) => ( ))} {results.length < totalCount && (
)} )}
, document.body, ); }