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.
456 lines
13 KiB
456 lines
13 KiB
<script lang="ts"> |
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
|
import Icon from '../ui/Icon.svelte'; |
|
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 } from '../../types/kind-lookup.js'; |
|
import type { NostrEvent } from '../../types/nostr.js'; |
|
|
|
let searchQuery = $state(''); |
|
let searching = $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; |
|
|
|
// Decode bech32 identifiers |
|
function decodeIdentifier(input: string): { type: 'event' | 'profile' | null; id: string | null; pubkey: string | null } { |
|
const trimmed = input.trim(); |
|
|
|
// Check if it's a bech32 encoded format first (before hex) |
|
if (/^(note|nevent|naddr|npub|nprofile)1[a-z0-9]+$/i.test(trimmed)) { |
|
try { |
|
const decoded = nip19.decode(trimmed); |
|
|
|
if (decoded.type === 'note') { |
|
return { type: 'event', id: String(decoded.data), pubkey: null }; |
|
} else if (decoded.type === 'nevent') { |
|
if (decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) { |
|
return { type: 'event', id: String(decoded.data.id), pubkey: null }; |
|
} |
|
} else if (decoded.type === 'naddr') { |
|
// naddr requires fetching by kind+pubkey+d, but we can try to find it |
|
// For now, return null - we'll handle it in search |
|
return { type: null, id: null, pubkey: null }; |
|
} else if (decoded.type === 'npub') { |
|
return { type: 'profile', id: null, pubkey: String(decoded.data) }; |
|
} else if (decoded.type === 'nprofile') { |
|
if (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data) { |
|
return { type: 'profile', id: null, pubkey: String(decoded.data.pubkey) }; |
|
} |
|
} |
|
} catch (error) { |
|
console.error('Error decoding bech32:', error); |
|
} |
|
} |
|
|
|
// Check if it's a hex identifier (64 hex characters) |
|
// We can't distinguish between event IDs and pubkeys by format alone, |
|
// so we'll try event ID first, then fall back to pubkey if event not found |
|
if (/^[0-9a-f]{64}$/i.test(trimmed)) { |
|
// Default to event ID - if not found, we can try as pubkey in performSearch |
|
return { type: 'event', id: trimmed.toLowerCase(), pubkey: trimmed.toLowerCase() }; |
|
} |
|
|
|
return { type: null, id: null, pubkey: null }; |
|
} |
|
|
|
async function performSearch() { |
|
if (!searchQuery.trim()) { |
|
searchResults = []; |
|
showResults = false; |
|
return; |
|
} |
|
|
|
// Ensure nostrClient is initialized |
|
await nostrClient.initialize(); |
|
|
|
searching = true; |
|
searchResults = []; |
|
showResults = true; |
|
|
|
try { |
|
const query = searchQuery.trim(); |
|
|
|
// First, try to decode as specific identifier |
|
const decoded = decodeIdentifier(query); |
|
|
|
if (decoded.type === 'event' && decoded.id) { |
|
// Search for specific event ID |
|
let event: NostrEvent | undefined = await getEvent(decoded.id); |
|
|
|
if (!event) { |
|
// Not in cache, fetch from relays |
|
const relays = relayManager.getFeedReadRelays(); |
|
const events = await nostrClient.fetchEvents( |
|
[{ ids: [decoded.id] }], |
|
relays, |
|
{ useCache: false, cacheResults: true } |
|
); |
|
|
|
if (events.length > 0) { |
|
event = events[0]; |
|
await cacheEvent(event); |
|
} |
|
} |
|
|
|
if (event) { |
|
searchResults = [{ event, matchType: 'Event ID' }]; |
|
} else if (decoded.pubkey) { |
|
// Event not found, but we have a pubkey - try as profile |
|
handleProfileClick(decoded.pubkey); |
|
return; |
|
} |
|
} else if (decoded.type === 'profile' && decoded.pubkey) { |
|
// Search for profile - navigate directly to profile page |
|
handleProfileClick(decoded.pubkey); |
|
return; |
|
} else { |
|
// Text search in cached events |
|
const allCached: NostrEvent[] = []; |
|
|
|
// Search kind 1 events |
|
const kind1Events = await getEventsByKind(KIND.SHORT_TEXT_NOTE, 100); |
|
allCached.push(...kind1Events); |
|
|
|
// Search kind 11 events |
|
const kind11Events = await getEventsByKind(KIND.DISCUSSION_THREAD, 100); |
|
allCached.push(...kind11Events); |
|
|
|
// Filter by search query - search title, summary, and content |
|
const queryLower = query.toLowerCase(); |
|
const matches = allCached.filter(event => { |
|
// Search content |
|
const contentMatch = event.content.toLowerCase().includes(queryLower); |
|
|
|
// Search title tag |
|
const titleTag = event.tags.find(t => t[0] === 'title'); |
|
const titleMatch = titleTag?.[1]?.toLowerCase().includes(queryLower) || false; |
|
|
|
// Search summary tag |
|
const summaryTag = event.tags.find(t => t[0] === 'summary'); |
|
const summaryMatch = summaryTag?.[1]?.toLowerCase().includes(queryLower) || false; |
|
|
|
return contentMatch || titleMatch || summaryMatch; |
|
}); |
|
|
|
// Sort by relevance (exact matches first, then by created_at) |
|
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; |
|
}); |
|
|
|
searchResults = sorted.slice(0, 20).map(e => ({ event: e, matchType: 'Content' })); |
|
} |
|
} catch (error) { |
|
console.error('Search error:', error); |
|
} finally { |
|
searching = false; |
|
} |
|
} |
|
|
|
function handleSearchInput(e: Event) { |
|
const target = e.target as HTMLInputElement; |
|
const newValue = target.value; |
|
searchQuery = newValue; |
|
|
|
// Clear existing timeout |
|
if (searchTimeout) { |
|
clearTimeout(searchTimeout); |
|
} |
|
|
|
// Debounce search - wait 300ms after user stops typing |
|
if (newValue.trim()) { |
|
// Show loading indicator immediately |
|
searching = true; |
|
searchTimeout = setTimeout(() => { |
|
performSearch(); |
|
}, 300); |
|
} else { |
|
searchResults = []; |
|
showResults = false; |
|
searching = false; |
|
} |
|
} |
|
|
|
function handleKeyDown(e: KeyboardEvent) { |
|
if (e.key === 'Enter') { |
|
performSearch(); |
|
} else if (e.key === 'Escape') { |
|
showResults = false; |
|
searchQuery = ''; |
|
} |
|
} |
|
|
|
function handleResultClick(event: NostrEvent) { |
|
showResults = false; |
|
searchQuery = ''; |
|
// Store event in sessionStorage so the event page can use it without re-fetching |
|
sessionStorage.setItem('aitherboard_preloadedEvent', JSON.stringify(event)); |
|
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('.search-box-container')) { |
|
showResults = false; |
|
searchQuery = ''; |
|
} |
|
}; |
|
|
|
// Use capture phase to catch clicks before they bubble |
|
document.addEventListener('click', handleClickOutside, true); |
|
return () => { |
|
document.removeEventListener('click', handleClickOutside, true); |
|
}; |
|
} |
|
}); |
|
|
|
// Cleanup timeout on unmount |
|
$effect(() => { |
|
return () => { |
|
if (searchTimeout) { |
|
clearTimeout(searchTimeout); |
|
} |
|
}; |
|
}); |
|
</script> |
|
|
|
<div class="search-box-container"> |
|
<div class="search-input-wrapper"> |
|
<Icon name="search" size={18} class="search-icon" /> |
|
<input |
|
bind:this={searchInput} |
|
type="text" |
|
placeholder="Search events, profiles, or enter event ID (hex, note, nevent, npub, nprofile)..." |
|
value={searchQuery} |
|
oninput={handleSearchInput} |
|
onkeydown={handleKeyDown} |
|
class="search-input" |
|
aria-label="Search" |
|
/> |
|
{#if searching} |
|
<span class="search-loading">⟳</span> |
|
{/if} |
|
</div> |
|
|
|
{#if 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 showResults && !searching && searchQuery.trim()} |
|
<div class="search-results"> |
|
<div class="search-no-results">No results found</div> |
|
</div> |
|
{/if} |
|
</div> |
|
|
|
<style> |
|
.search-box-container { |
|
position: relative; |
|
width: 100%; |
|
max-width: 600px; |
|
} |
|
|
|
.search-input-wrapper { |
|
position: relative; |
|
display: flex; |
|
align-items: center; |
|
} |
|
|
|
.search-icon { |
|
position: absolute; |
|
left: 0.75rem; |
|
color: var(--fog-text-light, #52667a); |
|
pointer-events: none; |
|
z-index: 1; |
|
} |
|
|
|
:global(.dark) .search-icon { |
|
color: var(--fog-dark-text-light, #a8b8d0); |
|
} |
|
|
|
.search-input { |
|
width: 100%; |
|
padding: 0.75rem 1rem 0.75rem 2.5rem; |
|
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); |
|
} |
|
|
|
: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, #52667a); |
|
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, #52667a); |
|
} |
|
|
|
:global(.dark) .search-result-id { |
|
color: var(--fog-dark-text-light, #a8b8d0); |
|
} |
|
|
|
.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, #52667a); |
|
} |
|
|
|
:global(.dark) .search-result-meta { |
|
color: var(--fog-dark-text-light, #a8b8d0); |
|
} |
|
|
|
.search-no-results { |
|
padding: 1rem; |
|
text-align: center; |
|
color: var(--fog-text-light, #52667a); |
|
font-size: 0.875rem; |
|
} |
|
|
|
:global(.dark) .search-no-results { |
|
color: var(--fog-dark-text-light, #a8b8d0); |
|
} |
|
</style>
|
|
|