import * as React from "react"; import { ChevronsUpDown, User, Users } from "lucide-react"; import { Command } from "cmdk"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import db, { Profile } from "@/services/db"; import { useGrimoire } from "@/core/state"; import { useNostrEvent } from "@/hooks/useNostrEvent"; import { getTagValues, getDisplayName } from "@/lib/nostr-utils"; interface ProfileSelectorProps { onSelect: (value: string) => void; showShortcuts?: boolean; className?: string; placeholder?: string; } /** * ProfileSelector - A searchable combobox for Nostr profiles * Autocompletes from locally cached profiles in IndexedDB * Supports $me and $contacts shortcuts */ export function ProfileSelector({ onSelect, showShortcuts = true, className, placeholder = "Select person...", }: ProfileSelectorProps) { const [open, setOpen] = React.useState(false); const [search, setSearch] = React.useState(""); const [results, setResults] = React.useState([]); const { state } = useGrimoire(); const accountPubkey = state.activeAccount?.pubkey; // Fetch contacts for shortcut count and validation const contactListEvent = useNostrEvent( showShortcuts && accountPubkey ? { kind: 3, pubkey: accountPubkey, identifier: "" } : undefined, ); const contacts = React.useMemo( () => contactListEvent ? getTagValues(contactListEvent, "p").filter((pk) => pk.length === 64) : [], [contactListEvent], ); // Search profiles when search changes React.useEffect(() => { if (!search || search.length < 1) { setResults([]); return; } const lowerSearch = search.toLowerCase(); // Query Dexie profiles table // Note: This is a full scan filter, but acceptable for local cache sizes db.profiles .filter((p) => { const displayName = p.display_name?.toLowerCase() || ""; const name = p.name?.toLowerCase() || ""; const about = p.about?.toLowerCase() || ""; const lud16 = p.lud16?.toLowerCase() || ""; const pubkey = p.pubkey.toLowerCase(); return ( displayName.includes(lowerSearch) || name.includes(lowerSearch) || about.includes(lowerSearch) || lud16.includes(lowerSearch) || pubkey.startsWith(lowerSearch) ); }) .limit(20) .toArray() .then((matches) => { // Sort matches by priority: contacts first, then display_name/name, then about, then lud16 const sorted = matches.sort((a, b) => { // 0. Contact priority const aIsContact = contacts.includes(a.pubkey); const bIsContact = contacts.includes(b.pubkey); if (aIsContact && !bIsContact) return -1; if (!aIsContact && bIsContact) return 1; const aDisplayName = a.display_name?.toLowerCase() || ""; const bDisplayName = b.display_name?.toLowerCase() || ""; const aName = a.name?.toLowerCase() || ""; const bName = b.name?.toLowerCase() || ""; const aAbout = a.about?.toLowerCase() || ""; const bAbout = b.about?.toLowerCase() || ""; const aLud = a.lud16?.toLowerCase() || ""; const bLud = b.lud16?.toLowerCase() || ""; // 1. Display Name / Name priority const aHasNameMatch = aDisplayName.includes(lowerSearch) || aName.includes(lowerSearch); const bHasNameMatch = bDisplayName.includes(lowerSearch) || bName.includes(lowerSearch); if (aHasNameMatch && !bHasNameMatch) return -1; if (!aHasNameMatch && bHasNameMatch) return 1; // 2. Description (About) priority const aHasAboutMatch = aAbout.includes(lowerSearch); const bHasAboutMatch = bAbout.includes(lowerSearch); if (aHasAboutMatch && !bHasAboutMatch) return -1; if (!aHasAboutMatch && bHasAboutMatch) return 1; // 3. Lud16 priority const aHasLudMatch = aLud.includes(lowerSearch); const bHasLudMatch = bLud.includes(lowerSearch); if (aHasLudMatch && !bHasLudMatch) return -1; if (!aHasLudMatch && bHasLudMatch) return 1; return 0; }); setResults(sorted); }); }, [search, contacts]); const handleSelect = (value: string) => { onSelect(value); setSearch(""); setOpen(false); }; return (
No profiles found in cache. {showShortcuts && !search && ( {accountPubkey && ( handleSelect("$me")} className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-muted/30" >
My Profile $me
)} {contacts.length > 0 && ( handleSelect("$contacts")} className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-muted/30" >
My Contacts ({contacts.length}) $contacts
)}
)} {results.length > 0 && ( {results.map((profile) => ( handleSelect(profile.pubkey)} className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-muted/30" >
{getDisplayName(profile.pubkey, profile)}
{profile.about && ( {profile.about} )} {profile.lud16 && ( {profile.lud16} )}
))}
)}
); }