import { useCallback, useEffect, useMemo, useState } from 'react'; import { Hash, Map, MessageSquare, Network, Radio, Route, Search, Star, User, Waypoints, } from 'lucide-react'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from './ui/command'; import { Dialog, DialogContent, DialogDescription, DialogTitle } from './ui/dialog'; import { getContactDisplayName } from '../utils/pubkey'; import { SETTINGS_SECTION_LABELS, SETTINGS_SECTION_ORDER, SETTINGS_SECTION_ICONS, type SettingsSection, } from './settings/settingsConstants'; import type { Channel, Contact, Conversation } from '../types'; import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types'; const MAX_PER_GROUP = 8; interface CommandPaletteProps { contacts: Contact[]; channels: Channel[]; onSelectConversation: (conv: Conversation) => void; onOpenSettings: (section: SettingsSection) => void; onRepeaterAutoLogin: (publicKey: string, displayName: string) => void; } interface Searchable { searchText: string; keyText?: string; } interface SearchableContact extends Searchable { contact: Contact; displayName: string; } interface SearchableChannel extends Searchable { channel: Channel; } interface ToolItem extends Searchable { id: string; name: string; icon: React.ComponentType<{ className?: string }>; type: 'raw' | 'map' | 'visualizer' | 'search' | 'trace'; } interface SettingItem extends Searchable { section: SettingsSection; label: string; icon: React.ComponentType<{ className?: string }>; } const TOOL_ITEMS: ToolItem[] = [ { id: 'raw', name: 'Raw Packet Feed', icon: Radio, type: 'raw', searchText: 'raw packet feed' }, { id: 'map', name: 'Map View', icon: Map, type: 'map', searchText: 'map view' }, { id: 'visualizer', name: 'Network Visualizer', icon: Network, type: 'visualizer', searchText: 'network visualizer', }, { id: 'search', name: 'Message Search', icon: Search, type: 'search', searchText: 'message search', }, { id: 'trace', name: 'Route Trace', icon: Route, type: 'trace', searchText: 'route trace' }, ]; const SETTING_ITEMS: SettingItem[] = SETTINGS_SECTION_ORDER.map((section) => ({ section, label: SETTINGS_SECTION_LABELS[section], icon: SETTINGS_SECTION_ICONS[section], searchText: `settings ${SETTINGS_SECTION_LABELS[section]}`.toLowerCase(), })); function fuzzyMatch(text: string, query: string): boolean { let qi = 0; for (let ti = 0; ti < text.length && qi < query.length; ti++) { if (text[ti] === query[qi]) qi++; } return qi === query.length; } function filterList(items: T[], query: string): T[] { if (!query) return items.slice(0, MAX_PER_GROUP); const results: T[] = []; for (const item of items) { const nameMatch = fuzzyMatch(item.searchText, query); const keyMatch = item.keyText ? item.keyText.startsWith(query) : false; if (nameMatch || keyMatch) { results.push(item); if (results.length >= MAX_PER_GROUP) break; } } return results; } export function CommandPalette({ contacts, channels, onSelectConversation, onOpenSettings, onRepeaterAutoLogin, }: CommandPaletteProps) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(''); useEffect(() => { function onKeyDown(e: KeyboardEvent) { if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); setOpen((prev) => !prev); } } document.addEventListener('keydown', onKeyDown); return () => document.removeEventListener('keydown', onKeyDown); }, []); const select = useCallback((action: () => void) => { setOpen(false); action(); }, []); const { favContacts, favRepeaters, regularContacts, repeaters, rooms, favChannels, regularChannels, } = useMemo(() => { const fc: SearchableContact[] = []; const fr: SearchableContact[] = []; const rc: SearchableContact[] = []; const rp: SearchableContact[] = []; const rm: SearchableContact[] = []; for (const c of contacts) { const displayName = getContactDisplayName(c.name, c.public_key, c.last_advert); const entry: SearchableContact = { contact: c, displayName, searchText: displayName.toLowerCase(), keyText: c.public_key.toLowerCase(), }; if (c.type === CONTACT_TYPE_REPEATER) { (c.favorite ? fr : rp).push(entry); } else if (c.type === CONTACT_TYPE_ROOM) { rm.push(entry); } else { (c.favorite ? fc : rc).push(entry); } } const fch: SearchableChannel[] = []; const rch: SearchableChannel[] = []; for (const ch of channels) { const entry: SearchableChannel = { channel: ch, searchText: ch.name.toLowerCase(), keyText: ch.key.toLowerCase(), }; (ch.favorite ? fch : rch).push(entry); } return { favContacts: fc, favRepeaters: fr, regularContacts: rc, repeaters: rp, rooms: rm, favChannels: fch, regularChannels: rch, }; }, [contacts, channels]); const lq = query.toLowerCase(); const fTools = filterList(TOOL_ITEMS, lq); const fSettings = filterList(SETTING_ITEMS, lq); const fFavContacts = filterList(favContacts, lq); const fFavRepeaters = filterList(favRepeaters, lq); const fFavChannels = filterList(favChannels, lq); const fContacts = filterList(regularContacts, lq); const fRepeaters = filterList(repeaters, lq); const fRooms = filterList(rooms, lq); const fChannels = filterList(regularChannels, lq); const totalResults = fTools.length + fSettings.length + fFavContacts.length + fFavRepeaters.length + fFavChannels.length + fContacts.length + fRepeaters.length + fRooms.length + fChannels.length; return ( { setOpen(nextOpen); if (!nextOpen) setQuery(''); }} > Command palette Search for conversations, settings, and tools {totalResults === 0 && No results found.} {fTools.length > 0 && ( {fTools.map((tool) => ( select(() => onSelectConversation({ type: tool.type, id: tool.id, name: tool.name }) ) } > {tool.name} ))} )} {fSettings.length > 0 && ( {fSettings.map((item) => ( select(() => onOpenSettings(item.section))} > {item.label} ))} )} {fFavContacts.length > 0 && ( )} {fFavRepeaters.length > 0 && ( )} {fFavChannels.length > 0 && ( {fFavChannels.map(({ channel: ch }) => ( select(() => onSelectConversation({ type: 'channel', id: ch.key, name: ch.name }) ) } > {ch.name} ))} )} {fContacts.length > 0 && ( )} {fRepeaters.length > 0 && ( )} {fRooms.length > 0 && ( )} {fChannels.length > 0 && ( {fChannels.map(({ channel: ch }) => ( select(() => onSelectConversation({ type: 'channel', id: ch.key, name: ch.name }) ) } > {ch.name} ))} )} ); } function ContactGroup({ heading, items, icon: Icon, showStar, onSelect, onSelectConversation, }: { heading: string; items: SearchableContact[]; icon: React.ComponentType<{ className?: string }>; showStar?: boolean; onSelect: (action: () => void) => void; onSelectConversation: (conv: Conversation) => void; }) { return ( {items.map(({ contact: c, displayName }) => ( onSelect(() => onSelectConversation({ type: 'contact', id: c.public_key, name: displayName }) ) } > {displayName} {showStar && } ))} ); } function RepeaterGroup({ heading, items, showStar, onSelect, onSelectConversation, onRepeaterAutoLogin, }: { heading: string; items: SearchableContact[]; showStar?: boolean; onSelect: (action: () => void) => void; onSelectConversation: (conv: Conversation) => void; onRepeaterAutoLogin: (publicKey: string, displayName: string) => void; }) { return ( {items.flatMap(({ contact: c, displayName }) => [ onSelect(() => onSelectConversation({ type: 'contact', id: c.public_key, name: displayName }) ) } > {displayName} {showStar && } , onSelect(() => onRepeaterAutoLogin(c.public_key, displayName))} > {displayName} (ACL login + load all) , ])} ); }