diff --git a/src/lib/components/find/AdvancedSearch.svelte b/src/lib/components/find/AdvancedSearch.svelte new file mode 100644 index 0000000..09462b0 --- /dev/null +++ b/src/lib/components/find/AdvancedSearch.svelte @@ -0,0 +1,672 @@ + + + + + diff --git a/src/lib/components/find/NormalSearch.svelte b/src/lib/components/find/NormalSearch.svelte new file mode 100644 index 0000000..4124e01 --- /dev/null +++ b/src/lib/components/find/NormalSearch.svelte @@ -0,0 +1,277 @@ + + + + + diff --git a/src/lib/components/layout/Header.svelte b/src/lib/components/layout/Header.svelte index 5cb53d7..76e3336 100644 --- a/src/lib/components/layout/Header.svelte +++ b/src/lib/components/layout/Header.svelte @@ -58,6 +58,9 @@ {#if isLoggedIn} /Write {/if} + {#if isLoggedIn} + /Bookmarks + {/if} /Find {#if isLoggedIn} /RSS diff --git a/src/lib/components/layout/UnifiedSearch.svelte b/src/lib/components/layout/UnifiedSearch.svelte index daf3e77..1ea56d1 100644 --- a/src/lib/components/layout/UnifiedSearch.svelte +++ b/src/lib/components/layout/UnifiedSearch.svelte @@ -810,9 +810,11 @@ } // 6. Anything else is a full-text search + // Try NIP-50 search first (relay-side full-text search), then fallback to client-side filtering if (mode === 'search') { let allEvents: NostrEvent[] = []; const uniqueEventIds = new Set(); + const queryLower = query.toLowerCase(); // Helper to add event and notify if needed const addEvent = (event: NostrEvent, relay?: string) => { @@ -827,7 +829,6 @@ // 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'); @@ -860,11 +861,87 @@ } }; + const relays = relayManager.getAllAvailableRelays(); + + // Try NIP-50 search first (relay-side full-text search) + // Split query into words for NIP-50 search + const searchTerms = query.trim().split(/\s+/).filter(term => term.length > 0); + + if (searchTerms.length > 0) { + try { + // Build NIP-50 filter with search terms + const nip50Filter: any = { + search: searchTerms, + limit: 100 + }; + + // Add kind filter if specified + if (effectiveKinds && effectiveKinds.length > 0) { + nip50Filter.kinds = effectiveKinds; + } + + // Try NIP-50 search on relays + const nip50Events = await nostrClient.fetchEvents( + [nip50Filter], + relays, + { + useCache: 'cache-first', + cacheResults: true, + timeout: 10000, + onUpdateWithRelay: (eventsWithRelay: Array<{ event: NostrEvent; relay: string }>) => { + // Add events as they arrive (NIP-50 relays already filtered them) + for (const { event, relay } of eventsWithRelay) { + addEvent(event, relay); + } + } + } + ); + + // If we got results from NIP-50, use them (relays that support it already filtered) + if (nip50Events.length > 0) { + for (const event of nip50Events) { + addEvent(event, relays[0] || 'unknown'); + } + + // Sort and return NIP-50 results + 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; + }); + + const limitedResults = Array.from(new Map(sorted.map(e => [e.id, e])).values()).slice(0, 100); + + if (hideDropdownResults && onSearchResults) { + foundEvents = limitedResults; + const foundEventRelays = new Map(); + 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 (NIP-50)' })); + showResults = true; + } + + searching = false; + resolving = false; + return; + } + } catch (error) { + // NIP-50 search failed or not supported, fall through to client-side search + console.debug('NIP-50 search not available or failed, falling back to client-side search:', error); + } + } + + // Fallback to client-side filtering if NIP-50 didn't return results or isn't supported // 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( @@ -914,7 +991,6 @@ } } - const queryLower = query.toLowerCase(); allEvents = allCached.filter(event => { const contentMatch = event.content.toLowerCase().includes(queryLower); @@ -935,7 +1011,6 @@ // 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; diff --git a/src/lib/modules/profiles/PaymentAddresses.svelte b/src/lib/modules/profiles/PaymentAddresses.svelte index 468926e..de63f7c 100644 --- a/src/lib/modules/profiles/PaymentAddresses.svelte +++ b/src/lib/modules/profiles/PaymentAddresses.svelte @@ -26,22 +26,21 @@ async function loadPaymentAddresses() { loading = true; try { - const config = nostrClient.getConfig(); + const { getRecentCachedEvents } = await import('../../services/cache/event-cache.js'); + const { relayManager } = await import('../../services/nostr/relay-manager.js'); + const { config } = await import('../../services/nostr/config.js'); + + // Try cache first (fast - instant display) + const cachedEvents = await getRecentCachedEvents([KIND.PAYMENT_ADDRESSES], 60 * 60 * 1000, 100); // 1 hour cache + const cachedPaymentEvent = cachedEvents.find(e => e.pubkey === pubkey); - // Fetch kind 10133 (payment targets) - const paymentEvents = await nostrClient.fetchEvents( - [{ kinds: [KIND.PAYMENT_ADDRESSES], authors: [pubkey], limit: 1 }], - [...config.defaultRelays, ...config.profileRelays], - { useCache: true, cacheResults: true } - ); - const addresses: Array<{ type: string; address: string }> = []; const seen = new Set(); - // Extract from kind 10133 - if (paymentEvents.length > 0) { - paymentEvent = paymentEvents[0]; - for (const tag of paymentEvent.tags) { + // Extract from cached event if available + if (cachedPaymentEvent) { + paymentEvent = cachedPaymentEvent; + for (const tag of cachedPaymentEvent.tags) { if (tag[0] === 'payto' && tag[1] && tag[2]) { const key = `${tag[1]}:${tag[2]}`; if (!seen.has(key)) { @@ -50,9 +49,11 @@ } } } + paymentAddresses = addresses; + loading = false; // Show cached content immediately } - // Also get lud16 from profile (kind 0) + // Also get lud16 from profile (kind 0) - this is usually cached const profile = await fetchProfile(pubkey); if (profile && profile.lud16) { for (const lud16 of profile.lud16) { @@ -62,9 +63,49 @@ seen.add(key); } } + paymentAddresses = addresses; } - paymentAddresses = addresses; + // Fetch from relays in background (progressive enhancement) if not in cache + if (!cachedPaymentEvent) { + const relays = relayManager.getProfileReadRelays(); + const paymentEvents = await nostrClient.fetchEvents( + [{ kinds: [KIND.PAYMENT_ADDRESSES], authors: [pubkey], limit: 1 }], + relays, + { useCache: 'cache-first', cacheResults: true, timeout: config.shortTimeout } + ); + + // Update with fresh data if available + if (paymentEvents.length > 0) { + paymentEvent = paymentEvents[0]; + const freshAddresses: Array<{ type: string; address: string }> = []; + const freshSeen = new Set(); + + // Extract from kind 10133 + for (const tag of paymentEvents[0].tags) { + if (tag[0] === 'payto' && tag[1] && tag[2]) { + const key = `${tag[1]}:${tag[2]}`; + if (!freshSeen.has(key)) { + freshAddresses.push({ type: tag[1], address: tag[2] }); + freshSeen.add(key); + } + } + } + + // Merge with lud16 from profile + if (profile && profile.lud16) { + for (const lud16 of profile.lud16) { + const key = `lightning:${lud16}`; + if (!freshSeen.has(key)) { + freshAddresses.push({ type: 'lightning', address: lud16 }); + freshSeen.add(key); + } + } + } + + paymentAddresses = freshAddresses; + } + } } catch (error) { console.error('Error loading payment addresses:', error); } finally { diff --git a/src/lib/services/cache/event-archive.ts b/src/lib/services/cache/event-archive.ts index 78759e5..d60ee60 100644 --- a/src/lib/services/cache/event-archive.ts +++ b/src/lib/services/cache/event-archive.ts @@ -202,8 +202,10 @@ export async function archiveOldEvents( ): Promise { try { const db = await getDB(); - const now = Date.now(); - const cutoffTime = now - threshold; + // created_at is in seconds (Unix timestamp), threshold is in milliseconds + // Convert threshold to seconds for comparison + const nowSeconds = Math.floor(Date.now() / 1000); + const cutoffTimeSeconds = nowSeconds - Math.floor(threshold / 1000); // Find old events const tx = db.transaction('events', 'readonly'); @@ -211,7 +213,7 @@ export async function archiveOldEvents( const oldEvents: CachedEvent[] = []; for await (const cursor of index.iterate()) { - if (cursor.value.created_at < cutoffTime) { + if (cursor.value.created_at < cutoffTimeSeconds) { oldEvents.push(cursor.value as CachedEvent); } } @@ -331,9 +333,13 @@ export async function clearOldArchivedEvents(olderThan: number): Promise const tx = db.transaction('eventArchive', 'readwrite'); const index = tx.store.index('created_at'); + // olderThan is in milliseconds, but created_at is in seconds + // Convert to seconds for comparison + const olderThanSeconds = Math.floor(olderThan / 1000); + const idsToDelete: string[] = []; for await (const cursor of index.iterate()) { - if (cursor.value.created_at < olderThan) { + if (cursor.value.created_at < olderThanSeconds) { idsToDelete.push(cursor.value.id); } } @@ -373,6 +379,7 @@ export async function getArchiveStats(): Promise<{ for await (const cursor of tx.store.iterate()) { totalArchived++; totalSize += cursor.value.compressed.length; + // created_at is in seconds, keep it as-is for consistency if (!oldestArchived || cursor.value.created_at < oldestArchived) { oldestArchived = cursor.value.created_at; } @@ -393,3 +400,93 @@ export async function getArchiveStats(): Promise<{ }; } } + +/** + * Recover all archived events back to main cache (unarchive all) + */ +export async function recoverAllArchivedEvents(): Promise { + try { + const db = await getDB(); + const tx = db.transaction(['events', 'eventArchive'], 'readwrite'); + + const archivedEvents: CachedEvent[] = []; + + // Get all archived events + for await (const cursor of tx.objectStore('eventArchive').iterate()) { + const archived = cursor.value as ArchivedEvent; + // Decompress + const decompressed = await decompressJSON(archived.compressed); + archivedEvents.push(decompressed as CachedEvent); + } + + await tx.done; + + if (archivedEvents.length === 0) return 0; + + // Restore in batches to avoid blocking + const BATCH_SIZE = 50; + let recovered = 0; + + for (let i = 0; i < archivedEvents.length; i += BATCH_SIZE) { + const batch = archivedEvents.slice(i, i + BATCH_SIZE); + + // Use a new transaction for each batch + const batchTx = db.transaction(['events', 'eventArchive'], 'readwrite'); + + // Restore events to main cache + await Promise.all(batch.map(event => batchTx.objectStore('events').put(event))); + + // Remove from archive + await Promise.all(batch.map(event => batchTx.objectStore('eventArchive').delete(event.id))); + + await batchTx.done; + recovered += batch.length; + + // Yield to browser between batches to avoid blocking + await new Promise(resolve => setTimeout(resolve, 0)); + } + + return recovered; + } catch (error) { + // Recovery failed (non-critical) + return 0; + } +} + +/** + * Find and recover an archived event by ID + */ +export async function recoverArchivedEventById(id: string): Promise { + try { + const archived = await getArchivedEvent(id); + if (!archived) return false; + + const db = await getDB(); + const tx = db.transaction(['events', 'eventArchive'], 'readwrite'); + + // Put back in main events store + await tx.objectStore('events').put(archived); + + // Remove from archive + await tx.objectStore('eventArchive').delete(id); + + await tx.done; + return true; + } catch (error) { + // Recovery failed (non-critical) + return false; + } +} + +/** + * Check if an event is archived by ID + */ +export async function isEventArchived(id: string): Promise { + try { + const db = await getDB(); + const archived = await db.get('eventArchive', id); + return archived !== undefined; + } catch (error) { + return false; + } +} diff --git a/src/lib/services/cache/event-cache.ts b/src/lib/services/cache/event-cache.ts index 9cdad67..8432e3c 100644 --- a/src/lib/services/cache/event-cache.ts +++ b/src/lib/services/cache/event-cache.ts @@ -90,7 +90,7 @@ export async function getEvent(id: string): Promise { } /** - * Get events by kind + * Get events by kind (checks both main cache and archive) */ export async function getEventsByKind(kind: number, limit?: number): Promise { try { @@ -104,8 +104,28 @@ export async function getEventsByKind(kind: number, limit?: number): Promise(); + + // Add main cache events first + for (const event of events) { + eventMap.set(event.id, event as CachedEvent); + } + + // Add archived events (won't overwrite if already in main cache) + for (const event of archivedEvents) { + if (!eventMap.has(event.id)) { + eventMap.set(event.id, event); + } + } + // Sort and limit after fetching - const sorted = events.sort((a, b) => b.created_at - a.created_at); + const allEvents = Array.from(eventMap.values()); + const sorted = allEvents.sort((a, b) => b.created_at - a.created_at); return limit ? sorted.slice(0, limit) : sorted; } catch (error) { // Cache read failed (non-critical) @@ -114,7 +134,7 @@ export async function getEventsByKind(kind: number, limit?: number): Promise { try { @@ -128,8 +148,28 @@ export async function getEventsByPubkey(pubkey: string, limit?: number): Promise await tx.done; + // Also check archive for events by this pubkey + const { getArchivedEventsByPubkey } = await import('./event-archive.js'); + const archivedEvents = await getArchivedEventsByPubkey(pubkey, limit); + + // Combine and deduplicate (archive might have events that are also in main cache) + const eventMap = new Map(); + + // Add main cache events first + for (const event of events) { + eventMap.set(event.id, event as CachedEvent); + } + + // Add archived events (won't overwrite if already in main cache) + for (const event of archivedEvents) { + if (!eventMap.has(event.id)) { + eventMap.set(event.id, event); + } + } + // Sort and limit after fetching - const sorted = events.sort((a, b) => b.created_at - a.created_at); + const allEvents = Array.from(eventMap.values()); + const sorted = allEvents.sort((a, b) => b.created_at - a.created_at); return limit ? sorted.slice(0, limit) : sorted; } catch (error) { // Cache read failed (non-critical) @@ -292,7 +332,7 @@ export async function getCachedReactionsForEvents(eventIds: string[]): Promise { + const eventIdTag = reaction.tags.find((t: string[]) => { const tagName = t[0]; return (tagName === 'e' || tagName === 'E') && t[1] && eventIds.includes(t[1]); }); diff --git a/src/lib/types/nostr.ts b/src/lib/types/nostr.ts index 25d79a2..34bea58 100644 --- a/src/lib/types/nostr.ts +++ b/src/lib/types/nostr.ts @@ -22,4 +22,5 @@ export interface NostrFilter { since?: number; until?: number; limit?: number; + search?: string[]; // NIP-50: Full-text search terms } diff --git a/src/routes/bookmarks/+page.svelte b/src/routes/bookmarks/+page.svelte index 0f76095..40ec405 100644 --- a/src/routes/bookmarks/+page.svelte +++ b/src/routes/bookmarks/+page.svelte @@ -1,1002 +1,164 @@
-
-

/Bookmarks

- +
+
+

/Bookmarks

+
+ {#if loading}
-

Loading bookmarks and highlights...

+

Loading bookmarks...

{:else if error}
-

{error}

+

{error}

- {:else if allItems.length === 0} + {:else if events.length === 0}
-

No bookmarks or highlights found.

+

No bookmarks yet. Bookmark events to see them here.

{:else} -
-
-
- - -
- {#if currentUserPubkey} -
- -
- {/if} -
- -
-
- - {#if totalPages > 1 && !searchResults.events.length && !searchResults.profiles.length} - - {/if} -
- - {#if searchResults.events.length > 0 || searchResults.profiles.length > 0} -
-

Search Results

- - {#if searchResults.profiles.length > 0} -
-

Profiles

-
- {#each searchResults.profiles as pubkey} - - - - {/each} -
-
- {/if} - - {#if searchResults.events.length > 0} -
-

Events ({searchResults.events.length})

-
- {#each paginatedSearchEvents as event} - {#if event.kind === KIND.HIGHLIGHTED_ARTICLE} -
- goto(`/event/${e.id}`)} /> -
- {:else} - - - - {/if} - {/each} -
-
- {/if} -
- {:else} -
-

- Showing {paginatedItems.length} of {filteredItems.length} items - {#if allItems.length >= maxTotalItems} - (limited to {maxTotalItems}) - {/if} - {#if filterResult.value} - (filtered) - {/if} -

-
- -
- {#each paginatedItems as item (item.event.id)} -
- {#if item.type === 'highlight'} - goto(`/event/${event.id}`)} /> - {:else} -
- -
- - {/if} +
+ {#each paginatedEvents as event} +
+
{/each}
- - {#if totalPages > 1} - - {/if} + + {#if events.length > ITEMS_PER_PAGE} + {/if} {/if}
diff --git a/src/routes/cache/+page.svelte b/src/routes/cache/+page.svelte index 6485b29..2e4aaf2 100644 --- a/src/routes/cache/+page.svelte +++ b/src/routes/cache/+page.svelte @@ -14,7 +14,7 @@ import { getCacheStats, getAllCachedEvents, clearAllCache, clearCacheByKind, clearCacheByKinds, clearCacheByDate, deleteEventById, type CacheStats } from '../../lib/services/cache/cache-manager.js'; import { cacheEvent } from '../../lib/services/cache/event-cache.js'; import type { CachedEvent } from '../../lib/services/cache/event-cache.js'; - import { getArchiveStats, clearOldArchivedEvents } from '../../lib/services/cache/event-archive.js'; + import { getArchiveStats, clearOldArchivedEvents, recoverAllArchivedEvents, recoverArchivedEventById, isEventArchived } from '../../lib/services/cache/event-archive.js'; import { triggerArchive } from '../../lib/services/cache/archive-scheduler.js'; import { KIND, getKindInfo } from '../../lib/types/kind-lookup.js'; import { nip19 } from 'nostr-tools'; @@ -42,6 +42,9 @@ let offset = $state(0); const PAGE_SIZE = 50; let archiving = $state(false); + let recovering = $state(false); + let eventIdToRecover = $state(''); + let recoveringEvent = $state(false); // Filters let selectedKind = $state(null); @@ -107,6 +110,64 @@ } } + async function handleRecoverAllArchived() { + if (!confirm('Are you sure you want to recover all archived events? This will restore them to the main cache.')) { + return; + } + + recovering = true; + try { + const recovered = await recoverAllArchivedEvents(); + await loadStats(); + await loadArchiveStats(); + await loadEvents(true); + alert(`Recovered ${recovered} archived events. They are now back in the main cache.`); + } catch (error) { + alert('Failed to recover archived events'); + } finally { + recovering = false; + } + } + + async function handleRecoverEventById() { + if (!eventIdToRecover.trim()) { + alert('Please enter an event ID'); + return; + } + + // Decode bech32 if needed + const hexId = decodeEventIdToHex(eventIdToRecover.trim()); + if (!hexId) { + alert('Invalid event ID format'); + return; + } + + // Check if event is archived + const isArchived = await isEventArchived(hexId); + if (!isArchived) { + alert('Event not found in archive. It may already be in the main cache or may not exist.'); + return; + } + + recoveringEvent = true; + try { + const recovered = await recoverArchivedEventById(hexId); + if (recovered) { + await loadStats(); + await loadArchiveStats(); + await loadEvents(true); + alert('Event recovered successfully. It is now back in the main cache.'); + eventIdToRecover = ''; + } else { + alert('Failed to recover event'); + } + } catch (error) { + alert('Failed to recover event'); + } finally { + recoveringEvent = false; + } + } + async function loadEvents(reset = false) { if (reset) { offset = 0; @@ -621,7 +682,7 @@

{#if archiveStats.oldestArchived}

- Oldest Archived: {new Date(archiveStats.oldestArchived).toLocaleDateString()} + Oldest Archived: {new Date(archiveStats.oldestArchived * 1000).toLocaleDateString()}

{/if} @@ -633,6 +694,13 @@ > {archiving ? 'Archiving...' : 'Archive Events Older Than 30 Days'} + +
+

Recover Individual Event

+
+ e.key === 'Enter' && handleRecoverEventById()} + /> + +
+

+ Enter an event ID to check if it's archived and recover it to the main cache. +

+

Archived events are compressed to save space but remain accessible. They are automatically decompressed when needed. @@ -873,6 +963,38 @@ color: var(--fog-dark-text-light, #a8b8d0); } + .recover-event-section { + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid var(--fog-border, #e5e7eb); + } + + :global(.dark) .recover-event-section { + border-top-color: var(--fog-dark-border, #475569); + } + + .recover-event-section h3 { + margin: 0 0 1rem 0; + font-size: 1.1em; + color: var(--fog-text, #1f2937); + } + + :global(.dark) .recover-event-section h3 { + color: var(--fog-dark-text, #f9fafb); + } + + .recover-event-input-group { + display: flex; + gap: 0.75rem; + align-items: flex-start; + flex-wrap: wrap; + } + + .recover-event-input-group .filter-input { + flex: 1; + min-width: 250px; + } + .bulk-actions-section, .events-section { margin-bottom: 2rem; diff --git a/src/routes/find/+page.svelte b/src/routes/find/+page.svelte index 051e611..d8ac3d2 100644 --- a/src/routes/find/+page.svelte +++ b/src/routes/find/+page.svelte @@ -1,25 +1,26 @@