From 5513fc9850c7d9bb0d78b6fa60ba70973ff8bd08 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 12 Oct 2025 22:06:49 +0200 Subject: [PATCH 01/16] perf(relays): local-first queries with short timeouts; fallback to remote if needed --- src/services/articleService.ts | 35 +++++-- src/services/bookmarkService.ts | 33 +++++-- src/services/contactService.ts | 29 ++++-- src/services/exploreService.ts | 40 ++++++-- src/services/highlightService.ts | 153 +++++++++++++++++++++++-------- src/utils/helpers.ts | 31 +++++++ 6 files changed, 255 insertions(+), 66 deletions(-) diff --git a/src/services/articleService.ts b/src/services/articleService.ts index d1d8d320..a2b94f14 100644 --- a/src/services/articleService.ts +++ b/src/services/articleService.ts @@ -1,10 +1,11 @@ import { RelayPool, completeOnEose } from 'applesauce-relay' -import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs' +import { lastValueFrom, race, takeUntil, timer, toArray } from 'rxjs' import { nip19 } from 'nostr-tools' import { AddressPointer } from 'nostr-tools/nip19' import { NostrEvent } from 'nostr-tools' import { Helpers } from 'applesauce-core' import { RELAYS } from '../config/relays' +import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers' import { UserSettings } from './settingsService' import { rebroadcastEvents } from './rebroadcastService' @@ -98,9 +99,11 @@ export async function fetchArticleByNaddr( const pointer = decoded.data as AddressPointer // Define relays to query - prefer relays from naddr, fallback to configured relays (including local) - const relays = pointer.relays && pointer.relays.length > 0 + const baseRelays = pointer.relays && pointer.relays.length > 0 ? pointer.relays : RELAYS + const orderedRelays = prioritizeLocalRelays(baseRelays) + const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays) // Fetch the article event const filter = { @@ -109,12 +112,28 @@ export async function fetchArticleByNaddr( '#d': [pointer.identifier] } - // Use applesauce relay pool pattern - const events = await lastValueFrom( - relayPool - .req(relays, filter) - .pipe(completeOnEose(), takeUntil(timer(10000)), toArray()) - ) + // Local-first: try local relays quickly, then fallback to remote if no result + let events = [] as NostrEvent[] + if (localRelays.length > 0) { + try { + events = await lastValueFrom( + relayPool + .req(localRelays, filter) + .pipe(completeOnEose(), takeUntil(timer(1200)), toArray()) + ) + } catch { + events = [] + } + } + + if (events.length === 0) { + // Fallback: query all relays, but still time-box + events = await lastValueFrom( + relayPool + .req(orderedRelays, filter) + .pipe(completeOnEose(), takeUntil(timer(6000)), toArray()) + ) + } if (events.length === 0) { throw new Error('Article not found') diff --git a/src/services/bookmarkService.ts b/src/services/bookmarkService.ts index e147a59b..84198162 100644 --- a/src/services/bookmarkService.ts +++ b/src/services/bookmarkService.ts @@ -16,6 +16,7 @@ import { Bookmark } from '../types/bookmarks' import { collectBookmarksFromEvents } from './bookmarkProcessing.ts' import { UserSettings } from './settingsService' import { rebroadcastEvents } from './rebroadcastService' +import { prioritizeLocalRelays } from '../utils/helpers' @@ -31,14 +32,30 @@ export const fetchBookmarks = async ( throw new Error('Invalid account object provided') } // Get relay URLs from the pool - const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) + const relayUrls = prioritizeLocalRelays(Array.from(relayPool.relays.values()).map(relay => relay.url)) // Fetch bookmark events - NIP-51 standards, legacy formats, and web bookmarks (NIP-B0) console.log('πŸ” Fetching bookmark events from relays:', relayUrls) - const rawEvents = await lastValueFrom( - relayPool - .req(relayUrls, { kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] }) - .pipe(completeOnEose(), takeUntil(timer(20000)), toArray()) - ) + // Try local-first quickly, then full set fallback + let rawEvents = [] as NostrEvent[] + const localRelays = relayUrls.filter(url => url.includes('localhost') || url.includes('127.0.0.1')) + if (localRelays.length > 0) { + try { + rawEvents = await lastValueFrom( + relayPool + .req(localRelays, { kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] }) + .pipe(completeOnEose(), takeUntil(timer(1200)), toArray()) + ) + } catch { + rawEvents = [] + } + } + if (rawEvents.length === 0) { + rawEvents = await lastValueFrom( + relayPool + .req(relayUrls, { kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] }) + .pipe(completeOnEose(), takeUntil(timer(6000)), toArray()) + ) + } console.log('πŸ“Š Raw events fetched:', rawEvents.length, 'events') // Rebroadcast bookmark events to local/all relays based on settings @@ -103,7 +120,9 @@ export const fetchBookmarks = async ( if (noteIds.length > 0) { try { const events = await lastValueFrom( - relayPool.req(relayUrls, { ids: noteIds }).pipe(completeOnEose(), takeUntil(timer(10000)), toArray()) + relayPool + .req(relayUrls, { ids: noteIds }) + .pipe(completeOnEose(), takeUntil(timer(4000)), toArray()) ) idToEvent = new Map(events.map((e: NostrEvent) => [e.id, e])) } catch (error) { diff --git a/src/services/contactService.ts b/src/services/contactService.ts index b7d64c19..dc69b3b2 100644 --- a/src/services/contactService.ts +++ b/src/services/contactService.ts @@ -1,5 +1,6 @@ import { RelayPool, completeOnEose } from 'applesauce-relay' import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs' +import { prioritizeLocalRelays } from '../utils/helpers' /** * Fetches the contact list (follows) for a specific user @@ -12,15 +13,31 @@ export const fetchContacts = async ( pubkey: string ): Promise> => { try { - const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) + const relayUrls = prioritizeLocalRelays(Array.from(relayPool.relays.values()).map(relay => relay.url)) console.log('πŸ” Fetching contacts (kind 3) for user:', pubkey) - const events = await lastValueFrom( - relayPool - .req(relayUrls, { kinds: [3], authors: [pubkey] }) - .pipe(completeOnEose(), takeUntil(timer(10000)), toArray()) - ) + // Local-first quick attempt + const localRelays = relayUrls.filter(url => url.includes('localhost') || url.includes('127.0.0.1')) + let events = [] as any[] + if (localRelays.length > 0) { + try { + events = await lastValueFrom( + relayPool + .req(localRelays, { kinds: [3], authors: [pubkey] }) + .pipe(completeOnEose(), takeUntil(timer(1200)), toArray()) + ) + } catch { + events = [] + } + } + if (events.length === 0) { + events = await lastValueFrom( + relayPool + .req(relayUrls, { kinds: [3], authors: [pubkey] }) + .pipe(completeOnEose(), takeUntil(timer(6000)), toArray()) + ) + } console.log('πŸ“Š Contact events fetched:', events.length) diff --git a/src/services/exploreService.ts b/src/services/exploreService.ts index 38b7d0ce..795a9087 100644 --- a/src/services/exploreService.ts +++ b/src/services/exploreService.ts @@ -1,5 +1,6 @@ import { RelayPool, completeOnEose } from 'applesauce-relay' import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs' +import { prioritizeLocalRelays } from '../utils/helpers' import { NostrEvent } from 'nostr-tools' import { Helpers } from 'applesauce-core' @@ -34,15 +35,36 @@ export const fetchBlogPostsFromAuthors = async ( console.log('πŸ“š Fetching blog posts (kind 30023) from', pubkeys.length, 'authors') - const events = await lastValueFrom( - relayPool - .req(relayUrls, { - kinds: [30023], - authors: pubkeys, - limit: 100 // Fetch up to 100 recent posts - }) - .pipe(completeOnEose(), takeUntil(timer(15000)), toArray()) - ) + const prioritized = prioritizeLocalRelays(relayUrls) + const localRelays = prioritized.filter(url => url.includes('localhost') || url.includes('127.0.0.1')) + + let events = [] as NostrEvent[] + if (localRelays.length > 0) { + try { + events = await lastValueFrom( + relayPool + .req(localRelays, { + kinds: [30023], + authors: pubkeys, + limit: 100 + }) + .pipe(completeOnEose(), takeUntil(timer(1200)), toArray()) + ) + } catch { + events = [] + } + } + if (events.length === 0) { + events = await lastValueFrom( + relayPool + .req(prioritized, { + kinds: [30023], + authors: pubkeys, + limit: 100 + }) + .pipe(completeOnEose(), takeUntil(timer(6000)), toArray()) + ) + } console.log('πŸ“Š Blog post events fetched:', events.length) diff --git a/src/services/highlightService.ts b/src/services/highlightService.ts index 65f62908..fd07adf8 100644 --- a/src/services/highlightService.ts +++ b/src/services/highlightService.ts @@ -3,6 +3,7 @@ import { lastValueFrom, takeUntil, timer, tap, toArray } from 'rxjs' import { NostrEvent } from 'nostr-tools' import { Highlight } from '../types/highlights' import { RELAYS } from '../config/relays' +import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers' import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor' import { UserSettings } from './settingsService' import { rebroadcastEvents } from './rebroadcastService' @@ -34,32 +35,39 @@ export const fetchHighlightsForArticle = async ( return eventToHighlight(event) } + // Local-first relay ordering + const orderedRelays = prioritizeLocalRelays(RELAYS) + const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays) + // Query for highlights that reference this article via the 'a' tag - const aTagEvents = await lastValueFrom( - relayPool - .req(RELAYS, { kinds: [9802], '#a': [articleCoordinate] }) - .pipe( - onlyEvents(), - tap((event: NostrEvent) => { - const highlight = processEvent(event) - if (highlight && onHighlight) { - onHighlight(highlight) - } - }), - completeOnEose(), - takeUntil(timer(10000)), - toArray() + let aTagEvents: NostrEvent[] = [] + if (localRelays.length > 0) { + try { + aTagEvents = await lastValueFrom( + relayPool + .req(localRelays, { kinds: [9802], '#a': [articleCoordinate] }) + .pipe( + onlyEvents(), + tap((event: NostrEvent) => { + const highlight = processEvent(event) + if (highlight && onHighlight) { + onHighlight(highlight) + } + }), + completeOnEose(), + takeUntil(timer(1200)), + toArray() + ) ) - ) - - console.log('πŸ“Š Highlights via a-tag:', aTagEvents.length) - - // If we have an event ID, also query for highlights that reference via the 'e' tag - let eTagEvents: NostrEvent[] = [] - if (eventId) { - eTagEvents = await lastValueFrom( + } catch { + aTagEvents = [] + } + } + + if (aTagEvents.length === 0) { + aTagEvents = await lastValueFrom( relayPool - .req(RELAYS, { kinds: [9802], '#e': [eventId] }) + .req(orderedRelays, { kinds: [9802], '#a': [articleCoordinate] }) .pipe( onlyEvents(), tap((event: NostrEvent) => { @@ -69,10 +77,59 @@ export const fetchHighlightsForArticle = async ( } }), completeOnEose(), - takeUntil(timer(10000)), + takeUntil(timer(6000)), toArray() ) ) + } + + console.log('πŸ“Š Highlights via a-tag:', aTagEvents.length) + + // If we have an event ID, also query for highlights that reference via the 'e' tag + let eTagEvents: NostrEvent[] = [] + if (eventId) { + // e-tag query local-first as well + if (localRelays.length > 0) { + try { + eTagEvents = await lastValueFrom( + relayPool + .req(localRelays, { kinds: [9802], '#e': [eventId] }) + .pipe( + onlyEvents(), + tap((event: NostrEvent) => { + const highlight = processEvent(event) + if (highlight && onHighlight) { + onHighlight(highlight) + } + }), + completeOnEose(), + takeUntil(timer(1200)), + toArray() + ) + ) + } catch { + eTagEvents = [] + } + } + + if (eTagEvents.length === 0) { + eTagEvents = await lastValueFrom( + relayPool + .req(orderedRelays, { kinds: [9802], '#e': [eventId] }) + .pipe( + onlyEvents(), + tap((event: NostrEvent) => { + const highlight = processEvent(event) + if (highlight && onHighlight) { + onHighlight(highlight) + } + }), + completeOnEose(), + takeUntil(timer(6000)), + toArray() + ) + ) + } console.log('πŸ“Š Highlights via e-tag:', eTagEvents.length) } @@ -118,19 +175,43 @@ export const fetchHighlightsForUrl = async ( console.log('πŸ” Fetching highlights (kind 9802) for URL:', url) const seenIds = new Set() - const rawEvents = await lastValueFrom( - relayPool - .req(RELAYS, { kinds: [9802], '#r': [url] }) - .pipe( - onlyEvents(), - tap((event: NostrEvent) => { - seenIds.add(event.id) - }), - completeOnEose(), - takeUntil(timer(10000)), - toArray() + const orderedRelaysUrl = prioritizeLocalRelays(RELAYS) + const { local: localRelaysUrl } = partitionRelays(orderedRelaysUrl) + let rawEvents: NostrEvent[] = [] + if (localRelaysUrl.length > 0) { + try { + rawEvents = await lastValueFrom( + relayPool + .req(localRelaysUrl, { kinds: [9802], '#r': [url] }) + .pipe( + onlyEvents(), + tap((event: NostrEvent) => { + seenIds.add(event.id) + }), + completeOnEose(), + takeUntil(timer(1200)), + toArray() + ) ) - ) + } catch { + rawEvents = [] + } + } + if (rawEvents.length === 0) { + rawEvents = await lastValueFrom( + relayPool + .req(orderedRelaysUrl, { kinds: [9802], '#r': [url] }) + .pipe( + onlyEvents(), + tap((event: NostrEvent) => { + seenIds.add(event.id) + }), + completeOnEose(), + takeUntil(timer(6000)), + toArray() + ) + ) + } console.log('πŸ“Š Highlights for URL:', rawEvents.length) diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index da3c12ef..b162f9d0 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -63,3 +63,34 @@ export const hasRemoteRelay = (relayUrls: string[]): boolean => { return relayUrls.some(url => !isLocalRelay(url)) } +/** + * Splits relay URLs into local and remote groups + */ +export const partitionRelays = ( + relayUrls: string[] +): { local: string[]; remote: string[] } => { + const local: string[] = [] + const remote: string[] = [] + for (const url of relayUrls) { + if (isLocalRelay(url)) local.push(url) + else remote.push(url) + } + return { local, remote } +} + +/** + * Returns relays ordered with local first while keeping uniqueness + */ +export const prioritizeLocalRelays = (relayUrls: string[]): string[] => { + const { local, remote } = partitionRelays(relayUrls) + const seen = new Set() + const out: string[] = [] + for (const url of [...local, ...remote]) { + if (!seen.has(url)) { + seen.add(url) + out.push(url) + } + } + return out +} + From ca95d6c7f48119d80b8cbe5a48923e040a3a4a71 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 12 Oct 2025 22:33:46 +0200 Subject: [PATCH 02/16] perf(ui): stream results to UI; show cached/local immediately (articles, highlights, explore) --- src/components/Explore.tsx | 16 +++++++++++++++- src/hooks/useExternalUrlLoader.ts | 16 +++++++++++++++- src/services/articleService.ts | 18 ++++++++++++++---- src/services/exploreService.ts | 29 +++++++++++++++++++---------- src/services/highlightService.ts | 9 +++++++++ 5 files changed, 72 insertions(+), 16 deletions(-) diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index fbbcd18e..e5087ac1 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -46,7 +46,21 @@ const Explore: React.FC = ({ relayPool }) => { const posts = await fetchBlogPostsFromAuthors( relayPool, Array.from(contacts), - relayUrls + relayUrls, + (post) => { + // Stream posts as we get them + setBlogPosts((prev) => { + const exists = prev.some(p => p.event.id === post.event.id) + if (exists) return prev + const next = [...prev, post] + // Keep sorted by published or created_at + return next.sort((a, b) => { + const timeA = a.published || a.event.created_at + const timeB = b.published || b.event.created_at + return timeB - timeA + }) + }) + } ) if (posts.length === 0) { diff --git a/src/hooks/useExternalUrlLoader.ts b/src/hooks/useExternalUrlLoader.ts index aa00c352..a123cb23 100644 --- a/src/hooks/useExternalUrlLoader.ts +++ b/src/hooks/useExternalUrlLoader.ts @@ -57,7 +57,21 @@ export function useExternalUrlLoader({ // Check if fetchHighlightsForUrl exists, otherwise skip if (typeof fetchHighlightsForUrl === 'function') { - const highlightsList = await fetchHighlightsForUrl(relayPool, url) + const seen = new Set() + const highlightsList = await fetchHighlightsForUrl( + relayPool, + url, + (highlight) => { + if (seen.has(highlight.id)) return + seen.add(highlight.id) + setHighlights((prev) => { + if (prev.some(h => h.id === highlight.id)) return prev + const next = [...prev, highlight] + return next.sort((a, b) => b.created_at - a.created_at) + }) + } + ) + // Ensure final list is sorted and contains all items setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at)) console.log(`πŸ“Œ Found ${highlightsList.length} highlights for URL`) } else { diff --git a/src/services/articleService.ts b/src/services/articleService.ts index a2b94f14..16ce4280 100644 --- a/src/services/articleService.ts +++ b/src/services/articleService.ts @@ -1,5 +1,5 @@ -import { RelayPool, completeOnEose } from 'applesauce-relay' -import { lastValueFrom, race, takeUntil, timer, toArray } from 'rxjs' +import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay' +import { lastValueFrom, take, takeUntil, timer, toArray } from 'rxjs' import { nip19 } from 'nostr-tools' import { AddressPointer } from 'nostr-tools/nip19' import { NostrEvent } from 'nostr-tools' @@ -119,7 +119,12 @@ export async function fetchArticleByNaddr( events = await lastValueFrom( relayPool .req(localRelays, filter) - .pipe(completeOnEose(), takeUntil(timer(1200)), toArray()) + .pipe( + onlyEvents(), + take(1), + takeUntil(timer(1200)), + toArray() + ) ) } catch { events = [] @@ -131,7 +136,12 @@ export async function fetchArticleByNaddr( events = await lastValueFrom( relayPool .req(orderedRelays, filter) - .pipe(completeOnEose(), takeUntil(timer(6000)), toArray()) + .pipe( + onlyEvents(), + take(1), + takeUntil(timer(6000)), + toArray() + ) ) } diff --git a/src/services/exploreService.ts b/src/services/exploreService.ts index 795a9087..e2d5c2e2 100644 --- a/src/services/exploreService.ts +++ b/src/services/exploreService.ts @@ -25,7 +25,8 @@ export interface BlogPostPreview { export const fetchBlogPostsFromAuthors = async ( relayPool: RelayPool, pubkeys: string[], - relayUrls: string[] + relayUrls: string[], + onPost?: (post: BlogPostPreview) => void ): Promise => { try { if (pubkeys.length === 0) { @@ -48,7 +49,11 @@ export const fetchBlogPostsFromAuthors = async ( authors: pubkeys, limit: 100 }) - .pipe(completeOnEose(), takeUntil(timer(1200)), toArray()) + .pipe( + completeOnEose(), + takeUntil(timer(1200)), + toArray() + ) ) } catch { events = [] @@ -84,14 +89,18 @@ export const fetchBlogPostsFromAuthors = async ( // Convert to blog post previews and sort by published date (most recent first) const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values()) - .map(event => ({ - event, - title: getArticleTitle(event) || 'Untitled', - summary: getArticleSummary(event), - image: getArticleImage(event), - published: getArticlePublished(event), - author: event.pubkey - })) + .map(event => { + const post: BlogPostPreview = { + event, + title: getArticleTitle(event) || 'Untitled', + summary: getArticleSummary(event), + image: getArticleImage(event), + published: getArticlePublished(event), + author: event.pubkey + } + if (onPost) onPost(post) + return post + }) .sort((a, b) => { const timeA = a.published || a.event.created_at const timeB = b.published || b.event.created_at diff --git a/src/services/highlightService.ts b/src/services/highlightService.ts index fd07adf8..dccb617b 100644 --- a/src/services/highlightService.ts +++ b/src/services/highlightService.ts @@ -169,6 +169,7 @@ export const fetchHighlightsForArticle = async ( export const fetchHighlightsForUrl = async ( relayPool: RelayPool, url: string, + onHighlight?: (highlight: Highlight) => void, settings?: UserSettings ): Promise => { try { @@ -187,6 +188,10 @@ export const fetchHighlightsForUrl = async ( onlyEvents(), tap((event: NostrEvent) => { seenIds.add(event.id) + if (onHighlight) { + const h = eventToHighlight(event) + onHighlight(h) + } }), completeOnEose(), takeUntil(timer(1200)), @@ -205,6 +210,10 @@ export const fetchHighlightsForUrl = async ( onlyEvents(), tap((event: NostrEvent) => { seenIds.add(event.id) + if (onHighlight) { + const h = eventToHighlight(event) + onHighlight(h) + } }), completeOnEose(), takeUntil(timer(6000)), From a1305fba8160397af54ccf59656c1ea304b11ff5 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 12 Oct 2025 22:38:29 +0200 Subject: [PATCH 03/16] fix(explore): always query remote relays after local; stream merge into UI --- src/services/exploreService.ts | 89 ++++++++++++++++++++++------------ 1 file changed, 57 insertions(+), 32 deletions(-) diff --git a/src/services/exploreService.ts b/src/services/exploreService.ts index e2d5c2e2..d283f3e3 100644 --- a/src/services/exploreService.ts +++ b/src/services/exploreService.ts @@ -1,6 +1,6 @@ import { RelayPool, completeOnEose } from 'applesauce-relay' import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs' -import { prioritizeLocalRelays } from '../utils/helpers' +import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers' import { NostrEvent } from 'nostr-tools' import { Helpers } from 'applesauce-core' @@ -37,15 +37,42 @@ export const fetchBlogPostsFromAuthors = async ( console.log('πŸ“š Fetching blog posts (kind 30023) from', pubkeys.length, 'authors') const prioritized = prioritizeLocalRelays(relayUrls) - const localRelays = prioritized.filter(url => url.includes('localhost') || url.includes('127.0.0.1')) + const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized) - let events = [] as NostrEvent[] + // Deduplicate replaceable events by keeping the most recent version + // Group by author + d-tag identifier + const uniqueEvents = new Map() + + const processEvents = (incoming: NostrEvent[]) => { + for (const event of incoming) { + const dTag = event.tags.find(t => t[0] === 'd')?.[1] || '' + const key = `${event.pubkey}:${dTag}` + const existing = uniqueEvents.get(key) + if (!existing || event.created_at > existing.created_at) { + uniqueEvents.set(key, event) + // Emit as we incorporate + if (onPost) { + const post: BlogPostPreview = { + event, + title: getArticleTitle(event) || 'Untitled', + summary: getArticleSummary(event), + image: getArticleImage(event), + published: getArticlePublished(event), + author: event.pubkey + } + onPost(post) + } + } + } + } + + // Phase 1: local relays fast path if (localRelays.length > 0) { try { - events = await lastValueFrom( + const localEvents = await lastValueFrom( relayPool - .req(localRelays, { - kinds: [30023], + .req(localRelays, { + kinds: [30023], authors: pubkeys, limit: 100 }) @@ -55,37 +82,35 @@ export const fetchBlogPostsFromAuthors = async ( toArray() ) ) + processEvents(localEvents) } catch { - events = [] + // ignore } } - if (events.length === 0) { - events = await lastValueFrom( - relayPool - .req(prioritized, { - kinds: [30023], - authors: pubkeys, - limit: 100 - }) - .pipe(completeOnEose(), takeUntil(timer(6000)), toArray()) - ) - } - - console.log('πŸ“Š Blog post events fetched:', events.length) - - // Deduplicate replaceable events by keeping the most recent version - // Group by author + d-tag identifier - const uniqueEvents = new Map() - - for (const event of events) { - const dTag = event.tags.find(t => t[0] === 'd')?.[1] || '' - const key = `${event.pubkey}:${dTag}` - - const existing = uniqueEvents.get(key) - if (!existing || event.created_at > existing.created_at) { - uniqueEvents.set(key, event) + + // Phase 2: always query remote relays to fill in missing content + if (remoteRelays.length > 0) { + try { + const remoteEvents = await lastValueFrom( + relayPool + .req(remoteRelays, { + kinds: [30023], + authors: pubkeys, + limit: 100 + }) + .pipe( + completeOnEose(), + takeUntil(timer(6000)), + toArray() + ) + ) + processEvents(remoteEvents) + } catch { + // ignore } } + + console.log('πŸ“Š Blog post events fetched (unique):', uniqueEvents.size) // Convert to blog post previews and sort by published date (most recent first) const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values()) From e3debfa5df41cd404d481aba3c3c1009f38a7b71 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 12 Oct 2025 22:42:24 +0200 Subject: [PATCH 04/16] perf(local-first): apply local-first then remote pattern across services (titles, bookmarks, highlights) --- src/services/articleTitleResolver.ts | 35 ++++++++++---- src/services/bookmarkService.ts | 51 +++++++++++++++----- src/services/highlightService.ts | 71 +++++++++++++++++++++------- 3 files changed, 118 insertions(+), 39 deletions(-) diff --git a/src/services/articleTitleResolver.ts b/src/services/articleTitleResolver.ts index f990bb35..acc7ba76 100644 --- a/src/services/articleTitleResolver.ts +++ b/src/services/articleTitleResolver.ts @@ -1,9 +1,10 @@ -import { RelayPool, completeOnEose } from 'applesauce-relay' -import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs' +import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay' +import { lastValueFrom, take, takeUntil, timer, toArray } from 'rxjs' import { nip19 } from 'nostr-tools' import { AddressPointer } from 'nostr-tools/nip19' import { Helpers } from 'applesauce-core' import { RELAYS } from '../config/relays' +import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers' const { getArticleTitle } = Helpers @@ -25,9 +26,11 @@ export async function fetchArticleTitle( const pointer = decoded.data as AddressPointer // Define relays to query - const relays = pointer.relays && pointer.relays.length > 0 + const baseRelays = pointer.relays && pointer.relays.length > 0 ? pointer.relays : RELAYS + const orderedRelays = prioritizeLocalRelays(baseRelays) + const { local: localRelays } = partitionRelays(orderedRelays) // Fetch the article event const filter = { @@ -36,11 +39,27 @@ export async function fetchArticleTitle( '#d': [pointer.identifier] } - const events = await lastValueFrom( - relayPool - .req(relays, filter) - .pipe(completeOnEose(), takeUntil(timer(5000)), toArray()) - ) + // Try to get the first event quickly from local relays + let events = [] as any[] + if (localRelays.length > 0) { + try { + events = await lastValueFrom( + relayPool + .req(localRelays, filter) + .pipe(onlyEvents(), take(1), takeUntil(timer(1200)), toArray()) + ) + } catch { + events = [] + } + } + // Fallback to all relays if nothing from local quickly + if (events.length === 0) { + events = await lastValueFrom( + relayPool + .req(orderedRelays, filter) + .pipe(onlyEvents(), take(1), takeUntil(timer(5000)), toArray()) + ) + } if (events.length === 0) { return null diff --git a/src/services/bookmarkService.ts b/src/services/bookmarkService.ts index 84198162..dcd0e4f8 100644 --- a/src/services/bookmarkService.ts +++ b/src/services/bookmarkService.ts @@ -16,7 +16,7 @@ import { Bookmark } from '../types/bookmarks' import { collectBookmarksFromEvents } from './bookmarkProcessing.ts' import { UserSettings } from './settingsService' import { rebroadcastEvents } from './rebroadcastService' -import { prioritizeLocalRelays } from '../utils/helpers' +import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers' @@ -33,11 +33,11 @@ export const fetchBookmarks = async ( } // Get relay URLs from the pool const relayUrls = prioritizeLocalRelays(Array.from(relayPool.relays.values()).map(relay => relay.url)) + const { local: localRelays, remote: remoteRelays } = partitionRelays(relayUrls) // Fetch bookmark events - NIP-51 standards, legacy formats, and web bookmarks (NIP-B0) console.log('πŸ” Fetching bookmark events from relays:', relayUrls) // Try local-first quickly, then full set fallback let rawEvents = [] as NostrEvent[] - const localRelays = relayUrls.filter(url => url.includes('localhost') || url.includes('127.0.0.1')) if (localRelays.length > 0) { try { rawEvents = await lastValueFrom( @@ -49,12 +49,17 @@ export const fetchBookmarks = async ( rawEvents = [] } } - if (rawEvents.length === 0) { - rawEvents = await lastValueFrom( - relayPool - .req(relayUrls, { kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] }) - .pipe(completeOnEose(), takeUntil(timer(6000)), toArray()) - ) + if (remoteRelays.length > 0) { + try { + const remoteEvents = await lastValueFrom( + relayPool + .req(remoteRelays, { kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] }) + .pipe(completeOnEose(), takeUntil(timer(6000)), toArray()) + ) + rawEvents = rawEvents.concat(remoteEvents) + } catch { + // ignore + } } console.log('πŸ“Š Raw events fetched:', rawEvents.length, 'events') @@ -119,11 +124,31 @@ export const fetchBookmarks = async ( let idToEvent: Map = new Map() if (noteIds.length > 0) { try { - const events = await lastValueFrom( - relayPool - .req(relayUrls, { ids: noteIds }) - .pipe(completeOnEose(), takeUntil(timer(4000)), toArray()) - ) + const { local: localHydrate, remote: remoteHydrate } = partitionRelays(relayUrls) + let events: NostrEvent[] = [] + if (localHydrate.length > 0) { + try { + events = await lastValueFrom( + relayPool + .req(localHydrate, { ids: noteIds }) + .pipe(completeOnEose(), takeUntil(timer(800)), toArray()) + ) + } catch { + events = [] + } + } + if (remoteHydrate.length > 0) { + try { + const remote = await lastValueFrom( + relayPool + .req(remoteHydrate, { ids: noteIds }) + .pipe(completeOnEose(), takeUntil(timer(2500)), toArray()) + ) + events = events.concat(remote) + } catch { + // ignore + } + } idToEvent = new Map(events.map((e: NostrEvent) => [e.id, e])) } catch (error) { console.warn('Failed to fetch events for hydration:', error) diff --git a/src/services/highlightService.ts b/src/services/highlightService.ts index dccb617b..cdf8216e 100644 --- a/src/services/highlightService.ts +++ b/src/services/highlightService.ts @@ -251,29 +251,64 @@ export const fetchHighlights = async ( ): Promise => { try { const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) + const ordered = prioritizeLocalRelays(relayUrls) + const { local: localRelays, remote: remoteRelays } = partitionRelays(ordered) console.log('πŸ” Fetching highlights (kind 9802) by author:', pubkey) const seenIds = new Set() - const rawEvents = await lastValueFrom( - relayPool - .req(relayUrls, { kinds: [9802], authors: [pubkey] }) - .pipe( - onlyEvents(), - tap((event: NostrEvent) => { - if (!seenIds.has(event.id)) { - seenIds.add(event.id) - const highlight = eventToHighlight(event) - if (onHighlight) { - onHighlight(highlight) - } - } - }), - completeOnEose(), - takeUntil(timer(10000)), - toArray() + let rawEvents: NostrEvent[] = [] + if (localRelays.length > 0) { + try { + rawEvents = await lastValueFrom( + relayPool + .req(localRelays, { kinds: [9802], authors: [pubkey] }) + .pipe( + onlyEvents(), + tap((event: NostrEvent) => { + if (!seenIds.has(event.id)) { + seenIds.add(event.id) + const highlight = eventToHighlight(event) + if (onHighlight) { + onHighlight(highlight) + } + } + }), + completeOnEose(), + takeUntil(timer(1200)), + toArray() + ) ) - ) + } catch { + rawEvents = [] + } + } + if (remoteRelays.length > 0) { + try { + const remoteEvents = await lastValueFrom( + relayPool + .req(remoteRelays, { kinds: [9802], authors: [pubkey] }) + .pipe( + onlyEvents(), + tap((event: NostrEvent) => { + if (!seenIds.has(event.id)) { + seenIds.add(event.id) + const highlight = eventToHighlight(event) + if (onHighlight) { + onHighlight(highlight) + } + } + }), + completeOnEose(), + takeUntil(timer(6000)), + toArray() + ) + ) + rawEvents = rawEvents.concat(remoteEvents) + } catch { + // ignore + } + } console.log('πŸ“Š Raw highlight events fetched:', rawEvents.length) From 31f7d538291aec1cd33bdb0767046500a82cc0d7 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 12 Oct 2025 22:43:35 +0200 Subject: [PATCH 05/16] perf(explore): stream contacts + early posts from local; merge remote later --- src/components/Explore.tsx | 63 +++++++++++++++++++++------------ src/services/contactService.ts | 64 ++++++++++++++++++++-------------- 2 files changed, 78 insertions(+), 49 deletions(-) diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index e5087ac1..91ee344d 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -31,7 +31,44 @@ const Explore: React.FC = ({ relayPool }) => { setError(null) // Fetch the user's contacts (friends) - const contacts = await fetchContacts(relayPool, activeAccount.pubkey) + const contacts = await fetchContacts( + relayPool, + activeAccount.pubkey, + (partial) => { + // When local contacts are available, kick off early posts fetch + if (partial.size > 0) { + const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) + fetchBlogPostsFromAuthors( + relayPool, + Array.from(partial), + relayUrls, + (post) => { + setBlogPosts((prev) => { + const exists = prev.some(p => p.event.id === post.event.id) + if (exists) return prev + const next = [...prev, post] + return next.sort((a, b) => { + const timeA = a.published || a.event.created_at + const timeB = b.published || b.event.created_at + return timeB - timeA + }) + }) + } + ).then((all) => { + // Ensure union of streamed + final is displayed + setBlogPosts((prev) => { + const byId = new Map(prev.map(p => [p.event.id, p])) + for (const post of all) byId.set(post.event.id, post) + return Array.from(byId.values()).sort((a, b) => { + const timeA = a.published || a.event.created_at + const timeB = b.published || b.event.created_at + return timeB - timeA + }) + }) + }) + } + } + ) if (contacts.size === 0) { setError('You are not following anyone yet. Follow some people to see their blog posts!') @@ -39,29 +76,9 @@ const Explore: React.FC = ({ relayPool }) => { return } - // Get relay URLs from pool + // After full contacts, do a final pass for completeness const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) - - // Fetch blog posts from friends - const posts = await fetchBlogPostsFromAuthors( - relayPool, - Array.from(contacts), - relayUrls, - (post) => { - // Stream posts as we get them - setBlogPosts((prev) => { - const exists = prev.some(p => p.event.id === post.event.id) - if (exists) return prev - const next = [...prev, post] - // Keep sorted by published or created_at - return next.sort((a, b) => { - const timeA = a.published || a.event.created_at - const timeB = b.published || b.event.created_at - return timeB - timeA - }) - }) - } - ) + const posts = await fetchBlogPostsFromAuthors(relayPool, Array.from(contacts), relayUrls) if (posts.length === 0) { setError('No blog posts found from your friends yet') diff --git a/src/services/contactService.ts b/src/services/contactService.ts index dc69b3b2..0304c5e7 100644 --- a/src/services/contactService.ts +++ b/src/services/contactService.ts @@ -10,7 +10,8 @@ import { prioritizeLocalRelays } from '../utils/helpers' */ export const fetchContacts = async ( relayPool: RelayPool, - pubkey: string + pubkey: string, + onPartial?: (contacts: Set) => void ): Promise> => { try { const relayUrls = prioritizeLocalRelays(Array.from(relayPool.relays.values()).map(relay => relay.url)) @@ -31,35 +32,46 @@ export const fetchContacts = async ( events = [] } } - if (events.length === 0) { - events = await lastValueFrom( - relayPool - .req(relayUrls, { kinds: [3], authors: [pubkey] }) - .pipe(completeOnEose(), takeUntil(timer(6000)), toArray()) - ) + let followed = new Set() + if (events.length > 0) { + // Get the most recent contact list + const sortedEvents = events.sort((a, b) => b.created_at - a.created_at) + const contactList = sortedEvents[0] + // Extract pubkeys from 'p' tags + for (const tag of contactList.tags) { + if (tag[0] === 'p' && tag[1]) { + followed.add(tag[1]) + } + } + if (onPartial) onPartial(new Set(followed)) + } + // Always fetch remote to merge more contacts + const remoteRelays = relayUrls.filter(url => !url.includes('localhost') && !url.includes('127.0.0.1')) + if (remoteRelays.length > 0) { + try { + const remoteEvents = await lastValueFrom( + relayPool + .req(remoteRelays, { kinds: [3], authors: [pubkey] }) + .pipe(completeOnEose(), takeUntil(timer(6000)), toArray()) + ) + if (remoteEvents.length > 0) { + const sortedEvents = remoteEvents.sort((a, b) => b.created_at - a.created_at) + const contactList = sortedEvents[0] + for (const tag of contactList.tags) { + if (tag[0] === 'p' && tag[1]) { + followed.add(tag[1]) + } + } + } + } catch { + // ignore + } } console.log('πŸ“Š Contact events fetched:', events.length) - if (events.length === 0) { - return new Set() - } - - // Get the most recent contact list - const sortedEvents = events.sort((a, b) => b.created_at - a.created_at) - const contactList = sortedEvents[0] - - // Extract pubkeys from 'p' tags - const followedPubkeys = new Set() - for (const tag of contactList.tags) { - if (tag[0] === 'p' && tag[1]) { - followedPubkeys.add(tag[1]) - } - } - - console.log('πŸ‘₯ Followed contacts:', followedPubkeys.size) - - return followedPubkeys + console.log('πŸ‘₯ Followed contacts:', followed.size) + return followed } catch (error) { console.error('Failed to fetch contacts:', error) return new Set() From 484c2e0c2fdd3883d321a464abebc2d05b3bcbdc Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 12 Oct 2025 22:54:11 +0200 Subject: [PATCH 06/16] refactor(highlights): split service into smaller modules; keep files <210 lines --- src/services/highlightService.ts | 330 +-------------------- src/services/highlights/fetchByAuthor.ts | 78 +++++ src/services/highlights/fetchForArticle.ts | 119 ++++++++ src/services/highlights/fetchForUrl.ts | 67 +++++ 4 files changed, 267 insertions(+), 327 deletions(-) create mode 100644 src/services/highlights/fetchByAuthor.ts create mode 100644 src/services/highlights/fetchForArticle.ts create mode 100644 src/services/highlights/fetchForUrl.ts diff --git a/src/services/highlightService.ts b/src/services/highlightService.ts index cdf8216e..3b5cb035 100644 --- a/src/services/highlightService.ts +++ b/src/services/highlightService.ts @@ -1,329 +1,5 @@ -import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay' -import { lastValueFrom, takeUntil, timer, tap, toArray } from 'rxjs' -import { NostrEvent } from 'nostr-tools' -import { Highlight } from '../types/highlights' -import { RELAYS } from '../config/relays' -import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers' -import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor' -import { UserSettings } from './settingsService' -import { rebroadcastEvents } from './rebroadcastService' +export * from './highlights/fetchForArticle' +export * from './highlights/fetchForUrl' +export * from './highlights/fetchByAuthor' -/** - * Fetches highlights for a specific article by its address coordinate and/or event ID - * @param relayPool - The relay pool to query - * @param articleCoordinate - The article's address in format "kind:pubkey:identifier" (e.g., "30023:abc...def:my-article") - * @param eventId - Optional event ID to also query by 'e' tag - * @param onHighlight - Optional callback to receive highlights as they arrive - * @param settings - User settings for rebroadcast options - */ -export const fetchHighlightsForArticle = async ( - relayPool: RelayPool, - articleCoordinate: string, - eventId?: string, - onHighlight?: (highlight: Highlight) => void, - settings?: UserSettings -): Promise => { - try { - console.log('πŸ” Fetching highlights (kind 9802) for article:', articleCoordinate) - console.log('πŸ” Event ID:', eventId || 'none') - console.log('πŸ” From relays (including local):', RELAYS) - - const seenIds = new Set() - const processEvent = (event: NostrEvent): Highlight | null => { - if (seenIds.has(event.id)) return null - seenIds.add(event.id) - return eventToHighlight(event) - } - - // Local-first relay ordering - const orderedRelays = prioritizeLocalRelays(RELAYS) - const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays) - - // Query for highlights that reference this article via the 'a' tag - let aTagEvents: NostrEvent[] = [] - if (localRelays.length > 0) { - try { - aTagEvents = await lastValueFrom( - relayPool - .req(localRelays, { kinds: [9802], '#a': [articleCoordinate] }) - .pipe( - onlyEvents(), - tap((event: NostrEvent) => { - const highlight = processEvent(event) - if (highlight && onHighlight) { - onHighlight(highlight) - } - }), - completeOnEose(), - takeUntil(timer(1200)), - toArray() - ) - ) - } catch { - aTagEvents = [] - } - } - - if (aTagEvents.length === 0) { - aTagEvents = await lastValueFrom( - relayPool - .req(orderedRelays, { kinds: [9802], '#a': [articleCoordinate] }) - .pipe( - onlyEvents(), - tap((event: NostrEvent) => { - const highlight = processEvent(event) - if (highlight && onHighlight) { - onHighlight(highlight) - } - }), - completeOnEose(), - takeUntil(timer(6000)), - toArray() - ) - ) - } - - console.log('πŸ“Š Highlights via a-tag:', aTagEvents.length) - - // If we have an event ID, also query for highlights that reference via the 'e' tag - let eTagEvents: NostrEvent[] = [] - if (eventId) { - // e-tag query local-first as well - if (localRelays.length > 0) { - try { - eTagEvents = await lastValueFrom( - relayPool - .req(localRelays, { kinds: [9802], '#e': [eventId] }) - .pipe( - onlyEvents(), - tap((event: NostrEvent) => { - const highlight = processEvent(event) - if (highlight && onHighlight) { - onHighlight(highlight) - } - }), - completeOnEose(), - takeUntil(timer(1200)), - toArray() - ) - ) - } catch { - eTagEvents = [] - } - } - - if (eTagEvents.length === 0) { - eTagEvents = await lastValueFrom( - relayPool - .req(orderedRelays, { kinds: [9802], '#e': [eventId] }) - .pipe( - onlyEvents(), - tap((event: NostrEvent) => { - const highlight = processEvent(event) - if (highlight && onHighlight) { - onHighlight(highlight) - } - }), - completeOnEose(), - takeUntil(timer(6000)), - toArray() - ) - ) - } - console.log('πŸ“Š Highlights via e-tag:', eTagEvents.length) - } - - // Combine results from both queries - const rawEvents = [...aTagEvents, ...eTagEvents] - console.log('πŸ“Š Total raw highlight events fetched:', rawEvents.length) - - // Rebroadcast highlight events to local/all relays based on settings - await rebroadcastEvents(rawEvents, relayPool, settings) - - if (rawEvents.length > 0) { - console.log('πŸ“„ Sample highlight tags:', JSON.stringify(rawEvents[0].tags, null, 2)) - } else { - console.log('❌ No highlights found. Article coordinate:', articleCoordinate) - console.log('❌ Event ID:', eventId || 'none') - console.log('πŸ’‘ Try checking if there are any highlights on this article at https://highlighter.com') - } - - // Deduplicate events by ID - const uniqueEvents = dedupeHighlights(rawEvents) - console.log('πŸ“Š Unique highlight events after deduplication:', uniqueEvents.length) - - const highlights: Highlight[] = uniqueEvents.map(eventToHighlight) - return sortHighlights(highlights) - } catch (error) { - console.error('Failed to fetch highlights for article:', error) - return [] - } -} - -/** - * Fetches highlights for a specific URL - * @param relayPool - The relay pool to query - * @param url - The external URL to find highlights for - * @param settings - User settings for rebroadcast options - */ -export const fetchHighlightsForUrl = async ( - relayPool: RelayPool, - url: string, - onHighlight?: (highlight: Highlight) => void, - settings?: UserSettings -): Promise => { - try { - console.log('πŸ” Fetching highlights (kind 9802) for URL:', url) - - const seenIds = new Set() - const orderedRelaysUrl = prioritizeLocalRelays(RELAYS) - const { local: localRelaysUrl } = partitionRelays(orderedRelaysUrl) - let rawEvents: NostrEvent[] = [] - if (localRelaysUrl.length > 0) { - try { - rawEvents = await lastValueFrom( - relayPool - .req(localRelaysUrl, { kinds: [9802], '#r': [url] }) - .pipe( - onlyEvents(), - tap((event: NostrEvent) => { - seenIds.add(event.id) - if (onHighlight) { - const h = eventToHighlight(event) - onHighlight(h) - } - }), - completeOnEose(), - takeUntil(timer(1200)), - toArray() - ) - ) - } catch { - rawEvents = [] - } - } - if (rawEvents.length === 0) { - rawEvents = await lastValueFrom( - relayPool - .req(orderedRelaysUrl, { kinds: [9802], '#r': [url] }) - .pipe( - onlyEvents(), - tap((event: NostrEvent) => { - seenIds.add(event.id) - if (onHighlight) { - const h = eventToHighlight(event) - onHighlight(h) - } - }), - completeOnEose(), - takeUntil(timer(6000)), - toArray() - ) - ) - } - - console.log('πŸ“Š Highlights for URL:', rawEvents.length) - - // Rebroadcast highlight events to local/all relays based on settings - await rebroadcastEvents(rawEvents, relayPool, settings) - - const uniqueEvents = dedupeHighlights(rawEvents) - const highlights: Highlight[] = uniqueEvents.map(eventToHighlight) - return sortHighlights(highlights) - } catch (error) { - console.error('Failed to fetch highlights for URL:', error) - return [] - } -} - -/** - * Fetches highlights created by a specific user - * @param relayPool - The relay pool to query - * @param pubkey - The user's public key - * @param onHighlight - Optional callback to receive highlights as they arrive - * @param settings - User settings for rebroadcast options - */ -export const fetchHighlights = async ( - relayPool: RelayPool, - pubkey: string, - onHighlight?: (highlight: Highlight) => void, - settings?: UserSettings -): Promise => { - try { - const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) - const ordered = prioritizeLocalRelays(relayUrls) - const { local: localRelays, remote: remoteRelays } = partitionRelays(ordered) - - console.log('πŸ” Fetching highlights (kind 9802) by author:', pubkey) - - const seenIds = new Set() - let rawEvents: NostrEvent[] = [] - if (localRelays.length > 0) { - try { - rawEvents = await lastValueFrom( - relayPool - .req(localRelays, { kinds: [9802], authors: [pubkey] }) - .pipe( - onlyEvents(), - tap((event: NostrEvent) => { - if (!seenIds.has(event.id)) { - seenIds.add(event.id) - const highlight = eventToHighlight(event) - if (onHighlight) { - onHighlight(highlight) - } - } - }), - completeOnEose(), - takeUntil(timer(1200)), - toArray() - ) - ) - } catch { - rawEvents = [] - } - } - if (remoteRelays.length > 0) { - try { - const remoteEvents = await lastValueFrom( - relayPool - .req(remoteRelays, { kinds: [9802], authors: [pubkey] }) - .pipe( - onlyEvents(), - tap((event: NostrEvent) => { - if (!seenIds.has(event.id)) { - seenIds.add(event.id) - const highlight = eventToHighlight(event) - if (onHighlight) { - onHighlight(highlight) - } - } - }), - completeOnEose(), - takeUntil(timer(6000)), - toArray() - ) - ) - rawEvents = rawEvents.concat(remoteEvents) - } catch { - // ignore - } - } - - console.log('πŸ“Š Raw highlight events fetched:', rawEvents.length) - - // Rebroadcast highlight events to local/all relays based on settings - await rebroadcastEvents(rawEvents, relayPool, settings) - - // Deduplicate and process events - const uniqueEvents = dedupeHighlights(rawEvents) - console.log('πŸ“Š Unique highlight events after deduplication:', uniqueEvents.length) - - const highlights: Highlight[] = uniqueEvents.map(eventToHighlight) - return sortHighlights(highlights) - } catch (error) { - console.error('Failed to fetch highlights by author:', error) - return [] - } -} diff --git a/src/services/highlights/fetchByAuthor.ts b/src/services/highlights/fetchByAuthor.ts new file mode 100644 index 00000000..14f908bc --- /dev/null +++ b/src/services/highlights/fetchByAuthor.ts @@ -0,0 +1,78 @@ +import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay' +import { lastValueFrom, takeUntil, timer, tap, toArray } from 'rxjs' +import { NostrEvent } from 'nostr-tools' +import { Highlight } from '../../types/highlights' +import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers' +import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor' +import { UserSettings } from '../settingsService' +import { rebroadcastEvents } from '../rebroadcastService' + +export const fetchHighlights = async ( + relayPool: RelayPool, + pubkey: string, + onHighlight?: (highlight: Highlight) => void, + settings?: UserSettings +): Promise => { + try { + const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) + const ordered = prioritizeLocalRelays(relayUrls) + const { local: localRelays, remote: remoteRelays } = partitionRelays(ordered) + + const seenIds = new Set() + let rawEvents: NostrEvent[] = [] + if (localRelays.length > 0) { + try { + rawEvents = await lastValueFrom( + relayPool + .req(localRelays, { kinds: [9802], authors: [pubkey] }) + .pipe( + onlyEvents(), + tap((event: NostrEvent) => { + if (!seenIds.has(event.id)) { + seenIds.add(event.id) + if (onHighlight) onHighlight(eventToHighlight(event)) + } + }), + completeOnEose(), + takeUntil(timer(1200)), + toArray() + ) + ) + } catch { + rawEvents = [] + } + } + if (remoteRelays.length > 0) { + try { + const remoteEvents = await lastValueFrom( + relayPool + .req(remoteRelays, { kinds: [9802], authors: [pubkey] }) + .pipe( + onlyEvents(), + tap((event: NostrEvent) => { + if (!seenIds.has(event.id)) { + seenIds.add(event.id) + if (onHighlight) onHighlight(eventToHighlight(event)) + } + }), + completeOnEose(), + takeUntil(timer(6000)), + toArray() + ) + ) + rawEvents = rawEvents.concat(remoteEvents) + } catch { + // ignore + } + } + + await rebroadcastEvents(rawEvents, relayPool, settings) + const uniqueEvents = dedupeHighlights(rawEvents) + const highlights = uniqueEvents.map(eventToHighlight) + return sortHighlights(highlights) + } catch { + return [] + } +} + + diff --git a/src/services/highlights/fetchForArticle.ts b/src/services/highlights/fetchForArticle.ts new file mode 100644 index 00000000..c308e275 --- /dev/null +++ b/src/services/highlights/fetchForArticle.ts @@ -0,0 +1,119 @@ +import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay' +import { lastValueFrom, takeUntil, timer, tap, toArray } from 'rxjs' +import { NostrEvent } from 'nostr-tools' +import { Highlight } from '../../types/highlights' +import { RELAYS } from '../../config/relays' +import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers' +import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor' +import { UserSettings } from '../settingsService' +import { rebroadcastEvents } from '../rebroadcastService' + +export const fetchHighlightsForArticle = async ( + relayPool: RelayPool, + articleCoordinate: string, + eventId?: string, + onHighlight?: (highlight: Highlight) => void, + settings?: UserSettings +): Promise => { + try { + const seenIds = new Set() + const processEvent = (event: NostrEvent): Highlight | null => { + if (seenIds.has(event.id)) return null + seenIds.add(event.id) + return eventToHighlight(event) + } + + const orderedRelays = prioritizeLocalRelays(RELAYS) + const { local: localRelays } = partitionRelays(orderedRelays) + + let aTagEvents: NostrEvent[] = [] + if (localRelays.length > 0) { + try { + aTagEvents = await lastValueFrom( + relayPool + .req(localRelays, { kinds: [9802], '#a': [articleCoordinate] }) + .pipe( + onlyEvents(), + tap((event: NostrEvent) => { + const highlight = processEvent(event) + if (highlight && onHighlight) onHighlight(highlight) + }), + completeOnEose(), + takeUntil(timer(1200)), + toArray() + ) + ) + } catch { + aTagEvents = [] + } + } + + if (aTagEvents.length === 0) { + aTagEvents = await lastValueFrom( + relayPool + .req(orderedRelays, { kinds: [9802], '#a': [articleCoordinate] }) + .pipe( + onlyEvents(), + tap((event: NostrEvent) => { + const highlight = processEvent(event) + if (highlight && onHighlight) onHighlight(highlight) + }), + completeOnEose(), + takeUntil(timer(6000)), + toArray() + ) + ) + } + + let eTagEvents: NostrEvent[] = [] + if (eventId) { + if (localRelays.length > 0) { + try { + eTagEvents = await lastValueFrom( + relayPool + .req(localRelays, { kinds: [9802], '#e': [eventId] }) + .pipe( + onlyEvents(), + tap((event: NostrEvent) => { + const highlight = processEvent(event) + if (highlight && onHighlight) onHighlight(highlight) + }), + completeOnEose(), + takeUntil(timer(1200)), + toArray() + ) + ) + } catch { + eTagEvents = [] + } + } + + if (eTagEvents.length === 0) { + eTagEvents = await lastValueFrom( + relayPool + .req(orderedRelays, { kinds: [9802], '#e': [eventId] }) + .pipe( + onlyEvents(), + tap((event: NostrEvent) => { + const highlight = processEvent(event) + if (highlight && onHighlight) onHighlight(highlight) + }), + completeOnEose(), + takeUntil(timer(6000)), + toArray() + ) + ) + } + } + + const rawEvents = [...aTagEvents, ...eTagEvents] + await rebroadcastEvents(rawEvents, relayPool, settings) + const uniqueEvents = dedupeHighlights(rawEvents) + const highlights: Highlight[] = uniqueEvents.map(eventToHighlight) + return sortHighlights(highlights) + } catch { + return [] + } +} + + diff --git a/src/services/highlights/fetchForUrl.ts b/src/services/highlights/fetchForUrl.ts new file mode 100644 index 00000000..336d51fa --- /dev/null +++ b/src/services/highlights/fetchForUrl.ts @@ -0,0 +1,67 @@ +import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay' +import { lastValueFrom, takeUntil, timer, tap, toArray } from 'rxjs' +import { NostrEvent } from 'nostr-tools' +import { Highlight } from '../../types/highlights' +import { RELAYS } from '../../config/relays' +import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers' +import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor' +import { UserSettings } from '../settingsService' +import { rebroadcastEvents } from '../rebroadcastService' + +export const fetchHighlightsForUrl = async ( + relayPool: RelayPool, + url: string, + onHighlight?: (highlight: Highlight) => void, + settings?: UserSettings +): Promise => { + try { + const seenIds = new Set() + const orderedRelaysUrl = prioritizeLocalRelays(RELAYS) + const { local: localRelaysUrl } = partitionRelays(orderedRelaysUrl) + let rawEvents: NostrEvent[] = [] + if (localRelaysUrl.length > 0) { + try { + rawEvents = await lastValueFrom( + relayPool + .req(localRelaysUrl, { kinds: [9802], '#r': [url] }) + .pipe( + onlyEvents(), + tap((event: NostrEvent) => { + seenIds.add(event.id) + if (onHighlight) onHighlight(eventToHighlight(event)) + }), + completeOnEose(), + takeUntil(timer(1200)), + toArray() + ) + ) + } catch { + rawEvents = [] + } + } + if (rawEvents.length === 0) { + rawEvents = await lastValueFrom( + relayPool + .req(orderedRelaysUrl, { kinds: [9802], '#r': [url] }) + .pipe( + onlyEvents(), + tap((event: NostrEvent) => { + seenIds.add(event.id) + if (onHighlight) onHighlight(eventToHighlight(event)) + }), + completeOnEose(), + takeUntil(timer(6000)), + toArray() + ) + ) + } + await rebroadcastEvents(rawEvents, relayPool, settings) + const uniqueEvents = dedupeHighlights(rawEvents) + const highlights: Highlight[] = uniqueEvents.map(eventToHighlight) + return sortHighlights(highlights) + } catch { + return [] + } +} + + From e1420140d16092e8e9149b46db18263b27d1ac41 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 12 Oct 2025 22:55:46 +0200 Subject: [PATCH 07/16] chore(lint): fix eslint and typescript issues; no rule changes --- src/hooks/useExternalUrlLoader.ts | 2 +- src/services/articleService.ts | 4 ++-- src/services/articleTitleResolver.ts | 9 +++++---- src/services/contactService.ts | 12 +++++++----- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/hooks/useExternalUrlLoader.ts b/src/hooks/useExternalUrlLoader.ts index a123cb23..bef85150 100644 --- a/src/hooks/useExternalUrlLoader.ts +++ b/src/hooks/useExternalUrlLoader.ts @@ -11,7 +11,7 @@ interface UseExternalUrlLoaderProps { setReaderContent: (content: ReadableContent | undefined) => void setReaderLoading: (loading: boolean) => void setIsCollapsed: (collapsed: boolean) => void - setHighlights: (highlights: Highlight[]) => void + setHighlights: (highlights: Highlight[] | ((prev: Highlight[]) => Highlight[])) => void setHighlightsLoading: (loading: boolean) => void setCurrentArticleCoordinate: (coord: string | undefined) => void setCurrentArticleEventId: (id: string | undefined) => void diff --git a/src/services/articleService.ts b/src/services/articleService.ts index 16ce4280..a1059f08 100644 --- a/src/services/articleService.ts +++ b/src/services/articleService.ts @@ -1,4 +1,4 @@ -import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay' +import { RelayPool, onlyEvents } from 'applesauce-relay' import { lastValueFrom, take, takeUntil, timer, toArray } from 'rxjs' import { nip19 } from 'nostr-tools' import { AddressPointer } from 'nostr-tools/nip19' @@ -103,7 +103,7 @@ export async function fetchArticleByNaddr( ? pointer.relays : RELAYS const orderedRelays = prioritizeLocalRelays(baseRelays) - const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays) + const { local: localRelays } = partitionRelays(orderedRelays) // Fetch the article event const filter = { diff --git a/src/services/articleTitleResolver.ts b/src/services/articleTitleResolver.ts index acc7ba76..0c922d1a 100644 --- a/src/services/articleTitleResolver.ts +++ b/src/services/articleTitleResolver.ts @@ -1,4 +1,4 @@ -import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay' +import { RelayPool, onlyEvents } from 'applesauce-relay' import { lastValueFrom, take, takeUntil, timer, toArray } from 'rxjs' import { nip19 } from 'nostr-tools' import { AddressPointer } from 'nostr-tools/nip19' @@ -40,7 +40,7 @@ export async function fetchArticleTitle( } // Try to get the first event quickly from local relays - let events = [] as any[] + let events: { created_at: number }[] = [] if (localRelays.length > 0) { try { events = await lastValueFrom( @@ -54,11 +54,12 @@ export async function fetchArticleTitle( } // Fallback to all relays if nothing from local quickly if (events.length === 0) { - events = await lastValueFrom( + const fallbackEvents = await lastValueFrom( relayPool .req(orderedRelays, filter) .pipe(onlyEvents(), take(1), takeUntil(timer(5000)), toArray()) ) + events = fallbackEvents as { created_at: number }[] } if (events.length === 0) { @@ -67,7 +68,7 @@ export async function fetchArticleTitle( // Sort by created_at and take the most recent events.sort((a, b) => b.created_at - a.created_at) - const article = events[0] + const article = events[0] as unknown as Parameters[0] return getArticleTitle(article) || null } catch (err) { diff --git a/src/services/contactService.ts b/src/services/contactService.ts index 0304c5e7..9581b5a6 100644 --- a/src/services/contactService.ts +++ b/src/services/contactService.ts @@ -20,19 +20,20 @@ export const fetchContacts = async ( // Local-first quick attempt const localRelays = relayUrls.filter(url => url.includes('localhost') || url.includes('127.0.0.1')) - let events = [] as any[] + let events: Array<{ created_at: number; tags: string[][] }> = [] if (localRelays.length > 0) { try { - events = await lastValueFrom( + const localEvents = await lastValueFrom( relayPool .req(localRelays, { kinds: [3], authors: [pubkey] }) .pipe(completeOnEose(), takeUntil(timer(1200)), toArray()) ) + events = localEvents as Array<{ created_at: number; tags: string[][] }> } catch { events = [] } } - let followed = new Set() + const followed = new Set() if (events.length > 0) { // Get the most recent contact list const sortedEvents = events.sort((a, b) => b.created_at - a.created_at) @@ -55,8 +56,9 @@ export const fetchContacts = async ( .pipe(completeOnEose(), takeUntil(timer(6000)), toArray()) ) if (remoteEvents.length > 0) { - const sortedEvents = remoteEvents.sort((a, b) => b.created_at - a.created_at) - const contactList = sortedEvents[0] + const sortedRemote = (remoteEvents as Array<{ created_at: number; tags: string[][] }>). + sort((a, b) => b.created_at - a.created_at) + const contactList = sortedRemote[0] for (const tag of contactList.tags) { if (tag[0] === 'p' && tag[1]) { followed.add(tag[1]) From 6d58d6e7f3683c7f7154e4e52d7a2c3de8aa2e9d Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 12 Oct 2025 22:58:25 +0200 Subject: [PATCH 08/16] fix(highlights): ensure nostrverse fetch merges remote results after local for article/url --- src/services/highlights/fetchForArticle.ts | 74 +++++++++++++--------- src/services/highlights/fetchForUrl.ts | 37 ++++++----- 2 files changed, 64 insertions(+), 47 deletions(-) diff --git a/src/services/highlights/fetchForArticle.ts b/src/services/highlights/fetchForArticle.ts index c308e275..9d72beab 100644 --- a/src/services/highlights/fetchForArticle.ts +++ b/src/services/highlights/fetchForArticle.ts @@ -24,7 +24,7 @@ export const fetchHighlightsForArticle = async ( } const orderedRelays = prioritizeLocalRelays(RELAYS) - const { local: localRelays } = partitionRelays(orderedRelays) + const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays) let aTagEvents: NostrEvent[] = [] if (localRelays.length > 0) { @@ -48,21 +48,27 @@ export const fetchHighlightsForArticle = async ( } } - if (aTagEvents.length === 0) { - aTagEvents = await lastValueFrom( - relayPool - .req(orderedRelays, { kinds: [9802], '#a': [articleCoordinate] }) - .pipe( - onlyEvents(), - tap((event: NostrEvent) => { - const highlight = processEvent(event) - if (highlight && onHighlight) onHighlight(highlight) - }), - completeOnEose(), - takeUntil(timer(6000)), - toArray() - ) - ) + // Always query remote relays to merge additional highlights + if (remoteRelays.length > 0) { + try { + const aRemote = await lastValueFrom( + relayPool + .req(remoteRelays, { kinds: [9802], '#a': [articleCoordinate] }) + .pipe( + onlyEvents(), + tap((event: NostrEvent) => { + const highlight = processEvent(event) + if (highlight && onHighlight) onHighlight(highlight) + }), + completeOnEose(), + takeUntil(timer(6000)), + toArray() + ) + ) + aTagEvents = aTagEvents.concat(aRemote) + } catch { + // ignore + } } let eTagEvents: NostrEvent[] = [] @@ -88,21 +94,27 @@ export const fetchHighlightsForArticle = async ( } } - if (eTagEvents.length === 0) { - eTagEvents = await lastValueFrom( - relayPool - .req(orderedRelays, { kinds: [9802], '#e': [eventId] }) - .pipe( - onlyEvents(), - tap((event: NostrEvent) => { - const highlight = processEvent(event) - if (highlight && onHighlight) onHighlight(highlight) - }), - completeOnEose(), - takeUntil(timer(6000)), - toArray() - ) - ) + // Always query remote for e-tag too + if (remoteRelays.length > 0) { + try { + const eRemote = await lastValueFrom( + relayPool + .req(remoteRelays, { kinds: [9802], '#e': [eventId] }) + .pipe( + onlyEvents(), + tap((event: NostrEvent) => { + const highlight = processEvent(event) + if (highlight && onHighlight) onHighlight(highlight) + }), + completeOnEose(), + takeUntil(timer(6000)), + toArray() + ) + ) + eTagEvents = eTagEvents.concat(eRemote) + } catch { + // ignore + } } } diff --git a/src/services/highlights/fetchForUrl.ts b/src/services/highlights/fetchForUrl.ts index 336d51fa..f8dc91a4 100644 --- a/src/services/highlights/fetchForUrl.ts +++ b/src/services/highlights/fetchForUrl.ts @@ -17,7 +17,7 @@ export const fetchHighlightsForUrl = async ( try { const seenIds = new Set() const orderedRelaysUrl = prioritizeLocalRelays(RELAYS) - const { local: localRelaysUrl } = partitionRelays(orderedRelaysUrl) + const { local: localRelaysUrl, remote: remoteRelaysUrl } = partitionRelays(orderedRelaysUrl) let rawEvents: NostrEvent[] = [] if (localRelaysUrl.length > 0) { try { @@ -39,21 +39,26 @@ export const fetchHighlightsForUrl = async ( rawEvents = [] } } - if (rawEvents.length === 0) { - rawEvents = await lastValueFrom( - relayPool - .req(orderedRelaysUrl, { kinds: [9802], '#r': [url] }) - .pipe( - onlyEvents(), - tap((event: NostrEvent) => { - seenIds.add(event.id) - if (onHighlight) onHighlight(eventToHighlight(event)) - }), - completeOnEose(), - takeUntil(timer(6000)), - toArray() - ) - ) + if (remoteRelaysUrl.length > 0) { + try { + const remote = await lastValueFrom( + relayPool + .req(remoteRelaysUrl, { kinds: [9802], '#r': [url] }) + .pipe( + onlyEvents(), + tap((event: NostrEvent) => { + seenIds.add(event.id) + if (onHighlight) onHighlight(eventToHighlight(event)) + }), + completeOnEose(), + takeUntil(timer(6000)), + toArray() + ) + ) + rawEvents = rawEvents.concat(remote) + } catch { + // ignore + } } await rebroadcastEvents(rawEvents, relayPool, settings) const uniqueEvents = dedupeHighlights(rawEvents) From ea1046fe138a2996c7dd4ba4c35dc05b5007abce Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 12 Oct 2025 23:00:23 +0200 Subject: [PATCH 09/16] perf(local-first): always follow up with remote for articles and titles --- src/services/articleService.ts | 33 ++++++++++++++++------------ src/services/articleTitleResolver.ts | 12 +++++----- 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/src/services/articleService.ts b/src/services/articleService.ts index a1059f08..cc18beeb 100644 --- a/src/services/articleService.ts +++ b/src/services/articleService.ts @@ -103,7 +103,7 @@ export async function fetchArticleByNaddr( ? pointer.relays : RELAYS const orderedRelays = prioritizeLocalRelays(baseRelays) - const { local: localRelays } = partitionRelays(orderedRelays) + const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays) // Fetch the article event const filter = { @@ -112,7 +112,7 @@ export async function fetchArticleByNaddr( '#d': [pointer.identifier] } - // Local-first: try local relays quickly, then fallback to remote if no result + // Local-first: try local relays quickly, then ALWAYS query remote to merge let events = [] as NostrEvent[] if (localRelays.length > 0) { try { @@ -131,18 +131,23 @@ export async function fetchArticleByNaddr( } } - if (events.length === 0) { - // Fallback: query all relays, but still time-box - events = await lastValueFrom( - relayPool - .req(orderedRelays, filter) - .pipe( - onlyEvents(), - take(1), - takeUntil(timer(6000)), - toArray() - ) - ) + // Always query remote to ensure we have the latest from the wider network + if (remoteRelays.length > 0) { + try { + const remoteEvents = await lastValueFrom( + relayPool + .req(remoteRelays, filter) + .pipe( + onlyEvents(), + take(1), + takeUntil(timer(6000)), + toArray() + ) + ) + events = events.concat(remoteEvents) + } catch { + // ignore + } } if (events.length === 0) { diff --git a/src/services/articleTitleResolver.ts b/src/services/articleTitleResolver.ts index 0c922d1a..8667fcbc 100644 --- a/src/services/articleTitleResolver.ts +++ b/src/services/articleTitleResolver.ts @@ -30,7 +30,7 @@ export async function fetchArticleTitle( ? pointer.relays : RELAYS const orderedRelays = prioritizeLocalRelays(baseRelays) - const { local: localRelays } = partitionRelays(orderedRelays) + const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays) // Fetch the article event const filter = { @@ -52,14 +52,14 @@ export async function fetchArticleTitle( events = [] } } - // Fallback to all relays if nothing from local quickly - if (events.length === 0) { - const fallbackEvents = await lastValueFrom( + // Always follow up with remote relays to ensure we have latest network data + if (remoteRelays.length > 0) { + const remoteEvents = await lastValueFrom( relayPool - .req(orderedRelays, filter) + .req(remoteRelays, filter) .pipe(onlyEvents(), take(1), takeUntil(timer(5000)), toArray()) ) - events = fallbackEvents as { created_at: number }[] + events = events.concat(remoteEvents as unknown as { created_at: number }[]) } if (events.length === 0) { From 496d1df4047c6587ef366c9c2c107f1ac2b2c462 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 12 Oct 2025 23:13:26 +0200 Subject: [PATCH 10/16] perf(parallel): run local+remote fetches concurrently across services; stream+dedupe --- src/services/articleService.ts | 48 ++----- src/services/articleTitleResolver.ts | 34 ++--- src/services/bookmarkService.ts | 68 +++------- src/services/contactService.ts | 53 +++----- src/services/exploreService.ts | 57 ++------ src/services/highlights/fetchByAuthor.ts | 79 +++++------- src/services/highlights/fetchForArticle.ts | 143 ++++++++------------- src/services/highlights/fetchForUrl.ts | 71 ++++------ src/utils/helpers.ts | 24 ++++ 9 files changed, 204 insertions(+), 373 deletions(-) diff --git a/src/services/articleService.ts b/src/services/articleService.ts index cc18beeb..f6f3cf7e 100644 --- a/src/services/articleService.ts +++ b/src/services/articleService.ts @@ -1,11 +1,12 @@ -import { RelayPool, onlyEvents } from 'applesauce-relay' -import { lastValueFrom, take, takeUntil, timer, toArray } from 'rxjs' +import { RelayPool } from 'applesauce-relay' +import { lastValueFrom, take } from 'rxjs' import { nip19 } from 'nostr-tools' import { AddressPointer } from 'nostr-tools/nip19' import { NostrEvent } from 'nostr-tools' import { Helpers } from 'applesauce-core' import { RELAYS } from '../config/relays' -import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers' +import { prioritizeLocalRelays, partitionRelays, createParallelReqStreams } from '../utils/helpers' +import { merge, toArray as rxToArray } from 'rxjs' import { UserSettings } from './settingsService' import { rebroadcastEvents } from './rebroadcastService' @@ -112,43 +113,10 @@ export async function fetchArticleByNaddr( '#d': [pointer.identifier] } - // Local-first: try local relays quickly, then ALWAYS query remote to merge - let events = [] as NostrEvent[] - if (localRelays.length > 0) { - try { - events = await lastValueFrom( - relayPool - .req(localRelays, filter) - .pipe( - onlyEvents(), - take(1), - takeUntil(timer(1200)), - toArray() - ) - ) - } catch { - events = [] - } - } - - // Always query remote to ensure we have the latest from the wider network - if (remoteRelays.length > 0) { - try { - const remoteEvents = await lastValueFrom( - relayPool - .req(remoteRelays, filter) - .pipe( - onlyEvents(), - take(1), - takeUntil(timer(6000)), - toArray() - ) - ) - events = events.concat(remoteEvents) - } catch { - // ignore - } - } + // Parallel local+remote, stream immediate, collect up to first from each + const { local$, remote$ } = createParallelReqStreams(relayPool, localRelays, remoteRelays, filter, 1200, 6000) + const collected = await lastValueFrom(merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray())) + const events = collected as NostrEvent[] if (events.length === 0) { throw new Error('Article not found') diff --git a/src/services/articleTitleResolver.ts b/src/services/articleTitleResolver.ts index 8667fcbc..57263fbc 100644 --- a/src/services/articleTitleResolver.ts +++ b/src/services/articleTitleResolver.ts @@ -1,10 +1,11 @@ -import { RelayPool, onlyEvents } from 'applesauce-relay' -import { lastValueFrom, take, takeUntil, timer, toArray } from 'rxjs' +import { RelayPool } from 'applesauce-relay' +import { lastValueFrom, take } from 'rxjs' import { nip19 } from 'nostr-tools' import { AddressPointer } from 'nostr-tools/nip19' import { Helpers } from 'applesauce-core' import { RELAYS } from '../config/relays' -import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers' +import { prioritizeLocalRelays, partitionRelays, createParallelReqStreams } from '../utils/helpers' +import { merge, toArray as rxToArray } from 'rxjs' const { getArticleTitle } = Helpers @@ -39,28 +40,11 @@ export async function fetchArticleTitle( '#d': [pointer.identifier] } - // Try to get the first event quickly from local relays - let events: { created_at: number }[] = [] - if (localRelays.length > 0) { - try { - events = await lastValueFrom( - relayPool - .req(localRelays, filter) - .pipe(onlyEvents(), take(1), takeUntil(timer(1200)), toArray()) - ) - } catch { - events = [] - } - } - // Always follow up with remote relays to ensure we have latest network data - if (remoteRelays.length > 0) { - const remoteEvents = await lastValueFrom( - relayPool - .req(remoteRelays, filter) - .pipe(onlyEvents(), take(1), takeUntil(timer(5000)), toArray()) - ) - events = events.concat(remoteEvents as unknown as { created_at: number }[]) - } + // Parallel local+remote: collect up to one event from each + const { local$, remote$ } = createParallelReqStreams(relayPool, localRelays, remoteRelays, filter, 1200, 5000) + const events = await lastValueFrom( + merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray()) + ) as unknown as { created_at: number }[] if (events.length === 0) { return null diff --git a/src/services/bookmarkService.ts b/src/services/bookmarkService.ts index dcd0e4f8..1b3346ee 100644 --- a/src/services/bookmarkService.ts +++ b/src/services/bookmarkService.ts @@ -1,5 +1,5 @@ import { RelayPool, completeOnEose } from 'applesauce-relay' -import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs' +import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs' import { AccountWithExtension, NostrEvent, @@ -37,30 +37,17 @@ export const fetchBookmarks = async ( // Fetch bookmark events - NIP-51 standards, legacy formats, and web bookmarks (NIP-B0) console.log('πŸ” Fetching bookmark events from relays:', relayUrls) // Try local-first quickly, then full set fallback - let rawEvents = [] as NostrEvent[] - if (localRelays.length > 0) { - try { - rawEvents = await lastValueFrom( - relayPool - .req(localRelays, { kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] }) - .pipe(completeOnEose(), takeUntil(timer(1200)), toArray()) - ) - } catch { - rawEvents = [] - } - } - if (remoteRelays.length > 0) { - try { - const remoteEvents = await lastValueFrom( - relayPool - .req(remoteRelays, { kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] }) - .pipe(completeOnEose(), takeUntil(timer(6000)), toArray()) - ) - rawEvents = rawEvents.concat(remoteEvents) - } catch { - // ignore - } - } + const local$ = localRelays.length > 0 + ? relayPool + .req(localRelays, { kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] }) + .pipe(completeOnEose(), takeUntil(timer(1200))) + : new Observable((sub) => sub.complete()) + const remote$ = remoteRelays.length > 0 + ? relayPool + .req(remoteRelays, { kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] }) + .pipe(completeOnEose(), takeUntil(timer(6000))) + : new Observable((sub) => sub.complete()) + const rawEvents = await lastValueFrom(merge(local$, remote$).pipe(toArray())) console.log('πŸ“Š Raw events fetched:', rawEvents.length, 'events') // Rebroadcast bookmark events to local/all relays based on settings @@ -125,30 +112,13 @@ export const fetchBookmarks = async ( if (noteIds.length > 0) { try { const { local: localHydrate, remote: remoteHydrate } = partitionRelays(relayUrls) - let events: NostrEvent[] = [] - if (localHydrate.length > 0) { - try { - events = await lastValueFrom( - relayPool - .req(localHydrate, { ids: noteIds }) - .pipe(completeOnEose(), takeUntil(timer(800)), toArray()) - ) - } catch { - events = [] - } - } - if (remoteHydrate.length > 0) { - try { - const remote = await lastValueFrom( - relayPool - .req(remoteHydrate, { ids: noteIds }) - .pipe(completeOnEose(), takeUntil(timer(2500)), toArray()) - ) - events = events.concat(remote) - } catch { - // ignore - } - } + const localHydrate$ = localHydrate.length > 0 + ? relayPool.req(localHydrate, { ids: noteIds }).pipe(completeOnEose(), takeUntil(timer(800))) + : new Observable((sub) => sub.complete()) + const remoteHydrate$ = remoteHydrate.length > 0 + ? relayPool.req(remoteHydrate, { ids: noteIds }).pipe(completeOnEose(), takeUntil(timer(2500))) + : new Observable((sub) => sub.complete()) + const events: NostrEvent[] = await lastValueFrom(merge(localHydrate$, remoteHydrate$).pipe(toArray())) idToEvent = new Map(events.map((e: NostrEvent) => [e.id, e])) } catch (error) { console.warn('Failed to fetch events for hydration:', error) diff --git a/src/services/contactService.ts b/src/services/contactService.ts index 9581b5a6..00dabd78 100644 --- a/src/services/contactService.ts +++ b/src/services/contactService.ts @@ -1,5 +1,5 @@ import { RelayPool, completeOnEose } from 'applesauce-relay' -import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs' +import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs' import { prioritizeLocalRelays } from '../utils/helpers' /** @@ -20,19 +20,20 @@ export const fetchContacts = async ( // Local-first quick attempt const localRelays = relayUrls.filter(url => url.includes('localhost') || url.includes('127.0.0.1')) - let events: Array<{ created_at: number; tags: string[][] }> = [] - if (localRelays.length > 0) { - try { - const localEvents = await lastValueFrom( - relayPool - .req(localRelays, { kinds: [3], authors: [pubkey] }) - .pipe(completeOnEose(), takeUntil(timer(1200)), toArray()) - ) - events = localEvents as Array<{ created_at: number; tags: string[][] }> - } catch { - events = [] - } - } + const remoteRelays = relayUrls.filter(url => !url.includes('localhost') && !url.includes('127.0.0.1')) + const local$ = localRelays.length > 0 + ? relayPool + .req(localRelays, { kinds: [3], authors: [pubkey] }) + .pipe(completeOnEose(), takeUntil(timer(1200))) + : new Observable<{ created_at: number; tags: string[][] }>((sub) => sub.complete()) + const remote$ = remoteRelays.length > 0 + ? relayPool + .req(remoteRelays, { kinds: [3], authors: [pubkey] }) + .pipe(completeOnEose(), takeUntil(timer(6000))) + : new Observable<{ created_at: number; tags: string[][] }>((sub) => sub.complete()) + const events = await lastValueFrom( + merge(local$, remote$).pipe(toArray()) + ) const followed = new Set() if (events.length > 0) { // Get the most recent contact list @@ -46,29 +47,7 @@ export const fetchContacts = async ( } if (onPartial) onPartial(new Set(followed)) } - // Always fetch remote to merge more contacts - const remoteRelays = relayUrls.filter(url => !url.includes('localhost') && !url.includes('127.0.0.1')) - if (remoteRelays.length > 0) { - try { - const remoteEvents = await lastValueFrom( - relayPool - .req(remoteRelays, { kinds: [3], authors: [pubkey] }) - .pipe(completeOnEose(), takeUntil(timer(6000)), toArray()) - ) - if (remoteEvents.length > 0) { - const sortedRemote = (remoteEvents as Array<{ created_at: number; tags: string[][] }>). - sort((a, b) => b.created_at - a.created_at) - const contactList = sortedRemote[0] - for (const tag of contactList.tags) { - if (tag[0] === 'p' && tag[1]) { - followed.add(tag[1]) - } - } - } - } catch { - // ignore - } - } + // merged already via streams console.log('πŸ“Š Contact events fetched:', events.length) diff --git a/src/services/exploreService.ts b/src/services/exploreService.ts index d283f3e3..04cff231 100644 --- a/src/services/exploreService.ts +++ b/src/services/exploreService.ts @@ -1,5 +1,5 @@ import { RelayPool, completeOnEose } from 'applesauce-relay' -import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs' +import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs' import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers' import { NostrEvent } from 'nostr-tools' import { Helpers } from 'applesauce-core' @@ -66,49 +66,18 @@ export const fetchBlogPostsFromAuthors = async ( } } - // Phase 1: local relays fast path - if (localRelays.length > 0) { - try { - const localEvents = await lastValueFrom( - relayPool - .req(localRelays, { - kinds: [30023], - authors: pubkeys, - limit: 100 - }) - .pipe( - completeOnEose(), - takeUntil(timer(1200)), - toArray() - ) - ) - processEvents(localEvents) - } catch { - // ignore - } - } - - // Phase 2: always query remote relays to fill in missing content - if (remoteRelays.length > 0) { - try { - const remoteEvents = await lastValueFrom( - relayPool - .req(remoteRelays, { - kinds: [30023], - authors: pubkeys, - limit: 100 - }) - .pipe( - completeOnEose(), - takeUntil(timer(6000)), - toArray() - ) - ) - processEvents(remoteEvents) - } catch { - // ignore - } - } + const local$ = localRelays.length > 0 + ? relayPool + .req(localRelays, { kinds: [30023], authors: pubkeys, limit: 100 }) + .pipe(completeOnEose(), takeUntil(timer(1200))) + : new Observable((sub) => sub.complete()) + const remote$ = remoteRelays.length > 0 + ? relayPool + .req(remoteRelays, { kinds: [30023], authors: pubkeys, limit: 100 }) + .pipe(completeOnEose(), takeUntil(timer(6000))) + : new Observable((sub) => sub.complete()) + const events = await lastValueFrom(merge(local$, remote$).pipe(toArray())) + processEvents(events) console.log('πŸ“Š Blog post events fetched (unique):', uniqueEvents.size) diff --git a/src/services/highlights/fetchByAuthor.ts b/src/services/highlights/fetchByAuthor.ts index 14f908bc..7c6b71c6 100644 --- a/src/services/highlights/fetchByAuthor.ts +++ b/src/services/highlights/fetchByAuthor.ts @@ -1,5 +1,5 @@ import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay' -import { lastValueFrom, takeUntil, timer, tap, toArray } from 'rxjs' +import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs' import { NostrEvent } from 'nostr-tools' import { Highlight } from '../../types/highlights' import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers' @@ -19,52 +19,37 @@ export const fetchHighlights = async ( const { local: localRelays, remote: remoteRelays } = partitionRelays(ordered) const seenIds = new Set() - let rawEvents: NostrEvent[] = [] - if (localRelays.length > 0) { - try { - rawEvents = await lastValueFrom( - relayPool - .req(localRelays, { kinds: [9802], authors: [pubkey] }) - .pipe( - onlyEvents(), - tap((event: NostrEvent) => { - if (!seenIds.has(event.id)) { - seenIds.add(event.id) - if (onHighlight) onHighlight(eventToHighlight(event)) - } - }), - completeOnEose(), - takeUntil(timer(1200)), - toArray() - ) - ) - } catch { - rawEvents = [] - } - } - if (remoteRelays.length > 0) { - try { - const remoteEvents = await lastValueFrom( - relayPool - .req(remoteRelays, { kinds: [9802], authors: [pubkey] }) - .pipe( - onlyEvents(), - tap((event: NostrEvent) => { - if (!seenIds.has(event.id)) { - seenIds.add(event.id) - if (onHighlight) onHighlight(eventToHighlight(event)) - } - }), - completeOnEose(), - takeUntil(timer(6000)), - toArray() - ) - ) - rawEvents = rawEvents.concat(remoteEvents) - } catch { - // ignore - } - } + const local$ = localRelays.length > 0 + ? relayPool + .req(localRelays, { kinds: [9802], authors: [pubkey] }) + .pipe( + onlyEvents(), + tap((event: NostrEvent) => { + if (!seenIds.has(event.id)) { + seenIds.add(event.id) + if (onHighlight) onHighlight(eventToHighlight(event)) + } + }), + completeOnEose(), + takeUntil(timer(1200)) + ) + : new Observable((sub) => sub.complete()) + const remote$ = remoteRelays.length > 0 + ? relayPool + .req(remoteRelays, { kinds: [9802], authors: [pubkey] }) + .pipe( + onlyEvents(), + tap((event: NostrEvent) => { + if (!seenIds.has(event.id)) { + seenIds.add(event.id) + if (onHighlight) onHighlight(eventToHighlight(event)) + } + }), + completeOnEose(), + takeUntil(timer(6000)) + ) + : new Observable((sub) => sub.complete()) + const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray())) await rebroadcastEvents(rawEvents, relayPool, settings) const uniqueEvents = dedupeHighlights(rawEvents) diff --git a/src/services/highlights/fetchForArticle.ts b/src/services/highlights/fetchForArticle.ts index 9d72beab..9f5ea08d 100644 --- a/src/services/highlights/fetchForArticle.ts +++ b/src/services/highlights/fetchForArticle.ts @@ -1,5 +1,5 @@ import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay' -import { lastValueFrom, takeUntil, timer, tap, toArray } from 'rxjs' +import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs' import { NostrEvent } from 'nostr-tools' import { Highlight } from '../../types/highlights' import { RELAYS } from '../../config/relays' @@ -26,96 +26,63 @@ export const fetchHighlightsForArticle = async ( const orderedRelays = prioritizeLocalRelays(RELAYS) const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays) - let aTagEvents: NostrEvent[] = [] - if (localRelays.length > 0) { - try { - aTagEvents = await lastValueFrom( - relayPool - .req(localRelays, { kinds: [9802], '#a': [articleCoordinate] }) - .pipe( - onlyEvents(), - tap((event: NostrEvent) => { - const highlight = processEvent(event) - if (highlight && onHighlight) onHighlight(highlight) - }), - completeOnEose(), - takeUntil(timer(1200)), - toArray() - ) - ) - } catch { - aTagEvents = [] - } - } - - // Always query remote relays to merge additional highlights - if (remoteRelays.length > 0) { - try { - const aRemote = await lastValueFrom( - relayPool - .req(remoteRelays, { kinds: [9802], '#a': [articleCoordinate] }) - .pipe( - onlyEvents(), - tap((event: NostrEvent) => { - const highlight = processEvent(event) - if (highlight && onHighlight) onHighlight(highlight) - }), - completeOnEose(), - takeUntil(timer(6000)), - toArray() - ) - ) - aTagEvents = aTagEvents.concat(aRemote) - } catch { - // ignore - } - } + const aLocal$ = localRelays.length > 0 + ? relayPool + .req(localRelays, { kinds: [9802], '#a': [articleCoordinate] }) + .pipe( + onlyEvents(), + tap((event: NostrEvent) => { + const highlight = processEvent(event) + if (highlight && onHighlight) onHighlight(highlight) + }), + completeOnEose(), + takeUntil(timer(1200)) + ) + : new Observable((sub) => sub.complete()) + const aRemote$ = remoteRelays.length > 0 + ? relayPool + .req(remoteRelays, { kinds: [9802], '#a': [articleCoordinate] }) + .pipe( + onlyEvents(), + tap((event: NostrEvent) => { + const highlight = processEvent(event) + if (highlight && onHighlight) onHighlight(highlight) + }), + completeOnEose(), + takeUntil(timer(6000)) + ) + : new Observable((sub) => sub.complete()) + const aTagEvents: NostrEvent[] = await lastValueFrom(merge(aLocal$, aRemote$).pipe(toArray())) let eTagEvents: NostrEvent[] = [] if (eventId) { - if (localRelays.length > 0) { - try { - eTagEvents = await lastValueFrom( - relayPool - .req(localRelays, { kinds: [9802], '#e': [eventId] }) - .pipe( - onlyEvents(), - tap((event: NostrEvent) => { - const highlight = processEvent(event) - if (highlight && onHighlight) onHighlight(highlight) - }), - completeOnEose(), - takeUntil(timer(1200)), - toArray() - ) - ) - } catch { - eTagEvents = [] - } - } - - // Always query remote for e-tag too - if (remoteRelays.length > 0) { - try { - const eRemote = await lastValueFrom( - relayPool - .req(remoteRelays, { kinds: [9802], '#e': [eventId] }) - .pipe( - onlyEvents(), - tap((event: NostrEvent) => { - const highlight = processEvent(event) - if (highlight && onHighlight) onHighlight(highlight) - }), - completeOnEose(), - takeUntil(timer(6000)), - toArray() - ) - ) - eTagEvents = eTagEvents.concat(eRemote) - } catch { - // ignore - } - } + const eLocal$ = localRelays.length > 0 + ? relayPool + .req(localRelays, { kinds: [9802], '#e': [eventId] }) + .pipe( + onlyEvents(), + tap((event: NostrEvent) => { + const highlight = processEvent(event) + if (highlight && onHighlight) onHighlight(highlight) + }), + completeOnEose(), + takeUntil(timer(1200)) + ) + : new Observable((sub) => sub.complete()) + const eRemote$ = remoteRelays.length > 0 + ? relayPool + .req(remoteRelays, { kinds: [9802], '#e': [eventId] }) + .pipe( + onlyEvents(), + tap((event: NostrEvent) => { + const highlight = processEvent(event) + if (highlight && onHighlight) onHighlight(highlight) + }), + completeOnEose(), + takeUntil(timer(6000)) + ) + : new Observable((sub) => sub.complete()) + eTagEvents = await lastValueFrom(merge(eLocal$, eRemote$).pipe(toArray())) } const rawEvents = [...aTagEvents, ...eTagEvents] diff --git a/src/services/highlights/fetchForUrl.ts b/src/services/highlights/fetchForUrl.ts index f8dc91a4..b71b6ebf 100644 --- a/src/services/highlights/fetchForUrl.ts +++ b/src/services/highlights/fetchForUrl.ts @@ -1,5 +1,5 @@ import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay' -import { lastValueFrom, takeUntil, timer, tap, toArray } from 'rxjs' +import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs' import { NostrEvent } from 'nostr-tools' import { Highlight } from '../../types/highlights' import { RELAYS } from '../../config/relays' @@ -18,48 +18,33 @@ export const fetchHighlightsForUrl = async ( const seenIds = new Set() const orderedRelaysUrl = prioritizeLocalRelays(RELAYS) const { local: localRelaysUrl, remote: remoteRelaysUrl } = partitionRelays(orderedRelaysUrl) - let rawEvents: NostrEvent[] = [] - if (localRelaysUrl.length > 0) { - try { - rawEvents = await lastValueFrom( - relayPool - .req(localRelaysUrl, { kinds: [9802], '#r': [url] }) - .pipe( - onlyEvents(), - tap((event: NostrEvent) => { - seenIds.add(event.id) - if (onHighlight) onHighlight(eventToHighlight(event)) - }), - completeOnEose(), - takeUntil(timer(1200)), - toArray() - ) - ) - } catch { - rawEvents = [] - } - } - if (remoteRelaysUrl.length > 0) { - try { - const remote = await lastValueFrom( - relayPool - .req(remoteRelaysUrl, { kinds: [9802], '#r': [url] }) - .pipe( - onlyEvents(), - tap((event: NostrEvent) => { - seenIds.add(event.id) - if (onHighlight) onHighlight(eventToHighlight(event)) - }), - completeOnEose(), - takeUntil(timer(6000)), - toArray() - ) - ) - rawEvents = rawEvents.concat(remote) - } catch { - // ignore - } - } + const local$ = localRelaysUrl.length > 0 + ? relayPool + .req(localRelaysUrl, { kinds: [9802], '#r': [url] }) + .pipe( + onlyEvents(), + tap((event: NostrEvent) => { + seenIds.add(event.id) + if (onHighlight) onHighlight(eventToHighlight(event)) + }), + completeOnEose(), + takeUntil(timer(1200)) + ) + : new Observable((sub) => sub.complete()) + const remote$ = remoteRelaysUrl.length > 0 + ? relayPool + .req(remoteRelaysUrl, { kinds: [9802], '#r': [url] }) + .pipe( + onlyEvents(), + tap((event: NostrEvent) => { + seenIds.add(event.id) + if (onHighlight) onHighlight(eventToHighlight(event)) + }), + completeOnEose(), + takeUntil(timer(6000)) + ) + : new Observable((sub) => sub.complete()) + const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray())) await rebroadcastEvents(rawEvents, relayPool, settings) const uniqueEvents = dedupeHighlights(rawEvents) const highlights: Highlight[] = uniqueEvents.map(eventToHighlight) diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index b162f9d0..692dcba6 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -94,3 +94,27 @@ export const prioritizeLocalRelays = (relayUrls: string[]): string[] => { return out } +// Parallel request helper +import { completeOnEose, onlyEvents, RelayPool } from 'applesauce-relay' +import { Observable, takeUntil, timer } from 'rxjs' + +export function createParallelReqStreams( + relayPool: RelayPool, + localRelays: string[], + remoteRelays: string[], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + filter: any, + localTimeoutMs = 1200, + remoteTimeoutMs = 6000 +): { local$: Observable; remote$: Observable } { + const local$ = (localRelays.length > 0) + ? relayPool.req(localRelays, filter).pipe(onlyEvents(), completeOnEose(), takeUntil(timer(localTimeoutMs))) + : new Observable((sub) => { sub.complete() }) + + const remote$ = (remoteRelays.length > 0) + ? relayPool.req(remoteRelays, filter).pipe(onlyEvents(), completeOnEose(), takeUntil(timer(remoteTimeoutMs))) + : new Observable((sub) => { sub.complete() }) + + return { local$, remote$ } +} + From 68c9623c35fef09c48bb51a6a676fb274298531e Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 12 Oct 2025 23:22:53 +0200 Subject: [PATCH 11/16] ux(bookmarks): keep list visible during refresh; show spinner only; no wipe --- src/components/BookmarkList.tsx | 22 ++++++++++++---------- src/hooks/useBookmarksData.ts | 6 +++++- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/components/BookmarkList.tsx b/src/components/BookmarkList.tsx index 2f0a8a28..5cb96434 100644 --- a/src/components/BookmarkList.tsx +++ b/src/components/BookmarkList.tsx @@ -111,16 +111,18 @@ export const BookmarkList: React.FC = ({ isMobile={isMobile} /> - {loading ? ( -
- -
- ) : allIndividualBookmarks.length === 0 ? ( -
-

No bookmarks found.

-

Add bookmarks using your nostr client to see them here.

-

If you aren't on nostr yet, start here: nstart.me

-
+ {allIndividualBookmarks.length === 0 ? ( + loading ? ( +
+ +
+ ) : ( +
+

No bookmarks found.

+

Add bookmarks using your nostr client to see them here.

+

If you aren't on nostr yet, start here: nstart.me

+
+ ) ) : (
diff --git a/src/hooks/useBookmarksData.ts b/src/hooks/useBookmarksData.ts index e9c519f8..adec2fae 100644 --- a/src/hooks/useBookmarksData.ts +++ b/src/hooks/useBookmarksData.ts @@ -44,10 +44,14 @@ export const useBookmarksData = ({ const handleFetchBookmarks = useCallback(async () => { if (!relayPool || !activeAccount) return + // don't clear existing bookmarks: we keep UI stable and show spinner unobtrusively setBookmarksLoading(true) try { const fullAccount = accountManager.getActive() - await fetchBookmarks(relayPool, fullAccount || activeAccount, setBookmarks, settings) + // merge-friendly: updater form that preserves visible list until replacement + await fetchBookmarks(relayPool, fullAccount || activeAccount, (next) => { + setBookmarks(() => next) + }, settings) } finally { setBookmarksLoading(false) } From d2ebcd8fbe0a5525da22d7fd3512861fc0ca76b1 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 12 Oct 2025 23:25:05 +0200 Subject: [PATCH 12/16] ux(explore): keep posts visible during refresh; inline spinner; no list wipe --- src/components/Explore.tsx | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index 91ee344d..e871217b 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -110,17 +110,6 @@ const Explore: React.FC = ({ relayPool }) => { return `/a/${naddr}` } - if (loading) { - return ( -
-
- -

Loading blog posts from your friends...

-
-
- ) - } - if (error) { return (
@@ -143,6 +132,12 @@ const Explore: React.FC = ({ relayPool }) => { Discover blog posts from your friends on Nostr

+ {loading && ( +
+ + Refreshing posts… +
+ )}
{blogPosts.map((post) => ( = ({ relayPool }) => { href={getPostUrl(post)} /> ))} + {!loading && blogPosts.length === 0 && ( +
+

No blog posts found yet.

+
+ )}
) From b3fc9bb5c34f23c26ab1ac8c893fb975c508b136 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 12 Oct 2025 23:26:18 +0200 Subject: [PATCH 13/16] ux(bookmarks): avoid clearing list when no new events; decouple refetch from route changes --- src/hooks/useBookmarksData.ts | 10 ++++++++-- src/services/bookmarkService.ts | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/hooks/useBookmarksData.ts b/src/hooks/useBookmarksData.ts index adec2fae..2eb550f4 100644 --- a/src/hooks/useBookmarksData.ts +++ b/src/hooks/useBookmarksData.ts @@ -106,15 +106,21 @@ export const useBookmarksData = ({ } }, [relayPool, activeAccount, isRefreshing, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts]) - // Load initial data + // Load initial data (avoid clearing on route-only changes) useEffect(() => { if (!relayPool || !activeAccount) return + // Only (re)fetch bookmarks when account or relayPool changes, not on naddr route changes handleFetchBookmarks() + }, [relayPool, activeAccount, handleFetchBookmarks]) + + // Fetch highlights/contacts independently to avoid disturbing bookmarks + useEffect(() => { + if (!relayPool || !activeAccount) return if (!naddr) { handleFetchHighlights() } handleFetchContacts() - }, [relayPool, activeAccount, naddr, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts]) + }, [relayPool, activeAccount, naddr, handleFetchHighlights, handleFetchContacts]) return { bookmarks, diff --git a/src/services/bookmarkService.ts b/src/services/bookmarkService.ts index 1b3346ee..3f461055 100644 --- a/src/services/bookmarkService.ts +++ b/src/services/bookmarkService.ts @@ -73,7 +73,7 @@ export const fetchBookmarks = async ( const bookmarkListEvents = dedupeNip51Events(rawEvents) console.log('πŸ“‹ After deduplication:', bookmarkListEvents.length, 'bookmark events') if (bookmarkListEvents.length === 0) { - setBookmarks([]) + // Keep existing bookmarks visible; do not clear list if nothing new found return } // Aggregate across events From b8b9f82d91ff1fbafad7b39a4e8f42c4f4ed92d0 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 12 Oct 2025 23:30:56 +0200 Subject: [PATCH 14/16] ux(explore): preserve posts across navigations; seed from cache; merge streamed + final --- src/components/Explore.tsx | 26 ++++++++++++++++++++-- src/services/exploreCache.ts | 42 ++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 src/services/exploreCache.ts diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index e871217b..1c0ff642 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -7,6 +7,7 @@ import { nip19 } from 'nostr-tools' import { fetchContacts } from '../services/contactService' import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService' import BlogPostCard from './BlogPostCard' +import { getCachedPosts, upsertCachedPost, setCachedPosts } from '../services/exploreCache' interface ExploreProps { relayPool: RelayPool @@ -27,9 +28,16 @@ const Explore: React.FC = ({ relayPool }) => { } try { + // show spinner but keep existing posts setLoading(true) setError(null) + // Seed from in-memory cache if available to avoid empty flash + const cached = getCachedPosts(activeAccount.pubkey) + if (cached && cached.length > 0 && blogPosts.length === 0) { + setBlogPosts(cached) + } + // Fetch the user's contacts (friends) const contacts = await fetchContacts( relayPool, @@ -43,6 +51,7 @@ const Explore: React.FC = ({ relayPool }) => { Array.from(partial), relayUrls, (post) => { + // merge into UI and cache as we stream setBlogPosts((prev) => { const exists = prev.some(p => p.event.id === post.event.id) if (exists) return prev @@ -53,17 +62,20 @@ const Explore: React.FC = ({ relayPool }) => { return timeB - timeA }) }) + setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post)) } ).then((all) => { // Ensure union of streamed + final is displayed setBlogPosts((prev) => { const byId = new Map(prev.map(p => [p.event.id, p])) for (const post of all) byId.set(post.event.id, post) - return Array.from(byId.values()).sort((a, b) => { + const merged = Array.from(byId.values()).sort((a, b) => { const timeA = a.published || a.event.created_at const timeB = b.published || b.event.created_at return timeB - timeA }) + setCachedPosts(activeAccount.pubkey, merged) + return merged }) }) } @@ -84,7 +96,17 @@ const Explore: React.FC = ({ relayPool }) => { setError('No blog posts found from your friends yet') } - setBlogPosts(posts) + setBlogPosts((prev) => { + const byId = new Map(prev.map(p => [p.event.id, p])) + for (const post of posts) byId.set(post.event.id, post) + const merged = Array.from(byId.values()).sort((a, b) => { + const timeA = a.published || a.event.created_at + const timeB = b.published || b.event.created_at + return timeB - timeA + }) + setCachedPosts(activeAccount.pubkey, merged) + return merged + }) } catch (err) { console.error('Failed to load blog posts:', err) setError('Failed to load blog posts. Please try again.') diff --git a/src/services/exploreCache.ts b/src/services/exploreCache.ts new file mode 100644 index 00000000..45fe3c7e --- /dev/null +++ b/src/services/exploreCache.ts @@ -0,0 +1,42 @@ +import { NostrEvent } from 'nostr-tools' + +export interface CachedBlogPostPreview { + event: NostrEvent + title: string + summary?: string + image?: string + published?: number + author: string +} + +type CacheValue = { + posts: CachedBlogPostPreview[] + timestamp: number +} + +const exploreCache = new Map() // key: pubkey + +export function getCachedPosts(pubkey: string): CachedBlogPostPreview[] | null { + const entry = exploreCache.get(pubkey) + if (!entry) return null + return entry.posts +} + +export function setCachedPosts(pubkey: string, posts: CachedBlogPostPreview[]): void { + exploreCache.set(pubkey, { posts, timestamp: Date.now() }) +} + +export function upsertCachedPost(pubkey: string, post: CachedBlogPostPreview): CachedBlogPostPreview[] { + const current = exploreCache.get(pubkey)?.posts || [] + const byId = new Map(current.map(p => [p.event.id, p])) + byId.set(post.event.id, post) + const merged = Array.from(byId.values()).sort((a, b) => { + const ta = a.published || a.event.created_at + const tb = b.published || b.event.created_at + return tb - ta + }) + setCachedPosts(pubkey, merged) + return merged +} + + From 89d5ba4c3775d8ffb9862dc5e0e9434d9f2080f9 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 12 Oct 2025 23:33:28 +0200 Subject: [PATCH 15/16] ui(explore): shrink refresh spinner footprint; inline-sized loading row --- src/index.css | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/index.css b/src/index.css index 2b03544a..1277ef73 100644 --- a/src/index.css +++ b/src/index.css @@ -3260,10 +3260,15 @@ body.mobile-sidebar-open { align-items: center; justify-content: center; gap: 1rem; - min-height: 50vh; color: rgba(255, 255, 255, 0.7); } +/* Compact, inline loading row for Explore refresh */ +.explore-loading { + min-height: 0; + padding: 0.25rem 0; +} + .explore-error { color: #ff6b6b; } From 886d5ac08ca058296b678ed9198e49024d4ed0da Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 12 Oct 2025 23:35:23 +0200 Subject: [PATCH 16/16] chore(lint): satisfy react-hooks dependency in Explore; lints clean --- src/components/Explore.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index 1c0ff642..cd57310f 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -116,7 +116,7 @@ const Explore: React.FC = ({ relayPool }) => { } loadBlogPosts() - }, [relayPool, activeAccount]) + }, [relayPool, activeAccount, blogPosts.length]) const getPostUrl = (post: BlogPostPreview) => { // Get the d-tag identifier