You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

1414 lines
52 KiB

<script lang="ts">
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { getEvent, getEventsByKind, getEventsByPubkey } from '../../services/cache/event-cache.js';
import { cacheEvent } from '../../services/cache/event-cache.js';
import { nip19 } from 'nostr-tools';
import { goto } from '$app/navigation';
import { KIND, KIND_LOOKUP } from '../../types/kind-lookup.js';
import type { NostrEvent } from '../../types/nostr.js';
import { parseProfile } from '../../services/user-data.js';
interface Props {
mode?: 'search' | 'filter'; // 'search' shows dropdown, 'filter' filters page content
placeholder?: string;
onFilterChange?: (result: { type: 'event' | 'pubkey' | 'text' | null; value: string | null; kind?: number | null }) => void;
showKindFilter?: boolean; // Show kind filter dropdown
selectedKind?: number | null; // Selected kind for filtering
onKindChange?: (kind: number | null) => void; // Callback when kind filter changes
hideDropdownResults?: boolean; // If true, don't show dropdown results (for /find page)
onSearchResults?: (results: { events: NostrEvent[]; profiles: string[]; relays?: string[]; eventRelays?: Map<string, string> }) => void; // Callback for search results (events, profile pubkeys, relays used, and relay per event)
allowedKinds?: number[]; // Hard-coded list of kinds to search (hides kind dropdown)
}
let { mode = 'search', placeholder = 'Search events, profiles, pubkeys, or enter event ID...', onFilterChange, showKindFilter = false, selectedKind = null, onKindChange, hideDropdownResults = false, onSearchResults, allowedKinds }: Props = $props();
// Use allowedKinds if provided, otherwise use selectedKind
let effectiveKinds = $derived(allowedKinds && allowedKinds.length > 0 ? allowedKinds : (selectedKind !== null ? [selectedKind] : null));
let searchQuery = $state('');
let searching = $state(false);
let resolving = $state(false);
let searchResults = $state<Array<{ event: NostrEvent; matchType: string }>>([]);
let showResults = $state(false);
let searchInput: HTMLInputElement | null = $state(null);
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
let cacheSearchTimeoutId: ReturnType<typeof setTimeout> | null = null;
// For collecting results when hideDropdownResults is true
let foundEvents: NostrEvent[] = [];
let foundProfiles: string[] = [];
// For cache-only results
let cacheEvents: NostrEvent[] = [];
let cacheProfiles: string[] = [];
// Map to track which relay each event came from
const eventRelayMap = new Map<string, string>();
const CACHE_SEARCH_DEBOUNCE = 500; // 500ms debounce for cache search
// Clear results at start of search
function clearResults() {
foundEvents = [];
foundProfiles = [];
eventRelayMap.clear();
}
// Clear cache results
function clearCacheResults() {
cacheEvents = [];
cacheProfiles = [];
}
// Search cache only (debounced)
async function searchCacheOnly() {
if (!searchQuery.trim()) {
clearCacheResults();
if (hideDropdownResults && onSearchResults) {
onSearchResults({ events: [], profiles: [], relays: [], eventRelays: new Map() });
}
return;
}
const query = searchQuery.trim();
clearCacheResults();
try {
// 1. Check if it's a hex event ID
if (/^[0-9a-f]{64}$/i.test(query)) {
const hexId = query.toLowerCase();
const event = await getEvent(hexId);
if (event) {
// Check if kind matches filter
if (effectiveKinds && effectiveKinds.length > 0 && !effectiveKinds.includes(event.kind)) {
// Doesn't match, continue
} else {
cacheEvents = [event];
if (hideDropdownResults && onSearchResults) {
const cacheRelays = new Map<string, string>();
cacheRelays.set(event.id, 'cache');
onSearchResults({ events: cacheEvents, profiles: [], relays: [], eventRelays: cacheRelays });
}
return;
}
}
// Try as pubkey
const cachedByPubkey = await getEventsByPubkey(hexId, 100);
if (cachedByPubkey.length > 0) {
const filtered = effectiveKinds && effectiveKinds.length > 0
? cachedByPubkey.filter(e => effectiveKinds.includes(e.kind))
: cachedByPubkey;
cacheEvents = filtered;
cacheProfiles = [hexId];
if (hideDropdownResults && onSearchResults) {
const cacheRelays = new Map<string, string>();
for (const event of cacheEvents) {
cacheRelays.set(event.id, 'cache');
}
onSearchResults({ events: cacheEvents, profiles: cacheProfiles, relays: [], eventRelays: cacheRelays });
}
return;
}
}
// 2. Check if it's a bech32 npub/nprofile/naddr
if (/^(npub|nprofile|naddr)1[a-z0-9]+$/i.test(query)) {
try {
const decoded = nip19.decode(query);
let pubkey: string | null = null;
if (decoded.type === 'npub') {
pubkey = String(decoded.data);
} else if (decoded.type === 'nprofile') {
if (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data) {
pubkey = String(decoded.data.pubkey);
}
} else if (decoded.type === 'naddr') {
// naddr encodes kind + pubkey + d tag
if (decoded.data && typeof decoded.data === 'object' && 'kind' in decoded.data && 'pubkey' in decoded.data && 'identifier' in decoded.data) {
const naddrData = decoded.data as { kind: number; pubkey: string; identifier: string };
const kind = naddrData.kind;
pubkey = String(naddrData.pubkey);
const dTag = String(naddrData.identifier);
// Check if kind matches filter
if (effectiveKinds && effectiveKinds.length > 0 && !effectiveKinds.includes(kind)) {
// Doesn't match, continue
} else {
// Search cache for event with matching kind, pubkey, and d tag
const cachedByKind = await getEventsByKind(kind, 1000);
const event = cachedByKind.find(e =>
pubkey && e.pubkey.toLowerCase() === pubkey.toLowerCase() &&
e.tags.some(t => t[0] === 'd' && t[1] === dTag)
);
if (event) {
cacheEvents = [event];
if (hideDropdownResults && onSearchResults) {
const cacheRelays = new Map<string, string>();
cacheRelays.set(event.id, 'cache');
onSearchResults({ events: cacheEvents, profiles: [], relays: [], eventRelays: cacheRelays });
}
return;
}
}
}
}
if (pubkey) {
const normalizedPubkey = pubkey.toLowerCase();
const cachedByPubkey = await getEventsByPubkey(normalizedPubkey, 100);
if (cachedByPubkey.length > 0) {
const filtered = effectiveKinds && effectiveKinds.length > 0
? cachedByPubkey.filter(e => effectiveKinds.includes(e.kind))
: cachedByPubkey;
cacheEvents = filtered;
cacheProfiles = [normalizedPubkey];
if (hideDropdownResults && onSearchResults) {
const cacheRelays = new Map<string, string>();
for (const event of cacheEvents) {
cacheRelays.set(event.id, 'cache');
}
onSearchResults({ events: cacheEvents, profiles: cacheProfiles, relays: [], eventRelays: cacheRelays });
}
return;
}
}
} catch {
// Not a valid bech32, continue
}
}
// 3. Search by kind if specified
if (effectiveKinds && effectiveKinds.length > 0) {
const allCached: NostrEvent[] = [];
for (const kind of effectiveKinds) {
const cached = await getEventsByKind(kind, 100);
allCached.push(...cached);
}
// Filter by content matching query
const queryLower = query.toLowerCase();
const matching = allCached.filter(event => {
return event.content.toLowerCase().includes(queryLower) ||
event.tags.some(tag => tag.some(v => v && v.toLowerCase().includes(queryLower)));
});
cacheEvents = matching.slice(0, 100);
if (hideDropdownResults && onSearchResults && cacheEvents.length > 0) {
const cacheRelays = new Map<string, string>();
for (const event of cacheEvents) {
cacheRelays.set(event.id, 'cache');
}
onSearchResults({ events: cacheEvents, profiles: [], relays: [], eventRelays: cacheRelays });
}
}
} catch (error) {
console.error('Error searching cache:', error);
}
}
// Debounced cache search
$effect(() => {
if (cacheSearchTimeoutId) {
clearTimeout(cacheSearchTimeoutId);
}
if (searchQuery.trim() && hideDropdownResults && onSearchResults) {
cacheSearchTimeoutId = setTimeout(() => {
searchCacheOnly();
}, CACHE_SEARCH_DEBOUNCE);
} else {
clearCacheResults();
}
return () => {
if (cacheSearchTimeoutId) {
clearTimeout(cacheSearchTimeoutId);
}
};
});
// For filter mode: resolved search result
let filterResult = $state<{ type: 'event' | 'pubkey' | 'text' | null; value: string | null; kind?: number | null }>({ type: null, value: null, kind: null });
// Note: filterResult kind is updated in performSearch, not here to avoid loops
// Check if input looks like NIP-05 (user@domain.com)
function isNIP05(input: string): boolean {
const trimmed = input.trim();
return /^[^@]+@[^@]+\.[^@]+$/.test(trimmed);
}
// Search cache for profiles with matching NIP-05
async function searchCacheForNIP05(nip05: string): Promise<string | null> {
try {
const db = await import('../../services/cache/indexeddb-store.js').then(m => m.getDB());
const tx = db.transaction('profiles', 'readonly');
const store = tx.store;
const profiles: any[] = [];
let cursor = await store.openCursor();
while (cursor) {
profiles.push(cursor.value);
cursor = await cursor.continue();
}
await tx.done;
const normalizedNIP05 = nip05.toLowerCase();
for (const cached of profiles) {
const profile = parseProfile(cached.event);
if (profile.nip05) {
for (const profileNip05 of profile.nip05) {
if (profileNip05.toLowerCase() === normalizedNIP05) {
return cached.pubkey;
}
}
}
}
} catch (error) {
console.debug('Error searching cache for NIP-05:', error);
}
return null;
}
// Resolve NIP-05 from well-known.json
async function resolveNIP05FromWellKnown(nip05: string): Promise<string | null> {
try {
const [localPart, domain] = nip05.split('@');
if (!localPart || !domain) return null;
const wellKnownUrl = `https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(localPart)}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(wellKnownUrl, { signal: controller.signal });
clearTimeout(timeout);
if (!response.ok) {
return null;
}
const data = await response.json();
const names = data.names || {};
const pubkey = names[localPart];
if (pubkey && /^[0-9a-f]{64}$/i.test(pubkey)) {
return pubkey.toLowerCase();
}
} catch (fetchError) {
clearTimeout(timeout);
if (fetchError instanceof Error && fetchError.name !== 'AbortError') {
console.debug('Error fetching well-known.json:', fetchError);
}
}
} catch (error) {
console.debug('Error resolving NIP-05:', error);
}
return null;
}
async function performSearch() {
if (!searchQuery.trim()) {
searchResults = [];
showResults = false;
filterResult = { type: null, value: null, kind: effectiveKinds?.[0] || selectedKind || null };
if (onFilterChange) onFilterChange(filterResult);
return;
}
// Clear results at start of search
clearResults();
await nostrClient.initialize();
searching = true;
resolving = true;
searchResults = [];
showResults = true;
filterResult = { type: null, value: null, kind: effectiveKinds?.[0] || selectedKind || null };
// Get relays that will be used for search (used for empty result messages)
const relaysUsed = relayManager.getAllAvailableRelays();
try {
const query = searchQuery.trim();
// 1. Check if it's a hex event ID (64 hex chars)
if (/^[0-9a-f]{64}$/i.test(query)) {
const hexId = query.toLowerCase();
let event: NostrEvent | undefined = await getEvent(hexId);
if (!event) {
const relays = relayManager.getAllAvailableRelays();
const events = await nostrClient.fetchEvents(
[{ ids: [hexId] }],
relays,
{ useCache: false, cacheResults: true }
);
if (events.length > 0) {
event = events[0];
await cacheEvent(event);
}
}
if (event) {
// If kinds are specified, filter by kind
if (effectiveKinds && effectiveKinds.length > 0 && !effectiveKinds.includes(event.kind)) {
// Event found but doesn't match allowed kinds, continue to next check
} else {
if (mode === 'search') {
if (hideDropdownResults && onSearchResults) {
foundEvents = [event];
// Create relay map for found events
const foundEventRelays = new Map<string, string>();
for (const event of foundEvents) {
const relay = eventRelayMap.get(event.id);
if (relay) {
foundEventRelays.set(event.id, relay);
}
}
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed, eventRelays: foundEventRelays });
} else {
searchResults = [{ event, matchType: 'Event ID' }];
showResults = true;
}
} else {
filterResult = { type: 'event', value: event.id, kind: effectiveKinds?.[0] || selectedKind || null };
if (onFilterChange) onFilterChange(filterResult);
}
searching = false;
resolving = false;
return;
}
}
// Event not found, try as pubkey (step 2)
const hexPubkey = hexId.toLowerCase();
// If kinds are specified, search for events with this pubkey in p/q tags
if (effectiveKinds && effectiveKinds.length > 0 && mode === 'search' && hideDropdownResults && onSearchResults) {
const relays = relayManager.getAllAvailableRelays();
const allEvents = new Map<string, NostrEvent>();
// Search each allowed kind
for (const kind of effectiveKinds) {
// Search by author (most important for bookmarks and highlights)
const eventsByAuthor = await nostrClient.fetchEvents(
[{ kinds: [kind], authors: [hexPubkey], limit: 100 }],
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
// Search for events with pubkey in p or q tags
const eventsWithP = await nostrClient.fetchEvents(
[{ kinds: [kind], '#p': [hexPubkey], limit: 100 }],
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
const eventsWithQ = await nostrClient.fetchEvents(
[{ kinds: [kind], '#q': [hexPubkey], limit: 100 }],
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
// Add to combined results
for (const event of [...eventsByAuthor, ...eventsWithP, ...eventsWithQ]) {
allEvents.set(event.id, event);
}
}
foundEvents = Array.from(allEvents.values());
// Create relay map for found events
const foundEventRelays = new Map<string, string>();
for (const event of foundEvents) {
const relay = eventRelayMap.get(event.id);
if (relay) {
foundEventRelays.set(event.id, relay);
}
}
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed, eventRelays: foundEventRelays });
searching = false;
resolving = false;
return;
} else if (mode === 'search') {
if (hideDropdownResults && onSearchResults) {
foundProfiles = [hexPubkey];
onSearchResults({ events: [], profiles: foundProfiles, relays: relaysUsed });
} else {
// For search mode, navigate to profile
handleProfileClick(hexPubkey);
}
searching = false;
resolving = false;
return;
} else {
filterResult = { type: 'pubkey', value: hexPubkey, kind: effectiveKinds?.[0] || selectedKind || null };
if (onFilterChange) onFilterChange(filterResult);
searching = false;
resolving = false;
return;
}
}
// 3. Check npub, nprofile (resolve to hex)
if (/^(npub|nprofile)1[a-z0-9]+$/i.test(query)) {
try {
const decoded = nip19.decode(query);
let pubkey: string | null = null;
if (decoded.type === 'npub') {
pubkey = String(decoded.data);
} else if (decoded.type === 'nprofile') {
if (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data) {
pubkey = String(decoded.data.pubkey);
}
}
if (pubkey) {
const normalizedPubkey = pubkey.toLowerCase();
// If kinds are specified, search for events with this pubkey in p/q tags
if (effectiveKinds && effectiveKinds.length > 0 && mode === 'search' && hideDropdownResults && onSearchResults) {
const relays = relayManager.getAllAvailableRelays();
const allEvents = new Map<string, NostrEvent>();
// Search each allowed kind
for (const kind of effectiveKinds) {
// Search by author (most important for bookmarks and highlights)
const eventsByAuthor = await nostrClient.fetchEvents(
[{ kinds: [kind], authors: [normalizedPubkey], limit: 100 }],
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
// Search for events with pubkey in p or q tags
const eventsWithP = await nostrClient.fetchEvents(
[{ kinds: [kind], '#p': [normalizedPubkey], limit: 100 }],
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
const eventsWithQ = await nostrClient.fetchEvents(
[{ kinds: [kind], '#q': [normalizedPubkey], limit: 100 }],
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
// Add to combined results
for (const event of [...eventsByAuthor, ...eventsWithP, ...eventsWithQ]) {
allEvents.set(event.id, event);
}
}
foundEvents = Array.from(allEvents.values());
// Create relay map for found events
const foundEventRelays = new Map<string, string>();
for (const event of foundEvents) {
const relay = eventRelayMap.get(event.id);
if (relay) {
foundEventRelays.set(event.id, relay);
}
}
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed, eventRelays: foundEventRelays });
searching = false;
resolving = false;
return;
} else if (mode === 'search') {
if (hideDropdownResults && onSearchResults) {
foundProfiles = [normalizedPubkey];
onSearchResults({ events: [], profiles: foundProfiles, relays: relaysUsed });
} else {
handleProfileClick(normalizedPubkey);
}
searching = false;
resolving = false;
return;
} else {
filterResult = { type: 'pubkey', value: normalizedPubkey, kind: effectiveKinds?.[0] || selectedKind || null };
if (onFilterChange) onFilterChange(filterResult);
searching = false;
resolving = false;
return;
}
}
} catch (error) {
console.debug('Error decoding npub/nprofile:', error);
}
}
// 4. Check NIP-05 (resolve to hex)
if (isNIP05(query)) {
resolving = true;
try {
// First check cache
const cachedPubkey = await searchCacheForNIP05(query);
if (cachedPubkey) {
const normalizedPubkey = cachedPubkey.toLowerCase();
// If kinds are specified, search for events with this pubkey in p/q tags
if (effectiveKinds && effectiveKinds.length > 0 && mode === 'search' && hideDropdownResults && onSearchResults) {
const relays = relayManager.getAllAvailableRelays();
const allEvents = new Map<string, NostrEvent>();
// Search each allowed kind
for (const kind of effectiveKinds) {
// Search by author (most important for bookmarks and highlights)
const eventsByAuthor = await nostrClient.fetchEvents(
[{ kinds: [kind], authors: [normalizedPubkey], limit: 100 }],
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
// Search for events with pubkey in p or q tags
const eventsWithP = await nostrClient.fetchEvents(
[{ kinds: [kind], '#p': [normalizedPubkey], limit: 100 }],
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
const eventsWithQ = await nostrClient.fetchEvents(
[{ kinds: [kind], '#q': [normalizedPubkey], limit: 100 }],
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
// Add to combined results
for (const event of [...eventsByAuthor, ...eventsWithP, ...eventsWithQ]) {
allEvents.set(event.id, event);
}
}
foundEvents = Array.from(allEvents.values());
// Create relay map for found events
const foundEventRelays = new Map<string, string>();
for (const event of foundEvents) {
const relay = eventRelayMap.get(event.id);
if (relay) {
foundEventRelays.set(event.id, relay);
}
}
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed, eventRelays: foundEventRelays });
searching = false;
resolving = false;
return;
} else if (mode === 'search') {
if (hideDropdownResults && onSearchResults) {
foundProfiles = [normalizedPubkey];
onSearchResults({ events: [], profiles: foundProfiles, relays: relaysUsed });
} else {
handleProfileClick(normalizedPubkey);
}
searching = false;
resolving = false;
return;
} else {
filterResult = { type: 'pubkey', value: normalizedPubkey, kind: effectiveKinds?.[0] || selectedKind || null };
if (onFilterChange) onFilterChange(filterResult);
searching = false;
resolving = false;
return;
}
}
// Then check well-known.json
const wellKnownPubkey = await resolveNIP05FromWellKnown(query);
if (wellKnownPubkey) {
const normalizedPubkey = wellKnownPubkey.toLowerCase();
// If kinds are specified, search for events with this pubkey in p/q tags
if (effectiveKinds && effectiveKinds.length > 0 && mode === 'search' && hideDropdownResults && onSearchResults) {
const relays = relayManager.getAllAvailableRelays();
const allEvents = new Map<string, NostrEvent>();
// Search each allowed kind
for (const kind of effectiveKinds) {
// Search by author (most important for bookmarks and highlights)
const eventsByAuthor = await nostrClient.fetchEvents(
[{ kinds: [kind], authors: [normalizedPubkey], limit: 100 }],
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
// Search for events with pubkey in p or q tags
const eventsWithP = await nostrClient.fetchEvents(
[{ kinds: [kind], '#p': [normalizedPubkey], limit: 100 }],
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
const eventsWithQ = await nostrClient.fetchEvents(
[{ kinds: [kind], '#q': [normalizedPubkey], limit: 100 }],
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
// Add to combined results
for (const event of [...eventsByAuthor, ...eventsWithP, ...eventsWithQ]) {
allEvents.set(event.id, event);
}
}
foundEvents = Array.from(allEvents.values());
// Create relay map for found events
const foundEventRelays = new Map<string, string>();
for (const event of foundEvents) {
const relay = eventRelayMap.get(event.id);
if (relay) {
foundEventRelays.set(event.id, relay);
}
}
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed, eventRelays: foundEventRelays });
searching = false;
resolving = false;
return;
} else if (mode === 'search') {
if (hideDropdownResults && onSearchResults) {
foundProfiles = [normalizedPubkey];
onSearchResults({ events: [], profiles: foundProfiles, relays: relaysUsed });
} else {
handleProfileClick(normalizedPubkey);
}
searching = false;
resolving = false;
return;
} else {
filterResult = { type: 'pubkey', value: normalizedPubkey, kind: effectiveKinds?.[0] || selectedKind || null };
if (onFilterChange) onFilterChange(filterResult);
searching = false;
resolving = false;
return;
}
}
} catch (error) {
console.debug('Error resolving NIP-05:', error);
}
resolving = false;
}
// 5. Check note, nevent, naddr (resolve to hex event ID)
if (/^(note|nevent|naddr)1[a-z0-9]+$/i.test(query)) {
try {
const decoded = nip19.decode(query);
let eventId: string | null = null;
if (decoded.type === 'note') {
eventId = String(decoded.data);
} else if (decoded.type === 'nevent') {
if (decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) {
eventId = String(decoded.data.id);
}
} else if (decoded.type === 'naddr') {
// naddr encodes kind + pubkey + d tag
if (decoded.data && typeof decoded.data === 'object' && 'kind' in decoded.data && 'pubkey' in decoded.data && 'identifier' in decoded.data) {
const naddrData = decoded.data as { kind: number; pubkey: string; identifier: string; relays?: string[] };
const kind = naddrData.kind;
const pubkey = String(naddrData.pubkey);
const dTag = String(naddrData.identifier);
// Check if kind matches filter
if (effectiveKinds && effectiveKinds.length > 0 && !effectiveKinds.includes(kind)) {
// Doesn't match, continue
} else {
// Try to find in cache first
const cachedByKind = await getEventsByKind(kind, 1000);
let event: NostrEvent | undefined = cachedByKind.find(e =>
e.pubkey.toLowerCase() === pubkey.toLowerCase() &&
e.tags.some(t => t[0] === 'd' && t[1] === dTag)
);
if (!event) {
// Fetch from relays
const relays = naddrData.relays && naddrData.relays.length > 0
? naddrData.relays
: relayManager.getAllAvailableRelays();
const events = await nostrClient.fetchEvents(
[{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }],
relays,
{ useCache: true, cacheResults: true }
);
if (events.length > 0) {
event = events[0];
}
}
if (event) {
if (mode === 'search') {
if (hideDropdownResults && onSearchResults) {
foundEvents = [event];
const foundEventRelays = new Map<string, string>();
foundEventRelays.set(event.id, 'cache');
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed, eventRelays: foundEventRelays });
} else {
searchResults = [{ event, matchType: 'Addressable Event (naddr)' }];
showResults = true;
}
} else {
filterResult = { type: 'event', value: event.id, kind: kind };
if (onFilterChange) onFilterChange(filterResult);
}
searching = false;
resolving = false;
return;
}
}
}
}
if (eventId) {
let event: NostrEvent | undefined = await getEvent(eventId);
if (!event) {
const relays = relayManager.getAllAvailableRelays();
const events = await nostrClient.fetchEvents(
[{ ids: [eventId] }],
relays,
{ useCache: false, cacheResults: true }
);
if (events.length > 0) {
event = events[0];
await cacheEvent(event);
}
}
if (event) {
// If kinds are specified, filter by kind
if (effectiveKinds && effectiveKinds.length > 0 && !effectiveKinds.includes(event.kind)) {
// Event found but doesn't match allowed kinds, continue to next check
} else {
if (mode === 'search') {
if (hideDropdownResults && onSearchResults) {
foundEvents = [event];
// Create relay map for found events
const foundEventRelays = new Map<string, string>();
for (const event of foundEvents) {
const relay = eventRelayMap.get(event.id);
if (relay) {
foundEventRelays.set(event.id, relay);
}
}
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed, eventRelays: foundEventRelays });
} else {
searchResults = [{ event, matchType: 'Event ID' }];
showResults = true;
}
} else {
filterResult = { type: 'event', value: event.id, kind: effectiveKinds?.[0] || selectedKind || null };
if (onFilterChange) onFilterChange(filterResult);
}
searching = false;
resolving = false;
return;
}
}
}
} catch (error) {
console.debug('Error decoding note/nevent/naddr:', error);
}
}
// 6. Anything else is a full-text search
if (mode === 'search') {
let allEvents: NostrEvent[] = [];
const uniqueEventIds = new Set<string>();
// Helper to add event and notify if needed
const addEvent = (event: NostrEvent, relay?: string) => {
if (!uniqueEventIds.has(event.id)) {
uniqueEventIds.add(event.id);
allEvents.push(event);
// Track relay source
if (relay) {
eventRelayMap.set(event.id, relay);
}
// If hideDropdownResults, notify incrementally
if (hideDropdownResults && onSearchResults) {
const queryLower = query.toLowerCase();
const matches = allEvents.filter(event => {
const contentMatch = event.content.toLowerCase().includes(queryLower);
const titleTag = event.tags.find(t => t[0] === 'title');
const titleMatch = titleTag?.[1]?.toLowerCase().includes(queryLower) || false;
const summaryTag = event.tags.find(t => t[0] === 'summary');
const summaryMatch = summaryTag?.[1]?.toLowerCase().includes(queryLower) || false;
return contentMatch || titleMatch || summaryMatch;
});
// Sort and limit
const sorted = matches.sort((a, b) => {
const aExact = a.content.toLowerCase() === queryLower;
const bExact = b.content.toLowerCase() === queryLower;
if (aExact && !bExact) return -1;
if (!aExact && bExact) return 1;
return b.created_at - a.created_at;
});
foundEvents = sorted.slice(0, 100);
// Create a map of event IDs to relay URLs for the found events
const foundEventRelays = new Map<string, string>();
for (const event of foundEvents) {
const relay = eventRelayMap.get(event.id);
if (relay) {
foundEventRelays.set(event.id, relay);
}
}
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed, eventRelays: foundEventRelays });
}
}
};
// If kinds are specified, search from relays
if (effectiveKinds && effectiveKinds.length > 0) {
const relays = relayManager.getAllAvailableRelays();
const queryLower = query.toLowerCase();
// Search each allowed kind with onUpdate for incremental results
for (const kind of effectiveKinds) {
await nostrClient.fetchEvents(
[{ kinds: [kind], limit: 100 }],
relays,
{
useCache: 'cache-first',
cacheResults: true,
timeout: 10000,
onUpdateWithRelay: (eventsWithRelay: Array<{ event: NostrEvent; relay: string }>) => {
// Filter and add events as they arrive with relay info
for (const { event, relay } of eventsWithRelay) {
const contentMatch = event.content.toLowerCase().includes(queryLower);
const titleTag = event.tags.find(t => t[0] === 'title');
const titleMatch = titleTag?.[1]?.toLowerCase().includes(queryLower) || false;
const summaryTag = event.tags.find(t => t[0] === 'summary');
const summaryMatch = summaryTag?.[1]?.toLowerCase().includes(queryLower) || false;
if (contentMatch || titleMatch || summaryMatch) {
addEvent(event, relay);
}
}
}
}
);
}
} else {
// Text search in cached events (title, summary, content)
const allCached: NostrEvent[] = [];
// If kind filter is selected, only search that kind
if (selectedKind !== null) {
const kindEvents = await getEventsByKind(selectedKind, 100);
allCached.push(...kindEvents);
} else {
// Search all kinds we handle (limit each to 20 to keep total reasonable, then limit final to 100)
const kindsToSearch = Object.keys(KIND_LOOKUP).map(k => parseInt(k)).filter(k => !KIND_LOOKUP[k].isSecondaryKind);
for (const kind of kindsToSearch) {
try {
const kindEvents = await getEventsByKind(kind, 20);
allCached.push(...kindEvents);
// Stop if we have enough events already
if (allCached.length >= 100) break;
} catch (e) {
// Skip kinds that fail
}
}
}
const queryLower = query.toLowerCase();
allEvents = allCached.filter(event => {
const contentMatch = event.content.toLowerCase().includes(queryLower);
const titleTag = event.tags.find(t => t[0] === 'title');
const titleMatch = titleTag?.[1]?.toLowerCase().includes(queryLower) || false;
const summaryTag = event.tags.find(t => t[0] === 'summary');
const summaryMatch = summaryTag?.[1]?.toLowerCase().includes(queryLower) || false;
// Mark all cached events as coming from cache
if (contentMatch || titleMatch || summaryMatch) {
eventRelayMap.set(event.id, 'cache');
}
return contentMatch || titleMatch || summaryMatch;
});
}
// Final sort and limit (only if not already handled incrementally)
if (!(hideDropdownResults && onSearchResults && effectiveKinds && effectiveKinds.length > 0)) {
const queryLower = query.toLowerCase();
const sorted = allEvents.sort((a, b) => {
const aExact = a.content.toLowerCase() === queryLower;
const bExact = b.content.toLowerCase() === queryLower;
if (aExact && !bExact) return -1;
if (!aExact && bExact) return 1;
return b.created_at - a.created_at;
});
// Deduplicate by event ID
const uniqueEvents = new Map<string, NostrEvent>();
for (const event of sorted) {
uniqueEvents.set(event.id, event);
}
const limitedResults = Array.from(uniqueEvents.values()).slice(0, 100);
if (hideDropdownResults && onSearchResults) {
foundEvents = limitedResults;
// Create relay map for found events
const foundEventRelays = new Map<string, string>();
for (const event of foundEvents) {
const relay = eventRelayMap.get(event.id);
if (relay) {
foundEventRelays.set(event.id, relay);
}
}
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed, eventRelays: foundEventRelays });
} else {
searchResults = limitedResults.map(e => ({ event: e, matchType: 'Content' }));
showResults = true;
}
}
} else {
// Filter mode: treat as text search
filterResult = { type: 'text', value: query, kind: effectiveKinds?.[0] || selectedKind || null };
if (onFilterChange) onFilterChange(filterResult);
}
} catch (error) {
console.error('Search error:', error);
// Ensure we reset state even on error
if (hideDropdownResults && onSearchResults) {
onSearchResults({ events: [], profiles: [], relays: relaysUsed });
}
} finally {
searching = false;
resolving = false;
}
}
function handleSearchInput(e: Event) {
const target = e.target as HTMLInputElement;
const newValue = target.value;
searchQuery = newValue;
if (searchTimeout) {
clearTimeout(searchTimeout);
}
if (newValue.trim()) {
searching = true;
searchTimeout = setTimeout(() => {
performSearch();
}, 300);
} else {
searchResults = [];
showResults = false;
searching = false;
filterResult = { type: null, value: null, kind: selectedKind };
if (onFilterChange) onFilterChange(filterResult);
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter') {
if (searchTimeout) {
clearTimeout(searchTimeout);
}
performSearch();
} else if (e.key === 'Escape') {
showResults = false;
searchQuery = '';
filterResult = { type: null, value: null, kind: selectedKind };
if (onFilterChange) onFilterChange(filterResult);
}
}
function handleResultClick(event: NostrEvent) {
showResults = false;
searchQuery = '';
goto(`/event/${event.id}`);
}
function handleProfileClick(pubkey: string) {
showResults = false;
searchQuery = '';
goto(`/profile/${pubkey}`);
}
// Close results when clicking outside
$effect(() => {
if (showResults) {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest('.unified-search-container')) {
showResults = false;
}
};
document.addEventListener('click', handleClickOutside, true);
return () => {
document.removeEventListener('click', handleClickOutside, true);
};
}
});
// Cleanup timeout on unmount
$effect(() => {
return () => {
if (searchTimeout) {
clearTimeout(searchTimeout);
}
};
});
// Expose filter result for parent components (filter mode)
export function getFilterResult(): { type: 'event' | 'pubkey' | 'text' | null; value: string | null; kind?: number | null } {
return filterResult;
}
// Expose performSearch for manual trigger
export function triggerSearch() {
performSearch();
}
export function clearSearch() {
searchQuery = '';
searchResults = [];
showResults = false;
clearResults();
clearCacheResults();
filterResult = { type: null, value: null, kind: selectedKind };
if (onFilterChange) onFilterChange(filterResult);
if (hideDropdownResults && onSearchResults) {
onSearchResults({ events: [], profiles: [], relays: [], eventRelays: new Map() });
}
}
// Note: filterResult kind is updated in performSearch, not here to avoid loops
// Get all kinds for dropdown (sorted by number)
let allKinds = $derived(Object.values(KIND_LOOKUP).sort((a, b) => a.number - b.number));
function handleKindChange(e: Event) {
const select = e.target as HTMLSelectElement;
const kind = select.value === '' ? null : parseInt(select.value);
if (onKindChange) {
onKindChange(kind);
}
// Update filter result
filterResult = { ...filterResult, kind };
if (onFilterChange) {
onFilterChange(filterResult);
}
// Re-run search if there's a query
if (searchQuery.trim()) {
performSearch();
}
}
</script>
<div class="unified-search-container">
<div class="search-input-wrapper">
{#if showKindFilter && !allowedKinds}
<select
value={selectedKind?.toString() || ''}
onchange={handleKindChange}
class="kind-filter-select"
aria-label="Filter by kind"
>
<option value="">All Kinds</option>
{#each allKinds as kindInfo}
<option value={kindInfo.number}>{kindInfo.number}: {kindInfo.description}</option>
{/each}
</select>
{/if}
<input
bind:this={searchInput}
type="text"
placeholder={placeholder}
value={searchQuery}
oninput={handleSearchInput}
onkeydown={handleKeyDown}
class="search-input"
class:resolving={resolving}
class:with-kind-filter={showKindFilter}
aria-label="Search"
/>
{#if searching || resolving}
<span class="search-loading"></span>
{/if}
</div>
{#if mode === 'search' && !hideDropdownResults && showResults && searchResults.length > 0}
<div class="search-results">
{#each searchResults as { event, matchType }}
<button
onclick={() => {
if (event.kind === KIND.METADATA) {
handleProfileClick(event.pubkey);
} else {
handleResultClick(event);
}
}}
class="search-result-item"
>
<div class="search-result-header">
<span class="search-result-type">{matchType}</span>
<span class="search-result-id">{event.id.substring(0, 16)}...</span>
</div>
<div class="search-result-content">
{event.content.substring(0, 100)}{event.content.length > 100 ? '...' : ''}
</div>
<div class="search-result-meta">
Kind {event.kind}{new Date(event.created_at * 1000).toLocaleDateString()}
</div>
</button>
{/each}
</div>
{:else if mode === 'search' && !hideDropdownResults && showResults && !searching && searchQuery.trim()}
<div class="search-results">
<div class="search-no-results">No results found</div>
</div>
{/if}
</div>
<style>
.unified-search-container {
position: relative;
width: 100%;
max-width: 600px;
}
.search-input-wrapper {
position: relative;
display: flex;
align-items: center;
gap: 0.5rem;
}
.kind-filter-select {
padding: 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
background-color: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
font-family: monospace;
cursor: pointer;
min-width: 150px;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
}
.kind-filter-select option {
background: var(--fog-post, #ffffff);
background-color: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
}
.kind-filter-select option:checked {
background: var(--fog-accent, #64748b) !important;
background-color: var(--fog-accent, #64748b) !important;
color: var(--fog-post, #ffffff) !important;
}
/* Ensure selected option in dropdown has proper contrast */
.kind-filter-select option:checked:not(:disabled) {
background: var(--fog-accent, #64748b) !important;
background-color: var(--fog-accent, #64748b) !important;
color: var(--fog-post, #ffffff) !important;
}
.kind-filter-select:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.1);
}
:global(.dark) .kind-filter-select {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
background-color: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .kind-filter-select option {
background: var(--fog-dark-post, #1f2937);
background-color: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .kind-filter-select option:checked {
background: var(--fog-dark-accent, #94a3b8) !important;
background-color: var(--fog-dark-accent, #94a3b8) !important;
color: #ffffff !important;
}
/* Ensure selected option in dropdown has proper contrast in dark mode */
:global(.dark) .kind-filter-select option:checked:not(:disabled) {
background: var(--fog-dark-accent, #94a3b8) !important;
background-color: var(--fog-dark-accent, #94a3b8) !important;
color: #ffffff !important;
}
:global(.dark) .kind-filter-select:focus {
border-color: var(--fog-dark-accent, #94a3b8);
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.1);
}
.search-input.with-kind-filter {
flex: 1;
}
.search-input {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
transition: all 0.2s;
}
.search-input:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.1);
}
.search-input.resolving {
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .search-input {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .search-input:focus {
border-color: var(--fog-dark-accent, #94a3b8);
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.1);
}
.search-loading {
position: absolute;
right: 1rem;
color: var(--fog-text-light, #9ca3af);
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.search-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.25rem;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
max-height: 400px;
overflow-y: auto;
z-index: 1000;
}
:global(.dark) .search-results {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.search-result-item {
width: 100%;
padding: 0.75rem;
border: none;
background: transparent;
text-align: left;
cursor: pointer;
transition: background 0.2s;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
.search-result-item:last-child {
border-bottom: none;
}
.search-result-item:hover {
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .search-result-item {
border-bottom-color: var(--fog-dark-border, #374151);
}
:global(.dark) .search-result-item:hover {
background: var(--fog-dark-highlight, #374151);
}
.search-result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
.search-result-type {
font-size: 0.75rem;
font-weight: 600;
color: var(--fog-accent, #64748b);
text-transform: uppercase;
}
:global(.dark) .search-result-type {
color: var(--fog-dark-accent, #94a3b8);
}
.search-result-id {
font-size: 0.75rem;
font-family: monospace;
color: var(--fog-text-light, #9ca3af);
}
:global(.dark) .search-result-id {
color: var(--fog-dark-text-light, #6b7280);
}
.search-result-content {
font-size: 0.875rem;
color: var(--fog-text, #1f2937);
margin-bottom: 0.25rem;
line-height: 1.4;
}
:global(.dark) .search-result-content {
color: var(--fog-dark-text, #f9fafb);
}
.search-result-meta {
font-size: 0.75rem;
color: var(--fog-text-light, #9ca3af);
}
:global(.dark) .search-result-meta {
color: var(--fog-dark-text-light, #6b7280);
}
.search-no-results {
padding: 1rem;
text-align: center;
color: var(--fog-text-light, #9ca3af);
font-size: 0.875rem;
}
:global(.dark) .search-no-results {
color: var(--fog-dark-text-light, #6b7280);
}
</style>