Browse Source

add /find addressables

add relay info to search results
search cache first
master
Silberengel 1 month ago
parent
commit
9154c4a0f5
  1. 4
      public/healthz.json
  2. 646
      src/lib/components/find/SearchAddressableEvents.svelte
  3. 112
      src/lib/components/layout/CacheBadge.svelte
  4. 105
      src/lib/components/layout/RelayBadge.svelte
  5. 229
      src/lib/components/layout/UnifiedSearch.svelte
  6. 45
      src/lib/services/nostr/nostr-client.ts
  7. 98
      src/routes/find/+page.svelte

4
public/healthz.json

@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
"status": "ok",
"service": "aitherboard",
"version": "0.2.0",
"buildTime": "2026-02-06T16:11:30.844Z",
"buildTime": "2026-02-06T16:38:51.661Z",
"gitCommit": "unknown",
"timestamp": 1770394290844
"timestamp": 1770395931661
}

646
src/lib/components/find/SearchAddressableEvents.svelte

@ -0,0 +1,646 @@ @@ -0,0 +1,646 @@
<script lang="ts">
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import ProfileBadge from '../layout/ProfileBadge.svelte';
import RelayBadge from '../layout/RelayBadge.svelte';
import CacheBadge from '../layout/CacheBadge.svelte';
import { getKindInfo } from '../../types/kind-lookup.js';
import { nip19 } from 'nostr-tools';
import { goto } from '$app/navigation';
import type { NostrEvent } from '../../types/nostr.js';
let searchQuery = $state('');
let searching = $state(false);
let results = $state<Array<{ event: NostrEvent; matchedTag: string; matchedValue: string; relevance: number; relay?: string }>>([]);
let timeoutId: ReturnType<typeof setTimeout> | null = $state(null);
// Tag search order (by relevance)
const searchTags = ['d', 'T', 'C', 'title', 'author', 'summary', 'description'];
const SEARCH_TIMEOUT = 10000; // 10 seconds
// Map to track results by event ID to avoid duplicates
const resultsMap = new Map<string, { event: NostrEvent; matchedTag: string; matchedValue: string; relevance: number; relay?: string }>();
// Map to track which relay each event came from
const eventRelayMap = new Map<string, string>();
function scoreEvent(event: NostrEvent, query: string): { tag: string; value: string; relevance: number } | null {
let bestMatch: { tag: string; value: string; relevance: number } | null = null;
// Check each tag in order of relevance
for (let i = 0; i < searchTags.length; i++) {
const tagName = searchTags[i];
const relevance = searchTags.length - i; // Higher number = more relevant
// Check both lowercase and original case tags
const tagVariants = [tagName, tagName.toLowerCase(), tagName.toUpperCase()];
for (const tagVariant of tagVariants) {
const tags = event.tags.filter(t => t[0] === tagVariant && t[1]);
for (const tag of tags) {
const value = tag[1]?.toLowerCase() || '';
// Check if query matches (exact match, contains, or word boundary match)
if (value === query) {
// Exact match - highest priority
if (!bestMatch || bestMatch.relevance < relevance * 10) {
bestMatch = { tag: tagVariant, value: tag[1] || '', relevance: relevance * 10 };
}
} else if (value.includes(query)) {
// Contains match
if (!bestMatch || bestMatch.relevance < relevance) {
bestMatch = { tag: tagVariant, value: tag[1] || '', relevance };
}
} else if (query.includes(' ') || query.includes('-')) {
// For multi-word queries, check if all words are present
const queryWords = query.split(/[\s-]+/).filter(w => w.length > 0);
const allWordsMatch = queryWords.every(word => value.includes(word));
if (allWordsMatch && (!bestMatch || bestMatch.relevance < relevance * 0.8)) {
bestMatch = { tag: tagVariant, value: tag[1] || '', relevance: relevance * 0.8 };
}
}
}
}
}
return bestMatch;
}
function addResult(event: NostrEvent, query: string, relay?: string) {
// Limit to 100 results total
if (resultsMap.size >= 100) {
return;
}
const bestMatch = scoreEvent(event, query);
if (bestMatch) {
// Track relay source
if (relay) {
eventRelayMap.set(event.id, relay);
}
const result = {
event,
matchedTag: bestMatch.tag,
matchedValue: bestMatch.value,
relevance: bestMatch.relevance,
relay: relay || eventRelayMap.get(event.id)
};
// Update or add result
resultsMap.set(event.id, result);
// Update results array (sorted by relevance, limit to 100)
results = Array.from(resultsMap.values())
.sort((a, b) => b.relevance - a.relevance)
.slice(0, 100);
}
}
async function search() {
if (!searchQuery.trim()) {
results = [];
resultsMap.clear();
return;
}
// Clear previous timeout
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
searching = true;
results = [];
resultsMap.clear();
eventRelayMap.clear();
const query = searchQuery.trim().toLowerCase();
// Set timeout
timeoutId = setTimeout(() => {
searching = false;
timeoutId = null;
}, SEARCH_TIMEOUT);
try {
// Fetch all events in kind range 30000-39999
const kinds: number[] = [];
for (let kind = 30000; kind <= 39999; kind++) {
kinds.push(kind);
}
const relays = relayManager.getProfileReadRelays();
// Use onUpdateWithRelay to process events as they arrive with relay info
await nostrClient.fetchEvents(
[{ kinds, limit: 100 }],
relays,
{
useCache: 'cache-first',
cacheResults: true,
timeout: SEARCH_TIMEOUT,
onUpdateWithRelay: (eventsWithRelay: Array<{ event: NostrEvent; relay: string }>) => {
// Process events incrementally as they arrive (limit to 100 total)
for (const { event, relay } of eventsWithRelay) {
if (!resultsMap.has(event.id) && resultsMap.size < 100) {
addResult(event, query, relay);
}
}
}
}
);
// Clear timeout if fetch completes before timeout
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
} catch (error) {
console.error('Error searching addressable events:', error);
} finally {
searching = false;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
search();
}
}
function getTagValue(event: NostrEvent, tagName: string): string | null {
const tagVariants = [tagName, tagName.toLowerCase(), tagName.toUpperCase()];
for (const variant of tagVariants) {
const tag = event.tags.find(t => t[0] === variant && t[1]);
if (tag && tag[1]) {
return tag[1];
}
}
return null;
}
function getAllTagValues(event: NostrEvent, tagName: string): string[] {
const tagVariants = [tagName, tagName.toLowerCase(), tagName.toUpperCase()];
const values: string[] = [];
for (const variant of tagVariants) {
const tags = event.tags.filter(t => t[0] === variant && t[1]);
for (const tag of tags) {
if (tag[1] && !values.includes(tag[1])) {
values.push(tag[1]);
}
}
}
return values;
}
function getImageTag(event: NostrEvent): string | null {
const imageTag = event.tags.find(t => t[0] === 'image' && t[1]);
return imageTag?.[1] || null;
}
function handleResultClick(event: NostrEvent) {
const dTag = getTagValue(event, 'd');
if (dTag) {
// Navigate to /replaceable/[d_tag] route
goto(`/replaceable/${encodeURIComponent(dTag)}`);
}
}
function handleResultKeydown(e: KeyboardEvent, event: NostrEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleResultClick(event);
}
}
function highlightText(text: string, query: string, isMatch: boolean): string {
if (!isMatch || !query) return text;
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
return text.replace(regex, '<mark>$1</mark>');
}
</script>
<div class="addressable-search">
<h2>Search Addressable Events</h2>
<p class="section-description">
Search through parameterized events with d-tags, such as long-form articles, wiki pages, or e-books/publications
</p>
<div class="search-container">
<input
type="text"
bind:value={searchQuery}
onkeydown={handleKeydown}
placeholder="e.g., jane eyre, jane-eyre, Charlotte Bronte..."
class="search-input"
disabled={searching}
/>
<button
class="search-button"
onclick={search}
disabled={searching || !searchQuery.trim()}
>
{searching ? 'Searching...' : 'Search'}
</button>
</div>
{#if results.length > 0}
<div class="results-container">
<h3>Results ({results.length})</h3>
<div class="results-grid">
{#each results as { event, matchedTag, matchedValue, relevance, relay }}
<div
class="result-card"
role="button"
tabindex="0"
onclick={() => handleResultClick(event)}
onkeydown={(e) => handleResultKeydown(e, event)}
>
{#if getImageTag(event)}
<div class="card-image">
<img src={getImageTag(event)} alt="" onerror={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
</div>
{/if}
<div class="card-content">
<div class="card-header">
<div class="card-header-left">
<ProfileBadge pubkey={event.pubkey} inline={true} />
{#if relay}
{#if relay === 'cache'}
<CacheBadge />
{:else}
<RelayBadge relayUrl={relay} />
{/if}
{/if}
</div>
<span class="kind-label">{getKindInfo(event.kind).description}</span>
</div>
<div class="card-metadata">
{#if getTagValue(event, 'd')}
<div class="metadata-item">
<strong class="metadata-label">d:</strong>
<span class="metadata-value" class:highlighted={matchedTag.toLowerCase() === 'd'}>
{@html highlightText(getTagValue(event, 'd') || '', searchQuery, matchedTag.toLowerCase() === 'd')}
</span>
</div>
{/if}
{#if getTagValue(event, 'T')}
<div class="metadata-item">
<strong class="metadata-label">T:</strong>
<span class="metadata-value" class:highlighted={matchedTag === 'T'}>
{@html highlightText(getTagValue(event, 'T') || '', searchQuery, matchedTag === 'T')}
</span>
</div>
{/if}
{#if getTagValue(event, 'C')}
<div class="metadata-item">
<strong class="metadata-label">C:</strong>
<span class="metadata-value" class:highlighted={matchedTag === 'C'}>
{@html highlightText(getTagValue(event, 'C') || '', searchQuery, matchedTag === 'C')}
</span>
</div>
{/if}
{#if getTagValue(event, 'title')}
<div class="metadata-item">
<strong class="metadata-label">title:</strong>
<span class="metadata-value" class:highlighted={matchedTag.toLowerCase() === 'title'}>
{@html highlightText(getTagValue(event, 'title') || '', searchQuery, matchedTag.toLowerCase() === 'title')}
</span>
</div>
{/if}
{#if getTagValue(event, 'author')}
<div class="metadata-item">
<strong class="metadata-label">author:</strong>
<span class="metadata-value" class:highlighted={matchedTag.toLowerCase() === 'author'}>
{@html highlightText(getTagValue(event, 'author') || '', searchQuery, matchedTag.toLowerCase() === 'author')}
</span>
</div>
{/if}
{#if getTagValue(event, 'summary')}
<div class="metadata-item">
<strong class="metadata-label">summary:</strong>
<span class="metadata-value" class:highlighted={matchedTag.toLowerCase() === 'summary'}>
{@html highlightText(getTagValue(event, 'summary') || '', searchQuery, matchedTag.toLowerCase() === 'summary')}
</span>
</div>
{/if}
{#if getTagValue(event, 'description')}
<div class="metadata-item">
<strong class="metadata-label">description:</strong>
<span class="metadata-value" class:highlighted={matchedTag.toLowerCase() === 'description'}>
{@html highlightText(getTagValue(event, 'description') || '', searchQuery, matchedTag.toLowerCase() === 'description')}
</span>
</div>
{/if}
</div>
<div class="card-footer">
<code class="event-id">{event.id}</code>
</div>
</div>
</div>
{/each}
</div>
</div>
{:else if !searching && searchQuery.trim()}
<div class="no-results">
No addressable events found matching "{searchQuery}"
</div>
{/if}
</div>
<style>
.addressable-search {
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
padding: 2rem;
background: var(--fog-post, #ffffff);
}
:global(.dark) .addressable-search {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.addressable-search h2 {
margin: 0 0 1.5rem 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #475569);
}
:global(.dark) .addressable-search h2 {
color: var(--fog-dark-text, #cbd5e1);
}
.section-description {
margin: 0 0 1.5rem 0;
color: var(--fog-text-light, #6b7280);
font-size: 0.875rem;
}
:global(.dark) .section-description {
color: var(--fog-dark-text-light, #9ca3af);
}
.search-container {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
}
.search-input {
flex: 1;
padding: 0.75rem;
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;
font-family: inherit;
}
:global(.dark) .search-input {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
.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:focus {
border-color: var(--fog-dark-accent, #94a3b8);
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.1);
}
.search-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.search-button {
padding: 0.75rem 1.5rem;
background: var(--fog-accent, #64748b);
color: white;
border: none;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
font-family: inherit;
white-space: nowrap;
transition: all 0.2s;
}
:global(.dark) .search-button {
background: var(--fog-dark-accent, #94a3b8);
color: var(--fog-dark-text, #1f2937);
}
.search-button:hover:not(:disabled) {
opacity: 0.9;
transform: translateY(-1px);
}
.search-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.results-container {
margin-top: 2rem;
}
.results-container h3 {
margin: 0 0 1.5rem 0;
font-size: 1rem;
font-weight: 500;
color: var(--fog-text-light, #6b7280);
}
:global(.dark) .results-container h3 {
color: var(--fog-dark-text-light, #9ca3af);
}
.results-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
.result-card {
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
background: var(--fog-post, #ffffff);
overflow: hidden;
cursor: pointer;
transition: all 0.2s;
}
:global(.dark) .result-card {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.result-card:hover {
border-color: var(--fog-accent, #64748b);
transform: translateY(-2px);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
:global(.dark) .result-card:hover {
border-color: var(--fog-dark-accent, #94a3b8);
}
.card-image {
width: 100%;
height: 200px;
overflow: hidden;
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .card-image {
background: var(--fog-dark-highlight, #374151);
}
.card-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.card-content {
padding: 1rem;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
gap: 0.5rem;
}
.card-header-left {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.kind-label {
font-size: 0.75rem;
color: var(--fog-text-light, #6b7280);
padding: 0.25rem 0.5rem;
background: var(--fog-highlight, #f3f4f6);
border-radius: 0.25rem;
}
:global(.dark) .kind-label {
color: var(--fog-dark-text-light, #9ca3af);
background: var(--fog-dark-highlight, #374151);
}
.card-metadata {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.metadata-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.metadata-label {
font-size: 0.75rem;
color: var(--fog-text-light, #6b7280);
font-weight: 600;
text-transform: uppercase;
}
:global(.dark) .metadata-label {
color: var(--fog-dark-text-light, #9ca3af);
}
.metadata-value {
font-size: 0.875rem;
color: var(--fog-text, #1f2937);
word-wrap: break-word;
}
:global(.dark) .metadata-value {
color: var(--fog-dark-text, #f9fafb);
}
.metadata-value.highlighted {
background: var(--fog-accent-light, #e2e8f0);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-weight: 500;
}
:global(.dark) .metadata-value.highlighted {
background: var(--fog-dark-accent-light, #475569);
}
.metadata-value :global(mark) {
background: var(--fog-accent, #64748b);
color: white;
padding: 0.125rem 0.25rem;
border-radius: 0.125rem;
}
:global(.dark) .metadata-value :global(mark) {
background: var(--fog-dark-accent, #94a3b8);
color: var(--fog-dark-text, #1f2937);
}
.card-footer {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .card-footer {
border-color: var(--fog-dark-border, #374151);
}
.event-id {
font-size: 0.75rem;
color: var(--fog-text-light, #6b7280);
font-family: monospace;
word-break: break-all;
}
:global(.dark) .event-id {
color: var(--fog-dark-text-light, #9ca3af);
}
.no-results {
padding: 2rem;
text-align: center;
color: var(--fog-text-light, #6b7280);
font-size: 0.875rem;
}
:global(.dark) .no-results {
color: var(--fog-dark-text-light, #9ca3af);
}
</style>

112
src/lib/components/layout/CacheBadge.svelte

@ -0,0 +1,112 @@ @@ -0,0 +1,112 @@
<script lang="ts">
import { goto } from '$app/navigation';
interface Props {
clickable?: boolean; // If true, make badge clickable to navigate to cache page
}
let { clickable = true }: Props = $props();
function handleClick(e: MouseEvent) {
if (clickable) {
e.stopPropagation();
goto('/cache');
}
}
function handleKeydown(e: KeyboardEvent) {
if (clickable && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
e.stopPropagation();
goto('/cache');
}
}
</script>
{#if clickable}
<button
type="button"
class="cache-badge clickable"
onclick={handleClick}
onkeydown={handleKeydown}
title="View cache page"
tabindex="0"
>
<img src="/favicon.ico" alt="Cache" class="cache-icon" />
<span class="cache-label">Cache</span>
</button>
{:else}
<span
class="cache-badge"
title="From cache"
tabindex="-1"
>
<img src="/favicon.ico" alt="Cache" class="cache-icon" />
<span class="cache-label">Cache</span>
</span>
{/if}
<style>
.cache-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--fog-text-light, #6b7280);
background: var(--fog-highlight, #f3f4f6);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
}
:global(.dark) .cache-badge {
color: var(--fog-dark-text-light, #9ca3af);
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-border, #475569);
}
.cache-icon {
width: 0.875rem;
height: 0.875rem;
object-fit: contain;
flex-shrink: 0;
}
.cache-label {
font-family: monospace;
}
.cache-badge.clickable {
cursor: pointer;
transition: all 0.2s;
}
.cache-badge.clickable:hover {
background: var(--fog-accent, #64748b);
color: white;
border-color: var(--fog-accent, #64748b);
transform: translateY(-1px);
}
:global(.dark) .cache-badge.clickable:hover {
background: var(--fog-dark-accent, #94a3b8);
color: var(--fog-dark-text, #1f2937);
border-color: var(--fog-dark-accent, #94a3b8);
}
.cache-badge.clickable:focus {
outline: none;
box-shadow: 0 0 0 2px var(--fog-accent, #64748b);
}
:global(.dark) .cache-badge.clickable:focus {
box-shadow: 0 0 0 2px var(--fog-dark-accent, #94a3b8);
}
button.cache-badge {
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
</style>

105
src/lib/components/layout/RelayBadge.svelte

@ -0,0 +1,105 @@ @@ -0,0 +1,105 @@
<script lang="ts">
import { goto } from '$app/navigation';
interface Props {
relayUrl: string;
clickable?: boolean; // If true, make badge clickable to navigate to relay feed
}
let { relayUrl, clickable = true }: Props = $props();
function getRelayDomain(url: string): string {
try {
// Remove protocol (wss://, ws://, https://, http://)
const withoutProtocol = url.replace(/^wss?:\/\//, '').replace(/^https?:\/\//, '');
// Remove port if present
const domain = withoutProtocol.split(':')[0];
// Remove path if present
return domain.split('/')[0];
} catch {
return url;
}
}
function handleClick(e: MouseEvent) {
if (clickable) {
e.stopPropagation();
const domain = getRelayDomain(relayUrl);
goto(`/feed/relay/${encodeURIComponent(domain)}`);
}
}
const displayName = $derived(getRelayDomain(relayUrl));
</script>
{#if clickable}
<button
type="button"
class="relay-badge clickable"
onclick={handleClick}
title={`View feed from ${displayName}`}
>
{displayName}
</button>
{:else}
<span
class="relay-badge"
title={displayName}
>
{displayName}
</span>
{/if}
<style>
.relay-badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--fog-text-light, #6b7280);
background: var(--fog-highlight, #f3f4f6);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
font-family: monospace;
}
:global(.dark) .relay-badge {
color: var(--fog-dark-text-light, #9ca3af);
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-border, #475569);
}
.relay-badge.clickable {
cursor: pointer;
transition: all 0.2s;
}
.relay-badge.clickable:hover {
background: var(--fog-accent, #64748b);
color: white;
border-color: var(--fog-accent, #64748b);
transform: translateY(-1px);
}
:global(.dark) .relay-badge.clickable:hover {
background: var(--fog-dark-accent, #94a3b8);
color: var(--fog-dark-text, #1f2937);
border-color: var(--fog-dark-accent, #94a3b8);
}
.relay-badge.clickable:focus {
outline: none;
box-shadow: 0 0 0 2px var(--fog-accent, #64748b);
}
:global(.dark) .relay-badge.clickable:focus {
box-shadow: 0 0 0 2px var(--fog-dark-accent, #94a3b8);
}
button.relay-badge {
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
</style>

229
src/lib/components/layout/UnifiedSearch.svelte

@ -17,7 +17,7 @@ @@ -17,7 +17,7 @@
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[] }) => void; // Callback for search results (events and profile pubkeys, and relays used)
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)
}
@ -37,6 +37,15 @@ @@ -37,6 +37,15 @@
// For collecting results when hideDropdownResults is true
let foundEvents: NostrEvent[] = [];
let foundProfiles: string[] = [];
// Map to track which relay each event came from
const eventRelayMap = new Map<string, string>();
// Clear results at start of search
function clearResults() {
foundEvents = [];
foundProfiles = [];
eventRelayMap.clear();
}
// 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 });
@ -128,6 +137,9 @@ @@ -128,6 +137,9 @@
return;
}
// Clear results at start of search
clearResults();
await nostrClient.initialize();
searching = true;
resolving = true;
@ -168,7 +180,15 @@ @@ -168,7 +180,15 @@
if (mode === 'search') {
if (hideDropdownResults && onSearchResults) {
foundEvents = [event];
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed });
// 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;
@ -219,7 +239,15 @@ @@ -219,7 +239,15 @@
}
foundEvents = Array.from(allEvents.values());
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed });
// 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;
@ -293,7 +321,15 @@ @@ -293,7 +321,15 @@
}
foundEvents = Array.from(allEvents.values());
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed });
// 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;
@ -362,7 +398,15 @@ @@ -362,7 +398,15 @@
}
foundEvents = Array.from(allEvents.values());
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed });
// 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;
@ -423,7 +467,15 @@ @@ -423,7 +467,15 @@
}
foundEvents = Array.from(allEvents.values());
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed });
// 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;
@ -493,7 +545,15 @@ @@ -493,7 +545,15 @@
if (mode === 'search') {
if (hideDropdownResults && onSearchResults) {
foundEvents = [event];
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed });
// 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;
@ -516,34 +576,84 @@ @@ -516,34 +576,84 @@
// 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
// Search each allowed kind with onUpdate for incremental results
for (const kind of effectiveKinds) {
const events = await nostrClient.fetchEvents(
await nostrClient.fetchEvents(
[{ kinds: [kind], limit: 100 }],
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
{
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);
}
}
}
}
);
// Filter by text content
const matches = events.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;
});
allEvents.push(...matches);
}
} else {
// Text search in cached events (title, summary, content)
@ -554,12 +664,14 @@ @@ -554,12 +664,14 @@
const kindEvents = await getEventsByKind(selectedKind, 100);
allCached.push(...kindEvents);
} else {
// Search all kinds we handle
// 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, 50);
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
}
@ -576,34 +688,49 @@ @@ -576,34 +688,49 @@
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;
});
}
// Sort and limit results
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;
onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed });
} else {
searchResults = limitedResults.map(e => ({ event: e, matchType: 'Content' }));
showResults = true;
// 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

45
src/lib/services/nostr/nostr-client.ts

@ -22,6 +22,7 @@ interface FetchOptions { @@ -22,6 +22,7 @@ interface FetchOptions {
useCache?: boolean | 'cache-first' | 'relay-first'; // Cache strategy: true/'cache-first' = check cache first (default), 'relay-first' = query relays first then cache fallback, false = no cache
cacheResults?: boolean;
onUpdate?: (events: NostrEvent[]) => void;
onUpdateWithRelay?: (events: Array<{ event: NostrEvent; relay: string }>) => void; // Callback with relay information for each event
timeout?: number;
priority?: 'high' | 'medium' | 'low'; // Priority level: high for critical UI (comments), low for background (reactions, profiles)
caller?: string; // Optional caller identifier for logging (e.g., "topics/[name]/+page.svelte")
@ -928,6 +929,7 @@ class NostrClient { @@ -928,6 +929,7 @@ class NostrClient {
events: Map<string, NostrEvent>,
timeout: number,
onUpdate?: (events: NostrEvent[]) => void,
onUpdateWithRelay?: (events: Array<{ event: NostrEvent; relay: string }>) => void,
priority: 'high' | 'medium' | 'low' = 'medium'
): Promise<void> {
return new Promise((resolve) => {
@ -954,7 +956,7 @@ class NostrClient { @@ -954,7 +956,7 @@ class NostrClient {
this.totalActiveRequests++;
// Make the request
this.makeRelayRequest(relayUrl, filters, events, timeout, onUpdate)
this.makeRelayRequest(relayUrl, filters, events, timeout, onUpdate, onUpdateWithRelay)
.finally(() => {
const current = this.activeRequestsPerRelay.get(relayUrl) || 0;
if (current > 0) {
@ -985,7 +987,8 @@ class NostrClient { @@ -985,7 +987,8 @@ class NostrClient {
filters: Filter[],
events: Map<string, NostrEvent>,
timeout: number,
onUpdate?: (events: NostrEvent[]) => void
onUpdate?: (events: NostrEvent[]) => void,
onUpdateWithRelay?: (events: Array<{ event: NostrEvent; relay: string }>) => void
): Promise<void> {
const relay = this.relays.get(relayUrl);
if (!relay || !this.checkAndCleanupRelay(relayUrl)) {
@ -1126,12 +1129,19 @@ class NostrClient { @@ -1126,12 +1129,19 @@ class NostrClient {
}
// Stream event directly to onUpdate callback immediately
if (onUpdate && !resolved) {
if ((onUpdate || onUpdateWithRelay) && !resolved) {
try {
const filtered = filterEvents([event]);
const zapFiltered = filtered.filter(e => !client.shouldFilterZapReceipt(e));
if (zapFiltered.length > 0) {
onUpdate(zapFiltered);
// Call onUpdate if provided (backward compatible)
if (onUpdate) {
onUpdate(zapFiltered);
}
// Call onUpdateWithRelay if provided (includes relay info)
if (onUpdateWithRelay) {
onUpdateWithRelay(zapFiltered.map(e => ({ event: e, relay: relayUrl })));
}
}
} catch (error) {
// Ignore errors from onUpdate callback
@ -1475,6 +1485,27 @@ class NostrClient { @@ -1475,6 +1485,27 @@ class NostrClient {
// Return cached data immediately for fast page loads
console.log(`[nostr-client] Fetch complete: ${cachedEvents.length} from cache (instant), fetching from relays in background [${filterDesc}] from [${relayDesc}]`);
// Notify about cached events via onUpdateWithRelay callback (if provided)
// Use "cache" as the relay identifier for cached events
if (options.onUpdateWithRelay) {
try {
options.onUpdateWithRelay(cachedEvents.map(event => ({ event, relay: 'cache' })));
} catch (error) {
// Ignore errors from onUpdateWithRelay callback
console.debug('[nostr-client] Error in onUpdateWithRelay callback for cached events:', error);
}
}
// Also call regular onUpdate callback for backward compatibility
if (onUpdate) {
try {
onUpdate(cachedEvents);
} catch (error) {
// Ignore errors from onUpdate callback
console.debug('[nostr-client] Error in onUpdate callback for cached events:', error);
}
}
// Fetch from relays in background to enhance/update results
// Don't await this - let it run in background
this.fetchFromRelays(filters, relays, {
@ -1485,6 +1516,7 @@ class NostrClient { @@ -1485,6 +1516,7 @@ class NostrClient {
onUpdate(freshEvents);
}
},
onUpdateWithRelay: options.onUpdateWithRelay,
timeout: relayTimeout,
priority: options.priority
}).then((freshEvents) => {
@ -1598,6 +1630,7 @@ class NostrClient { @@ -1598,6 +1630,7 @@ class NostrClient {
const relayEvents = await this.fetchFromRelays(filters, relays, {
cacheResults: cacheResults,
onUpdate,
onUpdateWithRelay: options.onUpdateWithRelay,
timeout: relayTimeout,
priority: options.priority
});
@ -1718,7 +1751,7 @@ class NostrClient { @@ -1718,7 +1751,7 @@ class NostrClient {
private async fetchFromRelays(
filters: Filter[],
relays: string[],
options: { cacheResults: boolean; onUpdate?: (events: NostrEvent[]) => void; timeout: number; priority?: 'high' | 'medium' | 'low' }
options: { cacheResults: boolean; onUpdate?: (events: NostrEvent[]) => void; onUpdateWithRelay?: (events: Array<{ event: NostrEvent; relay: string }>) => void; timeout: number; priority?: 'high' | 'medium' | 'low' }
): Promise<NostrEvent[]> {
const timeout = options.timeout || config.relayTimeout;
@ -1847,7 +1880,7 @@ class NostrClient { @@ -1847,7 +1880,7 @@ class NostrClient {
const MAX_EVENTS_IN_MAP = 1000;
for (const relayUrl of connectedRelays) {
await this.throttledRelayRequest(relayUrl, filters, events, timeout, options.onUpdate, priority);
await this.throttledRelayRequest(relayUrl, filters, events, timeout, options.onUpdate, options.onUpdateWithRelay, priority);
// Limit Map size - remove oldest if over limit
if (events.size > MAX_EVENTS_IN_MAP) {

98
src/routes/find/+page.svelte

@ -3,6 +3,9 @@ @@ -3,6 +3,9 @@
import UnifiedSearch from '../../lib/components/layout/UnifiedSearch.svelte';
import FeedPost from '../../lib/modules/feed/FeedPost.svelte';
import ProfileBadge from '../../lib/components/layout/ProfileBadge.svelte';
import RelayBadge from '../../lib/components/layout/RelayBadge.svelte';
import CacheBadge from '../../lib/components/layout/CacheBadge.svelte';
import SearchAddressableEvents from '../../lib/components/find/SearchAddressableEvents.svelte';
import { KIND, KIND_LOOKUP } from '../../lib/types/kind-lookup.js';
import { nostrClient } from '../../lib/services/nostr/nostr-client.js';
import type { NostrEvent } from '../../lib/types/nostr.js';
@ -12,7 +15,10 @@ @@ -12,7 +15,10 @@
let selectedKindString = $state<string>('');
let unifiedSearchComponent: { triggerSearch: () => void; getFilterResult: () => { type: 'event' | 'pubkey' | 'text' | null; value: string | null; kind?: number | null } } | null = $state(null);
let searchResults = $state<{ events: NostrEvent[]; profiles: string[]; relays?: string[] }>({ events: [], profiles: [] });
// Map to track which relay each event came from
const eventRelayMap = new Map<string, string>();
let searching = $state(false);
let searchTimeout: ReturnType<typeof setTimeout> | null = $state(null);
// Sync selectedKindString with selectedKind
$effect(() => {
@ -38,14 +44,40 @@ @@ -38,14 +44,40 @@
function handleSearch() {
if (unifiedSearchComponent) {
// Clear previous timeout
if (searchTimeout) {
clearTimeout(searchTimeout);
searchTimeout = null;
}
searching = true;
unifiedSearchComponent.triggerSearch();
// Set 10 second timeout
searchTimeout = setTimeout(() => {
searching = false;
searchTimeout = null;
}, 10000);
}
}
function handleSearchResults(results: { events: NostrEvent[]; profiles: string[]; relays?: string[] }) {
function handleSearchResults(results: { events: NostrEvent[]; profiles: string[]; relays?: string[]; eventRelays?: Map<string, string> }) {
// Update results incrementally as they arrive
searchResults = results;
searching = false;
// Update eventRelayMap with relay information from results
if (results.eventRelays) {
for (const [eventId, relay] of results.eventRelays) {
eventRelayMap.set(eventId, relay);
}
}
// Clear timeout when results arrive (search is complete)
if (searchTimeout) {
clearTimeout(searchTimeout);
searchTimeout = null;
searching = false;
}
}
// Get all kinds for dropdown (sorted by number)
@ -130,9 +162,20 @@ @@ -130,9 +162,20 @@
<h3>Events ({searchResults.events.length})</h3>
<div class="event-results">
{#each searchResults.events as event}
<a href="/event/{event.id}" class="event-result-card">
<FeedPost post={event} fullView={false} />
</a>
<div class="event-result-card">
<a href="/event/{event.id}" class="event-result-link">
<FeedPost post={event} fullView={false} />
</a>
{#if eventRelayMap.has(event.id)}
<div class="event-relay-badge">
{#if eventRelayMap.get(event.id) === 'cache'}
<CacheBadge />
{:else}
<RelayBadge relayUrl={eventRelayMap.get(event.id)!} />
{/if}
</div>
{/if}
</div>
{/each}
</div>
</div>
@ -145,6 +188,10 @@ @@ -145,6 +188,10 @@
</div>
</section>
{/if}
<section class="find-section">
<SearchAddressableEvents />
</section>
</div>
</div>
</main>
@ -401,14 +448,13 @@ @@ -401,14 +448,13 @@
}
.event-result-card {
display: block;
display: flex;
flex-direction: column;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
overflow: hidden;
transition: all 0.2s;
text-decoration: none;
color: inherit;
}
:global(.dark) .event-result-card {
@ -426,6 +472,42 @@ @@ -426,6 +472,42 @@
border-color: var(--fog-dark-accent, #94a3b8);
}
.event-result-link {
display: block;
text-decoration: none;
color: inherit;
flex: 1;
}
.event-relay-badge {
padding: 0.5rem 1rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .event-relay-badge {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-highlight, #374151);
}
.event-result-link {
display: block;
text-decoration: none;
color: inherit;
flex: 1;
}
.event-relay-badge {
padding: 0.5rem 1rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .event-relay-badge {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-highlight, #374151);
}
.no-results {
padding: 2rem;
text-align: center;

Loading…
Cancel
Save