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

<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>