From 765ce0ac5eeff0beb6b4c609718d61454e10ab2f Mon Sep 17 00:00:00 2001 From: Gigi Date: Wed, 15 Oct 2025 09:15:48 +0200 Subject: [PATCH 01/11] feat(network): centralize relay timeouts and contacts remote timeout --- src/config/network.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/config/network.ts diff --git a/src/config/network.ts b/src/config/network.ts new file mode 100644 index 00000000..6d3ffd33 --- /dev/null +++ b/src/config/network.ts @@ -0,0 +1,12 @@ +// Centralized network configuration for relay queries +// Keep timeouts modest for local-first, longer for remote; tweak per use-case + +export const LOCAL_TIMEOUT_MS = 1200 +export const REMOTE_TIMEOUT_MS = 6000 + +// Contacts often need a bit more time on mobile networks +export const CONTACTS_REMOTE_TIMEOUT_MS = 9000 + +// Future knobs could live here (e.g., max limits per kind) + + From 02ec8dd936c90ff7590c5775128697c801dbd1e7 Mon Sep 17 00:00:00 2001 From: Gigi Date: Wed, 15 Oct 2025 09:17:51 +0200 Subject: [PATCH 02/11] feat(fetch): add unified queryEvents helper and stream contacts partials with extended timeout --- src/services/contactService.ts | 43 ++++++++++++--------- src/services/dataFetch.ts | 70 ++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 19 deletions(-) create mode 100644 src/services/dataFetch.ts diff --git a/src/services/contactService.ts b/src/services/contactService.ts index 00dabd78..468777b3 100644 --- a/src/services/contactService.ts +++ b/src/services/contactService.ts @@ -1,6 +1,8 @@ -import { RelayPool, completeOnEose } from 'applesauce-relay' -import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs' +import { RelayPool } from 'applesauce-relay' +import { Observable } from 'rxjs' import { prioritizeLocalRelays } from '../utils/helpers' +import { queryEvents } from './dataFetch' +import { CONTACTS_REMOTE_TIMEOUT_MS } from '../config/network' /** * Fetches the contact list (follows) for a specific user @@ -15,24 +17,27 @@ export const fetchContacts = async ( ): Promise> => { try { const relayUrls = prioritizeLocalRelays(Array.from(relayPool.relays.values()).map(relay => relay.url)) - console.log('🔍 Fetching contacts (kind 3) for user:', pubkey) - - // Local-first quick attempt - const localRelays = relayUrls.filter(url => url.includes('localhost') || url.includes('127.0.0.1')) - 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 partialFollowed = new Set() + const events = await queryEvents( + relayPool, + { kinds: [3], authors: [pubkey] }, + { + relayUrls, + remoteTimeoutMs: CONTACTS_REMOTE_TIMEOUT_MS, + onEvent: (event: { created_at: number; tags: string[][] }) => { + // Stream partials as we see any contact list + for (const tag of event.tags) { + if (tag[0] === 'p' && tag[1]) { + partialFollowed.add(tag[1]) + } + } + if (onPartial && partialFollowed.size > 0) { + onPartial(new Set(partialFollowed)) + } + } + } ) const followed = new Set() if (events.length > 0) { diff --git a/src/services/dataFetch.ts b/src/services/dataFetch.ts new file mode 100644 index 00000000..f4f31bb4 --- /dev/null +++ b/src/services/dataFetch.ts @@ -0,0 +1,70 @@ +import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay' +import { Observable, merge, takeUntil, timer, toArray, tap, lastValueFrom } from 'rxjs' +import { NostrEvent } from 'nostr-tools' +import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers' +import { LOCAL_TIMEOUT_MS, REMOTE_TIMEOUT_MS } from '../config/network' + +export interface QueryOptions { + relayUrls?: string[] + localTimeoutMs?: number + remoteTimeoutMs?: number + onEvent?: (event: NostrEvent) => void +} + +/** + * Unified local-first query helper with optional streaming callback. + * Returns all collected events (deduped by id) after both streams complete or time out. + */ +export async function queryEvents( + relayPool: RelayPool, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + filter: any, + options: QueryOptions = {} +): Promise { + const { + relayUrls, + localTimeoutMs = LOCAL_TIMEOUT_MS, + remoteTimeoutMs = REMOTE_TIMEOUT_MS, + onEvent + } = options + + const urls = relayUrls && relayUrls.length > 0 + ? relayUrls + : Array.from(relayPool.relays.values()).map(r => r.url) + + const ordered = prioritizeLocalRelays(urls) + const { local: localRelays, remote: remoteRelays } = partitionRelays(ordered) + + const local$: Observable = localRelays.length > 0 + ? relayPool + .req(localRelays, filter) + .pipe( + onlyEvents(), + onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}), + completeOnEose(), + takeUntil(timer(localTimeoutMs)) + ) as unknown as Observable + : new Observable((sub) => sub.complete()) + + const remote$: Observable = remoteRelays.length > 0 + ? relayPool + .req(remoteRelays, filter) + .pipe( + onlyEvents(), + onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}), + completeOnEose(), + takeUntil(timer(remoteTimeoutMs)) + ) as unknown as Observable + : new Observable((sub) => sub.complete()) + + const events = await lastValueFrom(merge(local$, remote$).pipe(toArray())) + + // Deduplicate by id (callers can perform higher-level replaceable grouping if needed) + const byId = new Map() + for (const ev of events) { + if (!byId.has(ev.id)) byId.set(ev.id, ev) + } + return Array.from(byId.values()) +} + + From 44d6b1fb2aee8e3d8aaeb24712d05c82b9dd83c0 Mon Sep 17 00:00:00 2001 From: Gigi Date: Wed, 15 Oct 2025 09:19:10 +0200 Subject: [PATCH 03/11] fix(explore): prevent refresh loop and avoid false empty-follows error; always load nostrverse --- src/components/Explore.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index 79dd801e..f23b10a1 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -152,9 +152,13 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti ) if (contacts.size === 0) { - setError('You are not following anyone yet. Follow some people to see their content!') - setLoading(false) - return + // If we already have any cached or previously shown data, do not block the UI. + const hasAnyData = (blogPosts.length > 0) || (highlights.length > 0) + if (!hasAnyData) { + // No friends and no cached content: set a soft hint, but still proceed to load nostrverse. + setError(null) + } + // Continue without returning: still fetch nostrverse content below. } // Store final followed pubkeys @@ -202,7 +206,9 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti }) } - if (uniquePosts.length === 0 && uniqueHighlights.length === 0) { + if (contacts.size === 0 && uniquePosts.length === 0 && uniqueHighlights.length === 0) { + setError('You are not following anyone yet. Follow some people to see their content!') + } else if (uniquePosts.length === 0 && uniqueHighlights.length === 0) { setError('No content found yet') } @@ -220,7 +226,7 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti } loadData() - }, [relayPool, activeAccount, blogPosts.length, highlights.length, refreshTrigger, eventStore, settings]) + }, [relayPool, activeAccount, refreshTrigger, eventStore, settings]) // Pull-to-refresh const pullToRefreshState = usePullToRefresh(exploreContainerRef, { From bea62ddc4bbb5ea83da11bfa07f5662ad016356d Mon Sep 17 00:00:00 2001 From: Gigi Date: Wed, 15 Oct 2025 09:26:33 +0200 Subject: [PATCH 04/11] refactor(fetch): migrate exploreService and fetchHighlightsFromAuthors to use queryEvents --- src/services/exploreService.ts | 62 +++++++++------------ src/services/highlights/fetchFromAuthors.ts | 55 +++++------------- 2 files changed, 39 insertions(+), 78 deletions(-) diff --git a/src/services/exploreService.ts b/src/services/exploreService.ts index 04cff231..189a5bdf 100644 --- a/src/services/exploreService.ts +++ b/src/services/exploreService.ts @@ -1,8 +1,7 @@ -import { RelayPool, completeOnEose } from 'applesauce-relay' -import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs' -import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers' +import { RelayPool } from 'applesauce-relay' import { NostrEvent } from 'nostr-tools' import { Helpers } from 'applesauce-core' +import { queryEvents } from './dataFetch' const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers @@ -35,49 +34,38 @@ export const fetchBlogPostsFromAuthors = async ( } console.log('📚 Fetching blog posts (kind 30023) from', pubkeys.length, 'authors') - - const prioritized = prioritizeLocalRelays(relayUrls) - const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized) // 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 + const events = await queryEvents( + relayPool, + { kinds: [30023], authors: pubkeys, limit: 100 }, + { + relayUrls, + onEvent: (event: NostrEvent) => { + 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) } - onPost(post) } } } - } - - 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/fetchFromAuthors.ts b/src/services/highlights/fetchFromAuthors.ts index 2eb4ffc6..b157be5b 100644 --- a/src/services/highlights/fetchFromAuthors.ts +++ b/src/services/highlights/fetchFromAuthors.ts @@ -1,9 +1,8 @@ -import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay' -import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs' +import { RelayPool } from 'applesauce-relay' import { NostrEvent } from 'nostr-tools' import { Highlight } from '../../types/highlights' -import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers' import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor' +import { queryEvents } from '../dataFetch' /** * Fetches highlights (kind:9802) from a list of pubkeys (friends) @@ -24,46 +23,20 @@ export const fetchHighlightsFromAuthors = async ( } console.log('💡 Fetching highlights (kind 9802) from', pubkeys.length, 'authors') - - const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) - const prioritized = prioritizeLocalRelays(relayUrls) - const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized) const seenIds = new Set() - - const local$ = localRelays.length > 0 - ? relayPool - .req(localRelays, { kinds: [9802], authors: pubkeys, limit: 200 }) - .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: pubkeys, limit: 200 }) - .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())) + const rawEvents = await queryEvents( + relayPool, + { kinds: [9802], authors: pubkeys, limit: 200 }, + { + onEvent: (event: NostrEvent) => { + if (!seenIds.has(event.id)) { + seenIds.add(event.id) + if (onHighlight) onHighlight(eventToHighlight(event)) + } + } + } + ) const uniqueEvents = dedupeHighlights(rawEvents) const highlights = uniqueEvents.map(eventToHighlight) From 5b7488295ca3078a507f62a0c6e704af0693ae18 Mon Sep 17 00:00:00 2001 From: Gigi Date: Wed, 15 Oct 2025 09:28:35 +0200 Subject: [PATCH 05/11] refactor(fetch): migrate nostrverseService, bookmarkService, and libraryService to use queryEvents --- src/services/bookmarkService.ts | 41 +++++--------- src/services/libraryService.ts | 91 ++++--------------------------- src/services/nostrverseService.ts | 64 +++++++--------------- 3 files changed, 45 insertions(+), 151 deletions(-) diff --git a/src/services/bookmarkService.ts b/src/services/bookmarkService.ts index 3f461055..c9e42963 100644 --- a/src/services/bookmarkService.ts +++ b/src/services/bookmarkService.ts @@ -1,5 +1,4 @@ -import { RelayPool, completeOnEose } from 'applesauce-relay' -import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs' +import { RelayPool } from 'applesauce-relay' import { AccountWithExtension, NostrEvent, @@ -16,7 +15,7 @@ import { Bookmark } from '../types/bookmarks' import { collectBookmarksFromEvents } from './bookmarkProcessing.ts' import { UserSettings } from './settingsService' import { rebroadcastEvents } from './rebroadcastService' -import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers' +import { queryEvents } from './dataFetch' @@ -31,23 +30,14 @@ export const fetchBookmarks = async ( if (!isAccountWithExtension(activeAccount)) { throw new Error('Invalid account object provided') } - // 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 - 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('🔍 Fetching bookmark events') + + const rawEvents = await queryEvents( + relayPool, + { kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] }, + {} + ) console.log('📊 Raw events fetched:', rawEvents.length, 'events') // Rebroadcast bookmark events to local/all relays based on settings @@ -111,14 +101,11 @@ export const fetchBookmarks = async ( let idToEvent: Map = new Map() if (noteIds.length > 0) { try { - const { local: localHydrate, remote: remoteHydrate } = partitionRelays(relayUrls) - 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())) + const events = await queryEvents( + relayPool, + { ids: noteIds }, + { localTimeoutMs: 800, remoteTimeoutMs: 2500 } + ) 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/libraryService.ts b/src/services/libraryService.ts index 0c8b3729..8818b818 100644 --- a/src/services/libraryService.ts +++ b/src/services/libraryService.ts @@ -1,11 +1,10 @@ -import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay' -import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs' +import { RelayPool } from 'applesauce-relay' import { NostrEvent } from 'nostr-tools' import { Helpers } from 'applesauce-core' import { RELAYS } from '../config/relays' -import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers' import { MARK_AS_READ_EMOJI } from './reactionService' import { BlogPostPreview } from './exploreService' +import { queryEvents } from './dataFetch' const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers @@ -28,58 +27,11 @@ export async function fetchReadArticles( userPubkey: string ): Promise { try { - const orderedRelays = prioritizeLocalRelays(RELAYS) - const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays) - - // Fetch kind:7 reactions (nostr-native articles) - const kind7Local$ = localRelays.length > 0 - ? relayPool - .req(localRelays, { kinds: [7], authors: [userPubkey] }) - .pipe( - onlyEvents(), - completeOnEose(), - takeUntil(timer(1200)) - ) - : new Observable((sub) => sub.complete()) - - const kind7Remote$ = remoteRelays.length > 0 - ? relayPool - .req(remoteRelays, { kinds: [7], authors: [userPubkey] }) - .pipe( - onlyEvents(), - completeOnEose(), - takeUntil(timer(6000)) - ) - : new Observable((sub) => sub.complete()) - - const kind7Events: NostrEvent[] = await lastValueFrom( - merge(kind7Local$, kind7Remote$).pipe(toArray()) - ) - - // Fetch kind:17 reactions (external URLs) - const kind17Local$ = localRelays.length > 0 - ? relayPool - .req(localRelays, { kinds: [17], authors: [userPubkey] }) - .pipe( - onlyEvents(), - completeOnEose(), - takeUntil(timer(1200)) - ) - : new Observable((sub) => sub.complete()) - - const kind17Remote$ = remoteRelays.length > 0 - ? relayPool - .req(remoteRelays, { kinds: [17], authors: [userPubkey] }) - .pipe( - onlyEvents(), - completeOnEose(), - takeUntil(timer(6000)) - ) - : new Observable((sub) => sub.complete()) - - const kind17Events: NostrEvent[] = await lastValueFrom( - merge(kind17Local$, kind17Remote$).pipe(toArray()) - ) + // Fetch kind:7 and kind:17 reactions in parallel + const [kind7Events, kind17Events] = await Promise.all([ + queryEvents(relayPool, { kinds: [7], authors: [userPubkey] }, { relayUrls: RELAYS }), + queryEvents(relayPool, { kinds: [17], authors: [userPubkey] }, { relayUrls: RELAYS }) + ]) const readArticles: ReadArticle[] = [] @@ -157,34 +109,13 @@ export async function fetchReadArticlesWithData( return [] } - const orderedRelays = prioritizeLocalRelays(RELAYS) - const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays) - // Fetch the actual article events const eventIds = nostrArticles.map(a => a.eventId!).filter(Boolean) - const local$ = localRelays.length > 0 - ? relayPool - .req(localRelays, { kinds: [30023], ids: eventIds }) - .pipe( - onlyEvents(), - completeOnEose(), - takeUntil(timer(1200)) - ) - : new Observable((sub) => sub.complete()) - - const remote$ = remoteRelays.length > 0 - ? relayPool - .req(remoteRelays, { kinds: [30023], ids: eventIds }) - .pipe( - onlyEvents(), - completeOnEose(), - takeUntil(timer(6000)) - ) - : new Observable((sub) => sub.complete()) - - const articleEvents: NostrEvent[] = await lastValueFrom( - merge(local$, remote$).pipe(toArray()) + const articleEvents = await queryEvents( + relayPool, + { kinds: [30023], ids: eventIds }, + { relayUrls: RELAYS } ) // Deduplicate article events by ID diff --git a/src/services/nostrverseService.ts b/src/services/nostrverseService.ts index aa5a6839..04a98431 100644 --- a/src/services/nostrverseService.ts +++ b/src/services/nostrverseService.ts @@ -1,11 +1,10 @@ -import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay' -import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs' +import { RelayPool } from 'applesauce-relay' import { NostrEvent } from 'nostr-tools' -import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers' import { Helpers } from 'applesauce-core' import { BlogPostPreview } from './exploreService' import { Highlight } from '../types/highlights' import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor' +import { queryEvents } from './dataFetch' const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers @@ -23,36 +22,25 @@ export const fetchNostrverseBlogPosts = async ( ): Promise => { try { console.log('📚 Fetching nostrverse blog posts (kind 30023), limit:', limit) - - const prioritized = prioritizeLocalRelays(relayUrls) - const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized) // Deduplicate replaceable events by keeping the most recent version 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) + const events = await queryEvents( + relayPool, + { kinds: [30023], limit }, + { + relayUrls, + onEvent: (event: NostrEvent) => { + 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) + } } } - } - - const local$ = localRelays.length > 0 - ? relayPool - .req(localRelays, { kinds: [30023], limit }) - .pipe(completeOnEose(), takeUntil(timer(1200)), onlyEvents()) - : new Observable((sub) => sub.complete()) - const remote$ = remoteRelays.length > 0 - ? relayPool - .req(remoteRelays, { kinds: [30023], limit }) - .pipe(completeOnEose(), takeUntil(timer(6000)), onlyEvents()) - : new Observable((sub) => sub.complete()) - const events = await lastValueFrom(merge(local$, remote$).pipe(toArray())) - processEvents(events) + ) console.log('📊 Nostrverse blog post events fetched (unique):', uniqueEvents.size) @@ -93,24 +81,12 @@ export const fetchNostrverseHighlights = async ( ): Promise => { try { console.log('💡 Fetching nostrverse highlights (kind 9802), limit:', limit) - - const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) - const prioritized = prioritizeLocalRelays(relayUrls) - const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized) - const local$ = localRelays.length > 0 - ? relayPool - .req(localRelays, { kinds: [9802], limit }) - .pipe(completeOnEose(), takeUntil(timer(1200)), onlyEvents()) - : new Observable((sub) => sub.complete()) - - const remote$ = remoteRelays.length > 0 - ? relayPool - .req(remoteRelays, { kinds: [9802], limit }) - .pipe(completeOnEose(), takeUntil(timer(6000)), onlyEvents()) - : new Observable((sub) => sub.complete()) - - const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray())) + const rawEvents = await queryEvents( + relayPool, + { kinds: [9802], limit }, + {} + ) const uniqueEvents = dedupeHighlights(rawEvents) const highlights = uniqueEvents.map(eventToHighlight) From 3091ad7fd4af815da2b7d179c15dbe6c3d67dd89 Mon Sep 17 00:00:00 2001 From: Gigi Date: Wed, 15 Oct 2025 09:29:54 +0200 Subject: [PATCH 06/11] feat(write): add unified publishEvent service and refactor highlight and settings to use it --- src/services/highlightCreationService.ts | 63 ++++++------------------ src/services/settingsService.ts | 17 +++---- src/services/writeService.ts | 59 ++++++++++++++++++++++ 3 files changed, 82 insertions(+), 57 deletions(-) create mode 100644 src/services/writeService.ts diff --git a/src/services/highlightCreationService.ts b/src/services/highlightCreationService.ts index edbdd47d..e5a93d21 100644 --- a/src/services/highlightCreationService.ts +++ b/src/services/highlightCreationService.ts @@ -7,8 +7,8 @@ import { Helpers, IEventStore } from 'applesauce-core' import { RELAYS } from '../config/relays' import { Highlight } from '../types/highlights' import { UserSettings } from './settingsService' -import { areAllRelaysLocal } from '../utils/helpers' -import { markEventAsOfflineCreated } from './offlineSyncService' +import { isLocalRelay, areAllRelaysLocal } from '../utils/helpers' +import { publishEvent } from './writeService' // Boris pubkey for zap splits // npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x @@ -118,59 +118,26 @@ export async function createHighlight( // Sign the event const signedEvent = await factory.sign(highlightEvent) - // Publish to all configured relays - let the relay pool handle connection state - const targetRelays = RELAYS - - // Store the event in the local EventStore FIRST for immediate UI display - eventStore.add(signedEvent) - console.log('💾 Stored highlight in EventStore:', signedEvent.id.slice(0, 8)) - - // Check current connection status - are we online or in flight mode? + // Use unified write service to store and publish + await publishEvent(relayPool, eventStore, signedEvent, settings) + + // Check current connection status for UI feedback const connectedRelays = Array.from(relayPool.relays.values()) .filter(relay => relay.connected) .map(relay => relay.url) - - const hasRemoteConnection = connectedRelays.some(url => - !url.includes('localhost') && !url.includes('127.0.0.1') - ) - - // Determine which relays we expect to succeed - const expectedSuccessRelays = hasRemoteConnection - ? RELAYS - : RELAYS.filter(r => r.includes('localhost') || r.includes('127.0.0.1')) - + + const hasRemoteConnection = connectedRelays.some(url => !isLocalRelay(url)) + const expectedSuccessRelays = hasRemoteConnection + ? RELAYS + : RELAYS.filter(isLocalRelay) const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays) - - console.log('📍 Highlight relay status:', { - targetRelays: targetRelays.length, - expectedSuccessRelays, - isLocalOnly, - hasRemoteConnection, - eventId: signedEvent.id - }) - - // If we're in local-only mode, mark this event for later sync - if (isLocalOnly) { - markEventAsOfflineCreated(signedEvent.id) - } - + // Convert to Highlight with relay tracking info and return IMMEDIATELY const highlight = eventToHighlight(signedEvent) - highlight.publishedRelays = expectedSuccessRelays // Show only relays we expect to succeed + highlight.publishedRelays = expectedSuccessRelays highlight.isLocalOnly = isLocalOnly - highlight.isOfflineCreated = isLocalOnly // Mark as created offline if local-only - - // Publish to relays in the background (non-blocking) - // This allows instant UI updates while publishing happens asynchronously - relayPool.publish(targetRelays, signedEvent) - .then(() => { - console.log('✅ Highlight published to', targetRelays.length, 'relay(s):', targetRelays) - }) - .catch((error) => { - console.warn('⚠️ Failed to publish highlight to relays (event still saved locally):', error) - }) - - // Return the highlight immediately for instant UI updates + highlight.isOfflineCreated = isLocalOnly + return highlight } diff --git a/src/services/settingsService.ts b/src/services/settingsService.ts index 021b6655..5e081106 100644 --- a/src/services/settingsService.ts +++ b/src/services/settingsService.ts @@ -3,6 +3,7 @@ import { EventFactory } from 'applesauce-factory' import { RelayPool, onlyEvents } from 'applesauce-relay' import { NostrEvent } from 'nostr-tools' import { firstValueFrom } from 'rxjs' +import { publishEvent } from './writeService' const SETTINGS_IDENTIFIER = 'com.dergigi.boris.user-settings' const APP_DATA_KIND = 30078 // NIP-78 Application Data @@ -148,10 +149,10 @@ export async function saveSettings( eventStore: IEventStore, factory: EventFactory, settings: UserSettings, - relays: string[] + _relays: string[] ): Promise { console.log('💾 Saving settings to nostr:', settings) - + // Create NIP-78 application data event manually // Note: AppDataBlueprint is not available in the npm package const draft = await factory.create(async () => ({ @@ -160,14 +161,12 @@ export async function saveSettings( tags: [['d', SETTINGS_IDENTIFIER]], created_at: Math.floor(Date.now() / 1000) })) - + const signed = await factory.sign(draft) - - console.log('📤 Publishing settings event:', signed.id, 'to', relays.length, 'relays') - - eventStore.add(signed) - await relayPool.publish(relays, signed) - + + // Use unified write service + await publishEvent(relayPool, eventStore, signed, settings) + console.log('✅ Settings published successfully') } diff --git a/src/services/writeService.ts b/src/services/writeService.ts new file mode 100644 index 00000000..55ef4e17 --- /dev/null +++ b/src/services/writeService.ts @@ -0,0 +1,59 @@ +import { RelayPool } from 'applesauce-relay' +import { NostrEvent } from 'nostr-tools' +import { IEventStore } from 'applesauce-core' +import { UserSettings } from './settingsService' +import { RELAYS } from '../config/relays' +import { isLocalRelay, areAllRelaysLocal } from '../utils/helpers' +import { markEventAsOfflineCreated } from './offlineSyncService' + +/** + * Unified write helper: add event to EventStore, detect connectivity, + * mark for offline sync if needed, and publish in background. + */ +export async function publishEvent( + relayPool: RelayPool, + eventStore: IEventStore, + event: NostrEvent, + settings?: UserSettings +): Promise { + // Store the event in the local EventStore FIRST for immediate UI display + eventStore.add(event) + console.log('💾 Stored event in EventStore:', event.id.slice(0, 8), `(kind ${event.kind})`) + + // Check current connection status - are we online or in flight mode? + const connectedRelays = Array.from(relayPool.relays.values()) + .filter(relay => relay.connected) + .map(relay => relay.url) + + const hasRemoteConnection = connectedRelays.some(url => !isLocalRelay(url)) + + // Determine which relays we expect to succeed + const expectedSuccessRelays = hasRemoteConnection + ? RELAYS + : RELAYS.filter(isLocalRelay) + + const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays) + + console.log('📍 Event relay status:', { + targetRelays: RELAYS.length, + expectedSuccessRelays: expectedSuccessRelays.length, + isLocalOnly, + hasRemoteConnection, + eventId: event.id.slice(0, 8) + }) + + // If we're in local-only mode, mark this event for later sync + if (isLocalOnly) { + markEventAsOfflineCreated(event.id) + } + + // Publish to all configured relays in the background (non-blocking) + relayPool.publish(RELAYS, event) + .then(() => { + console.log('✅ Event published to', RELAYS.length, 'relay(s):', event.id.slice(0, 8)) + }) + .catch((error) => { + console.warn('⚠️ Failed to publish event to relays (event still saved locally):', error) + }) +} + From 5b2ee94062ecfa46fc4d58e49c25c0069b590d1f Mon Sep 17 00:00:00 2001 From: Gigi Date: Wed, 15 Oct 2025 09:32:25 +0200 Subject: [PATCH 07/11] feat(ui): replace custom pull-to-refresh with use-pull-to-refresh library for simplicity - Remove custom usePullToRefresh hook and PullToRefreshIndicator - Add use-pull-to-refresh library dependency - Create simple RefreshIndicator component - Apply pull-to-refresh to Explore and Me screens - Simplify implementation while maintaining functionality --- .cursor/rules/mobile-first-ui-ux.mdc | 2 + package-lock.json | 16 ++- package.json | 3 +- src/components/Explore.tsx | 24 ++-- src/components/Me.tsx | 24 ++-- src/components/PullToRefreshIndicator.tsx | 52 -------- src/components/RefreshIndicator.tsx | 63 +++++++++ src/hooks/usePullToRefresh.ts | 153 ---------------------- 8 files changed, 100 insertions(+), 237 deletions(-) delete mode 100644 src/components/PullToRefreshIndicator.tsx create mode 100644 src/components/RefreshIndicator.tsx delete mode 100644 src/hooks/usePullToRefresh.ts diff --git a/.cursor/rules/mobile-first-ui-ux.mdc b/.cursor/rules/mobile-first-ui-ux.mdc index cc64f96a..6834bebb 100644 --- a/.cursor/rules/mobile-first-ui-ux.mdc +++ b/.cursor/rules/mobile-first-ui-ux.mdc @@ -4,3 +4,5 @@ alwaysApply: false --- This is a mobile-first application. All UI elements should be designed with that in mind. The application should work well on small screens, including older smartphones. The UX should be immaculate on mobile, even when in flight mode. (We use local caches and local relays, so that app works offline too.) + +Let's not show too many error messages, and more importantly: let's not make them red. Nothing is ever this tragic. diff --git a/package-lock.json b/package-lock.json index 8aa64c08..ba5f7236 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "boris", - "version": "0.6.6", + "version": "0.6.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "boris", - "version": "0.6.6", + "version": "0.6.9", "dependencies": { "@fortawesome/fontawesome-svg-core": "^7.1.0", "@fortawesome/free-regular-svg-icons": "^7.1.0", @@ -33,7 +33,8 @@ "reading-time-estimator": "^1.14.0", "rehype-prism-plus": "^2.0.1", "rehype-raw": "^7.0.0", - "remark-gfm": "^4.0.1" + "remark-gfm": "^4.0.1", + "use-pull-to-refresh": "^2.4.1" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.14", @@ -11695,6 +11696,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-pull-to-refresh": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/use-pull-to-refresh/-/use-pull-to-refresh-2.4.1.tgz", + "integrity": "sha512-mI3utetwSPT3ovZHUJ4LBW29EtmkrzpK/O38msP5WnI8ocFmM5boy3QZALosgeQwqwdmtQgC+8xnJIYHXeABew==", + "license": "MIT", + "peerDependencies": { + "react": "18.x || 19.x" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", diff --git a/package.json b/package.json index 84dc1a0f..3d5b8fa9 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ "reading-time-estimator": "^1.14.0", "rehype-prism-plus": "^2.0.1", "rehype-raw": "^7.0.0", - "remark-gfm": "^4.0.1" + "remark-gfm": "^4.0.1", + "use-pull-to-refresh": "^2.4.1" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.14", diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index f23b10a1..c0936554 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -18,8 +18,8 @@ import { UserSettings } from '../services/settingsService' import BlogPostCard from './BlogPostCard' import { HighlightItem } from './HighlightItem' import { getCachedPosts, upsertCachedPost, setCachedPosts, getCachedHighlights, upsertCachedHighlight, setCachedHighlights } from '../services/exploreCache' -import { usePullToRefresh } from '../hooks/usePullToRefresh' -import PullToRefreshIndicator from './PullToRefreshIndicator' +import { usePullToRefresh } from 'use-pull-to-refresh' +import RefreshIndicator from './RefreshIndicator' import { classifyHighlights } from '../utils/highlightClassification' import { HighlightVisibility } from './HighlightsPanel' @@ -41,7 +41,6 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti const [followedPubkeys, setFollowedPubkeys] = useState>(new Set()) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - const exploreContainerRef = useRef(null) const [refreshTrigger, setRefreshTrigger] = useState(0) // Visibility filters (defaults from settings) @@ -229,11 +228,13 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti }, [relayPool, activeAccount, refreshTrigger, eventStore, settings]) // Pull-to-refresh - const pullToRefreshState = usePullToRefresh(exploreContainerRef, { + const { isRefreshing, pullPosition } = usePullToRefresh({ onRefresh: () => { setRefreshTrigger(prev => prev + 1) }, - isRefreshing: loading + maximumPullLength: 240, + refreshThreshold: 80, + isDisabled: !activeAccount }) const getPostUrl = (post: BlogPostPreview) => { @@ -393,15 +394,10 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti } return ( -
- +

diff --git a/src/components/Me.tsx b/src/components/Me.tsx index 37cfea95..95b537ef 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -22,8 +22,8 @@ import { ViewMode } from './Bookmarks' import { extractUrlsFromContent } from '../services/bookmarkHelpers' import { getCachedMeData, setCachedMeData, updateCachedHighlights } from '../services/meCache' import { faBooks } from '../icons/customIcons' -import { usePullToRefresh } from '../hooks/usePullToRefresh' -import PullToRefreshIndicator from './PullToRefreshIndicator' +import { usePullToRefresh } from 'use-pull-to-refresh' +import RefreshIndicator from './RefreshIndicator' import { getProfileUrl } from '../config/nostrGateways' interface MeProps { @@ -49,7 +49,6 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [viewMode, setViewMode] = useState('cards') - const meContainerRef = useRef(null) const [refreshTrigger, setRefreshTrigger] = useState(0) // Update local state when prop changes @@ -125,11 +124,13 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr }, [relayPool, viewingPubkey, isOwnProfile, activeAccount, refreshTrigger]) // Pull-to-refresh - const pullToRefreshState = usePullToRefresh(meContainerRef, { + const { isRefreshing, pullPosition } = usePullToRefresh({ onRefresh: () => { setRefreshTrigger(prev => prev + 1) }, - isRefreshing: loading + maximumPullLength: 240, + refreshThreshold: 80, + isDisabled: !viewingPubkey }) const handleHighlightDelete = (highlightId: string) => { @@ -367,15 +368,10 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr } return ( -
- +
{viewingPubkey && } diff --git a/src/components/PullToRefreshIndicator.tsx b/src/components/PullToRefreshIndicator.tsx deleted file mode 100644 index 1af1b149..00000000 --- a/src/components/PullToRefreshIndicator.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import React from 'react' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faArrowDown } from '@fortawesome/free-solid-svg-icons' - -interface PullToRefreshIndicatorProps { - isPulling: boolean - pullDistance: number - canRefresh: boolean - isRefreshing: boolean - threshold?: number -} - -const PullToRefreshIndicator: React.FC = ({ - isPulling, - pullDistance, - canRefresh, - threshold = 80 -}) => { - // Only show when actively pulling, not when refreshing - if (!isPulling) return null - - const opacity = Math.min(pullDistance / threshold, 1) - const rotation = (pullDistance / threshold) * 180 - - return ( -
-
- -
-
- {canRefresh ? 'Release to refresh' : 'Pull to refresh'} -
-
- ) -} - -export default PullToRefreshIndicator - diff --git a/src/components/RefreshIndicator.tsx b/src/components/RefreshIndicator.tsx new file mode 100644 index 00000000..0cd0184d --- /dev/null +++ b/src/components/RefreshIndicator.tsx @@ -0,0 +1,63 @@ +import React from 'react' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faArrowRotateRight } from '@fortawesome/free-solid-svg-icons' + +interface RefreshIndicatorProps { + isRefreshing: boolean + pullPosition: number +} + +const THRESHOLD = 80 + +/** + * Simple pull-to-refresh visual indicator + */ +const RefreshIndicator: React.FC = ({ + isRefreshing, + pullPosition +}) => { + const isVisible = isRefreshing || pullPosition > 0 + if (!isVisible) return null + + const opacity = Math.min(pullPosition / THRESHOLD, 1) + const translateY = isRefreshing ? THRESHOLD / 3 : pullPosition / 3 + + return ( +
+
+ +
+
+ ) +} + +export default RefreshIndicator + diff --git a/src/hooks/usePullToRefresh.ts b/src/hooks/usePullToRefresh.ts deleted file mode 100644 index abd490d5..00000000 --- a/src/hooks/usePullToRefresh.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { useEffect, useRef, useState, RefObject } from 'react' -import { useIsCoarsePointer } from './useMediaQuery' - -interface UsePullToRefreshOptions { - onRefresh: () => void | Promise - isRefreshing?: boolean - disabled?: boolean - threshold?: number // Distance in pixels to trigger refresh - resistance?: number // Resistance factor (higher = harder to pull) -} - -interface PullToRefreshState { - isPulling: boolean - pullDistance: number - canRefresh: boolean -} - -/** - * Hook to enable pull-to-refresh gesture on touch devices - * @param containerRef - Ref to the scrollable container element - * @param options - Configuration options - * @returns State of the pull gesture - */ -export function usePullToRefresh( - containerRef: RefObject, - options: UsePullToRefreshOptions -): PullToRefreshState { - const { - onRefresh, - isRefreshing = false, - disabled = false, - threshold = 80, - resistance = 2.5 - } = options - - const isTouch = useIsCoarsePointer() - const [pullState, setPullState] = useState({ - isPulling: false, - pullDistance: 0, - canRefresh: false - }) - - const touchStartY = useRef(0) - const startScrollTop = useRef(0) - const isDragging = useRef(false) - - useEffect(() => { - const container = containerRef.current - if (!container || !isTouch || disabled || isRefreshing) return - - const handleTouchStart = (e: TouchEvent) => { - // Only start if scrolled to top - const scrollTop = container.scrollTop - if (scrollTop <= 0) { - touchStartY.current = e.touches[0].clientY - startScrollTop.current = scrollTop - isDragging.current = true - } - } - - const handleTouchMove = (e: TouchEvent) => { - if (!isDragging.current) return - - const currentY = e.touches[0].clientY - const deltaY = currentY - touchStartY.current - const scrollTop = container.scrollTop - - // Only pull down when at top and pulling down - if (scrollTop <= 0 && deltaY > 0) { - // Prevent default scroll behavior - e.preventDefault() - - // Apply resistance to make pulling feel natural - const distance = Math.min(deltaY / resistance, threshold * 1.5) - const canRefresh = distance >= threshold - - setPullState({ - isPulling: true, - pullDistance: distance, - canRefresh - }) - } else { - // Reset if scrolled or pulling up - isDragging.current = false - setPullState({ - isPulling: false, - pullDistance: 0, - canRefresh: false - }) - } - } - - const handleTouchEnd = async () => { - if (!isDragging.current) return - - isDragging.current = false - - if (pullState.canRefresh && !isRefreshing) { - // Keep the indicator visible while refreshing - setPullState(prev => ({ - ...prev, - isPulling: false - })) - - // Trigger refresh - await onRefresh() - } - - // Reset state - setPullState({ - isPulling: false, - pullDistance: 0, - canRefresh: false - }) - } - - const handleTouchCancel = () => { - isDragging.current = false - setPullState({ - isPulling: false, - pullDistance: 0, - canRefresh: false - }) - } - - // Add event listeners with passive: false to allow preventDefault - container.addEventListener('touchstart', handleTouchStart, { passive: true }) - container.addEventListener('touchmove', handleTouchMove, { passive: false }) - container.addEventListener('touchend', handleTouchEnd, { passive: true }) - container.addEventListener('touchcancel', handleTouchCancel, { passive: true }) - - return () => { - container.removeEventListener('touchstart', handleTouchStart) - container.removeEventListener('touchmove', handleTouchMove) - container.removeEventListener('touchend', handleTouchEnd) - container.removeEventListener('touchcancel', handleTouchCancel) - } - }, [containerRef, isTouch, disabled, isRefreshing, threshold, resistance, onRefresh, pullState.canRefresh]) - - // Reset pull state when refresh completes - useEffect(() => { - if (!isRefreshing && pullState.isPulling) { - setPullState({ - isPulling: false, - pullDistance: 0, - canRefresh: false - }) - } - }, [isRefreshing, pullState.isPulling]) - - return pullState -} - From f16c1720a6d0b91806dee9d3413bef2c54e738be Mon Sep 17 00:00:00 2001 From: Gigi Date: Wed, 15 Oct 2025 09:34:46 +0200 Subject: [PATCH 08/11] fix(ui): remove blocking error screens, show progressive loading with skeletons - Remove full-screen error messages in Explore and Me - Show skeletons while loading if no data cached - Display empty states with 'Pull to refresh!' message - Allow users to pull-to-refresh to retry on errors - Keep content visible as data streams in progressively --- src/components/Explore.tsx | 64 ++++++++------------- src/components/Me.tsx | 112 +++++++++++++++++-------------------- 2 files changed, 74 insertions(+), 102 deletions(-) diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index c0936554..3ca08747 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -316,9 +316,18 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti const renderTabContent = () => { switch (activeTab) { case 'writings': + if (showSkeletons) { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ) + } return filteredBlogPosts.length === 0 ? ( -
-

No blog posts found yet.

+
+

No blog posts yet. Pull to refresh!

) : (
@@ -333,9 +342,18 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti ) case 'highlights': + if (showSkeletons) { + return ( +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ ) + } return classifiedHighlights.length === 0 ? ( -
-

No highlights yet. Your friends should start highlighting content!

+
+

No highlights yet. Pull to refresh!

) : (
@@ -355,43 +373,9 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti } } - // Only show full loading screen if we don't have any data yet + // Show content progressively - no blocking error screens const hasData = highlights.length > 0 || blogPosts.length > 0 - - if (loading && !hasData) { - return ( -
-
-

- - Explore -

-
-
- {activeTab === 'writings' ? ( - Array.from({ length: 6 }).map((_, i) => ( - - )) - ) : ( - Array.from({ length: 8 }).map((_, i) => ( - - )) - )} -
-
- ) - } - - if (error) { - return ( -
-
- -

{error}

-
-
- ) - } + const showSkeletons = loading && !hasData return (
diff --git a/src/components/Me.tsx b/src/components/Me.tsx index 95b537ef..34466095 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -195,56 +195,28 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr .filter(hasContentOrUrl) .sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0))) - // Only show full loading screen if we don't have any data yet + // Show content progressively - no blocking error screens const hasData = highlights.length > 0 || bookmarks.length > 0 || readArticles.length > 0 || writings.length > 0 - - if (loading && !hasData) { - return ( -
- {viewingPubkey && ( -
- -
- )} -
- {activeTab === 'writings' ? ( - Array.from({ length: 6 }).map((_, i) => ( - - )) - ) : activeTab === 'highlights' ? ( - Array.from({ length: 8 }).map((_, i) => ( - - )) - ) : ( - Array.from({ length: 6 }).map((_, i) => ( - - )) - )} -
-
- ) - } - - if (error) { - return ( -
-
- -

{error}

-
-
- ) - } + const showSkeletons = loading && !hasData const renderTabContent = () => { switch (activeTab) { case 'highlights': + if (showSkeletons) { + return ( +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ ) + } return highlights.length === 0 ? ( -
+

{isOwnProfile - ? 'No highlights yet. Start highlighting content to see them here!' - : 'No highlights yet. You should shame them on nostr!'} + ? 'No highlights yet. Pull to refresh!' + : 'No highlights yet. Pull to refresh!'}

) : ( @@ -261,9 +233,20 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr ) case 'reading-list': + if (showSkeletons) { + return ( +
+
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ ) + } return allIndividualBookmarks.length === 0 ? ( -
-

No bookmarks yet. Bookmark articles to see them here!

+
+

No bookmarks yet. Pull to refresh!

) : (
@@ -312,9 +295,18 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr ) case 'archive': + if (showSkeletons) { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ) + } return readArticles.length === 0 ? ( -
-

No read articles yet. Mark articles as read to see them here!

+
+

No read articles yet. Pull to refresh!

) : (
@@ -329,25 +321,21 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr ) case 'writings': + if (showSkeletons) { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ) + } return writings.length === 0 ? ( -
+

{isOwnProfile - ? 'No articles written yet. Publish your first article to see it here!' - : ( - <> - No articles written. You can find other stuff from this user using{' '} - - ants - - . - - )} + ? 'No articles written yet. Pull to refresh!' + : 'No articles written yet. Pull to refresh!'}

) : ( From a4548306e7d2c2b48c7814e7c837b24699e4b2b3 Mon Sep 17 00:00:00 2001 From: Gigi Date: Wed, 15 Oct 2025 09:37:03 +0200 Subject: [PATCH 09/11] fix(ui): update HighlightsPanel to use new pull-to-refresh library - Replace old usePullToRefresh hook with use-pull-to-refresh library - Update to use RefreshIndicator component - Remove ref-based implementation in favor of simpler library API --- src/components/HighlightsPanel.tsx | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/src/components/HighlightsPanel.tsx b/src/components/HighlightsPanel.tsx index b4687150..461de312 100644 --- a/src/components/HighlightsPanel.tsx +++ b/src/components/HighlightsPanel.tsx @@ -4,10 +4,10 @@ import { faHighlighter } from '@fortawesome/free-solid-svg-icons' import { Highlight } from '../types/highlights' import { HighlightItem } from './HighlightItem' import { useFilteredHighlights } from '../hooks/useFilteredHighlights' -import { usePullToRefresh } from '../hooks/usePullToRefresh' +import { usePullToRefresh } from 'use-pull-to-refresh' import HighlightsPanelCollapsed from './HighlightsPanel/HighlightsPanelCollapsed' import HighlightsPanelHeader from './HighlightsPanel/HighlightsPanelHeader' -import PullToRefreshIndicator from './PullToRefreshIndicator' +import RefreshIndicator from './RefreshIndicator' import { RelayPool } from 'applesauce-relay' import { IEventStore } from 'applesauce-core' import { UserSettings } from '../services/settingsService' @@ -60,7 +60,6 @@ export const HighlightsPanel: React.FC = ({ }) => { const [showHighlights, setShowHighlights] = useState(true) const [localHighlights, setLocalHighlights] = useState(highlights) - const highlightsListRef = useRef(null) const handleToggleHighlights = () => { const newValue = !showHighlights @@ -69,14 +68,15 @@ export const HighlightsPanel: React.FC = ({ } // Pull-to-refresh for highlights - const pullToRefreshState = usePullToRefresh(highlightsListRef, { + const { isRefreshing, pullPosition } = usePullToRefresh({ onRefresh: () => { if (onRefresh) { onRefresh() } }, - isRefreshing: loading, - disabled: !onRefresh + maximumPullLength: 240, + refreshThreshold: 80, + isDisabled: !onRefresh }) // Keep track of highlight updates @@ -144,15 +144,10 @@ export const HighlightsPanel: React.FC = ({

) : ( -
- + {filteredHighlights.map((highlight) => ( Date: Wed, 15 Oct 2025 09:42:56 +0200 Subject: [PATCH 10/11] fix(lint): resolve all linting and type errors - Remove unused imports (useRef, faExclamationCircle, getProfileUrl, Observable, UserSettings) - Remove unused error state and setError calls in Explore and Me components - Remove unused 'events' variable from exploreService and nostrverseService - Remove unused '_relays' parameter from saveSettings - Remove unused '_settings' parameter from publishEvent - Update all callers of publishEvent and saveSettings to match new signatures - Add eslint-disable comment for intentional dependency omission in Explore - Update BookmarkList to use new pull-to-refresh library and RefreshIndicator - All type checks and linting now pass --- src/components/BookmarkList.tsx | 21 ++++++++--------- src/components/Explore.tsx | 29 +++++++----------------- src/components/HighlightsPanel.tsx | 2 +- src/components/Me.tsx | 10 +++----- src/hooks/useSettings.ts | 2 +- src/services/contactService.ts | 1 - src/services/exploreService.ts | 2 +- src/services/highlightCreationService.ts | 2 +- src/services/nostrverseService.ts | 2 +- src/services/settingsService.ts | 5 ++-- src/services/writeService.ts | 4 +--- 11 files changed, 29 insertions(+), 51 deletions(-) diff --git a/src/components/BookmarkList.tsx b/src/components/BookmarkList.tsx index f2d11720..e4036142 100644 --- a/src/components/BookmarkList.tsx +++ b/src/components/BookmarkList.tsx @@ -10,8 +10,8 @@ import IconButton from './IconButton' import { ViewMode } from './Bookmarks' import { extractUrlsFromContent } from '../services/bookmarkHelpers' import { UserSettings } from '../services/settingsService' -import { usePullToRefresh } from '../hooks/usePullToRefresh' -import PullToRefreshIndicator from './PullToRefreshIndicator' +import { usePullToRefresh } from 'use-pull-to-refresh' +import RefreshIndicator from './RefreshIndicator' import { BookmarkSkeleton } from './Skeletons' interface BookmarkListProps { @@ -54,14 +54,15 @@ export const BookmarkList: React.FC = ({ const bookmarksListRef = useRef(null) // Pull-to-refresh for bookmarks - const pullToRefreshState = usePullToRefresh(bookmarksListRef, { + const { isRefreshing: isPulling, pullPosition } = usePullToRefresh({ onRefresh: () => { if (onRefresh) { onRefresh() } }, - isRefreshing: isRefreshing || false, - disabled: !onRefresh + maximumPullLength: 240, + refreshThreshold: 80, + isDisabled: !onRefresh }) // Helper to check if a bookmark has either content or a URL @@ -146,13 +147,11 @@ export const BookmarkList: React.FC = ({ ) : (
-
{allIndividualBookmarks.map((individualBookmark, index) => diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index 3ca08747..c9a13ca1 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -1,6 +1,6 @@ -import React, { useState, useEffect, useRef, useMemo } from 'react' +import React, { useState, useEffect, useMemo } from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faExclamationCircle, faNewspaper, faPenToSquare, faHighlighter, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons' +import { faNewspaper, faPenToSquare, faHighlighter, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons' import IconButton from './IconButton' import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons' import { Hooks } from 'applesauce-react' @@ -40,7 +40,6 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti const [highlights, setHighlights] = useState([]) const [followedPubkeys, setFollowedPubkeys] = useState>(new Set()) const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) const [refreshTrigger, setRefreshTrigger] = useState(0) // Visibility filters (defaults from settings) @@ -60,7 +59,6 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti useEffect(() => { const loadData = async () => { if (!activeAccount) { - setError('Please log in to explore content from your friends') setLoading(false) return } @@ -68,7 +66,6 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti try { // show spinner but keep existing data setLoading(true) - setError(null) // Seed from in-memory cache if available to avoid empty flash const cachedPosts = getCachedPosts(activeAccount.pubkey) @@ -150,15 +147,8 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti } ) - if (contacts.size === 0) { - // If we already have any cached or previously shown data, do not block the UI. - const hasAnyData = (blogPosts.length > 0) || (highlights.length > 0) - if (!hasAnyData) { - // No friends and no cached content: set a soft hint, but still proceed to load nostrverse. - setError(null) - } - // Continue without returning: still fetch nostrverse content below. - } + // Always proceed to load nostrverse content even if no contacts + // (removed blocking error for empty contacts) // Store final followed pubkeys setFollowedPubkeys(contacts) @@ -205,12 +195,7 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti }) } - if (contacts.size === 0 && uniquePosts.length === 0 && uniqueHighlights.length === 0) { - setError('You are not following anyone yet. Follow some people to see their content!') - } else if (uniquePosts.length === 0 && uniqueHighlights.length === 0) { - setError('No content found yet') - } - + // No blocking errors - let empty states handle messaging setBlogPosts(uniquePosts) setCachedPosts(activeAccount.pubkey, uniquePosts) @@ -218,13 +203,15 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti setCachedHighlights(activeAccount.pubkey, uniqueHighlights) } catch (err) { console.error('Failed to load data:', err) - setError('Failed to load content. Please try again.') + // No blocking error - user can pull-to-refresh } finally { setLoading(false) } } loadData() + // Note: intentionally not including blogPosts/highlights length to avoid re-fetch loops + // eslint-disable-next-line react-hooks/exhaustive-deps }, [relayPool, activeAccount, refreshTrigger, eventStore, settings]) // Pull-to-refresh diff --git a/src/components/HighlightsPanel.tsx b/src/components/HighlightsPanel.tsx index 461de312..f2596c89 100644 --- a/src/components/HighlightsPanel.tsx +++ b/src/components/HighlightsPanel.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef } from 'react' +import React, { useState } from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faHighlighter } from '@fortawesome/free-solid-svg-icons' import { Highlight } from '../types/highlights' diff --git a/src/components/Me.tsx b/src/components/Me.tsx index 34466095..45876095 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -1,6 +1,6 @@ -import React, { useState, useEffect, useRef } from 'react' +import React, { useState, useEffect } from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faSpinner, faExclamationCircle, faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare } from '@fortawesome/free-solid-svg-icons' +import { faSpinner, faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare } from '@fortawesome/free-solid-svg-icons' import { Hooks } from 'applesauce-react' import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons' import { RelayPool } from 'applesauce-relay' @@ -24,7 +24,6 @@ import { getCachedMeData, setCachedMeData, updateCachedHighlights } from '../ser import { faBooks } from '../icons/customIcons' import { usePullToRefresh } from 'use-pull-to-refresh' import RefreshIndicator from './RefreshIndicator' -import { getProfileUrl } from '../config/nostrGateways' interface MeProps { relayPool: RelayPool @@ -47,7 +46,6 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr const [readArticles, setReadArticles] = useState([]) const [writings, setWritings] = useState([]) const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) const [viewMode, setViewMode] = useState('cards') const [refreshTrigger, setRefreshTrigger] = useState(0) @@ -61,14 +59,12 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr useEffect(() => { const loadData = async () => { if (!viewingPubkey) { - setError(isOwnProfile ? 'Please log in to view your data' : 'Invalid profile') setLoading(false) return } try { setLoading(true) - setError(null) // Seed from cache if available to avoid empty flash (own profile only) if (isOwnProfile) { @@ -114,7 +110,7 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr } } catch (err) { console.error('Failed to load data:', err) - setError('Failed to load data. Please try again.') + // No blocking error - user can pull-to-refresh } finally { setLoading(false) } diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index a6b6ff6d..fd210500 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -85,7 +85,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U const fullAccount = accountManager.getActive() if (!fullAccount) throw new Error('No active account') const factory = new EventFactory({ signer: fullAccount }) - await saveSettings(relayPool, eventStore, factory, newSettings, RELAYS) + await saveSettings(relayPool, eventStore, factory, newSettings) setSettings(newSettings) setToastType('success') setToastMessage('Settings saved') diff --git a/src/services/contactService.ts b/src/services/contactService.ts index 468777b3..d7c3e780 100644 --- a/src/services/contactService.ts +++ b/src/services/contactService.ts @@ -1,5 +1,4 @@ import { RelayPool } from 'applesauce-relay' -import { Observable } from 'rxjs' import { prioritizeLocalRelays } from '../utils/helpers' import { queryEvents } from './dataFetch' import { CONTACTS_REMOTE_TIMEOUT_MS } from '../config/network' diff --git a/src/services/exploreService.ts b/src/services/exploreService.ts index 189a5bdf..b4b4c122 100644 --- a/src/services/exploreService.ts +++ b/src/services/exploreService.ts @@ -39,7 +39,7 @@ export const fetchBlogPostsFromAuthors = async ( // Group by author + d-tag identifier const uniqueEvents = new Map() - const events = await queryEvents( + await queryEvents( relayPool, { kinds: [30023], authors: pubkeys, limit: 100 }, { diff --git a/src/services/highlightCreationService.ts b/src/services/highlightCreationService.ts index e5a93d21..36d30503 100644 --- a/src/services/highlightCreationService.ts +++ b/src/services/highlightCreationService.ts @@ -119,7 +119,7 @@ export async function createHighlight( const signedEvent = await factory.sign(highlightEvent) // Use unified write service to store and publish - await publishEvent(relayPool, eventStore, signedEvent, settings) + await publishEvent(relayPool, eventStore, signedEvent) // Check current connection status for UI feedback const connectedRelays = Array.from(relayPool.relays.values()) diff --git a/src/services/nostrverseService.ts b/src/services/nostrverseService.ts index 04a98431..586136b5 100644 --- a/src/services/nostrverseService.ts +++ b/src/services/nostrverseService.ts @@ -26,7 +26,7 @@ export const fetchNostrverseBlogPosts = async ( // Deduplicate replaceable events by keeping the most recent version const uniqueEvents = new Map() - const events = await queryEvents( + await queryEvents( relayPool, { kinds: [30023], limit }, { diff --git a/src/services/settingsService.ts b/src/services/settingsService.ts index 5e081106..16458601 100644 --- a/src/services/settingsService.ts +++ b/src/services/settingsService.ts @@ -148,8 +148,7 @@ export async function saveSettings( relayPool: RelayPool, eventStore: IEventStore, factory: EventFactory, - settings: UserSettings, - _relays: string[] + settings: UserSettings ): Promise { console.log('💾 Saving settings to nostr:', settings) @@ -165,7 +164,7 @@ export async function saveSettings( const signed = await factory.sign(draft) // Use unified write service - await publishEvent(relayPool, eventStore, signed, settings) + await publishEvent(relayPool, eventStore, signed) console.log('✅ Settings published successfully') } diff --git a/src/services/writeService.ts b/src/services/writeService.ts index 55ef4e17..7ef6aa67 100644 --- a/src/services/writeService.ts +++ b/src/services/writeService.ts @@ -1,7 +1,6 @@ import { RelayPool } from 'applesauce-relay' import { NostrEvent } from 'nostr-tools' import { IEventStore } from 'applesauce-core' -import { UserSettings } from './settingsService' import { RELAYS } from '../config/relays' import { isLocalRelay, areAllRelaysLocal } from '../utils/helpers' import { markEventAsOfflineCreated } from './offlineSyncService' @@ -13,8 +12,7 @@ import { markEventAsOfflineCreated } from './offlineSyncService' export async function publishEvent( relayPool: RelayPool, eventStore: IEventStore, - event: NostrEvent, - settings?: UserSettings + event: NostrEvent ): Promise { // Store the event in the local EventStore FIRST for immediate UI display eventStore.add(event) From de314894ff502be85e3b7787ebb2da08945715a7 Mon Sep 17 00:00:00 2001 From: Gigi Date: Wed, 15 Oct 2025 09:44:57 +0200 Subject: [PATCH 11/11] fix(explore): properly fix react-hooks/exhaustive-deps warning Instead of suppressing the warning, use functional setState updates to check current state without creating dependencies. This allows the effect to check if blogPosts/highlights are empty without adding them as dependencies, which would cause infinite re-fetch loops. The pattern prev.length === 0 ? cached : prev ensures we only seed from cache on initial load, not on every refresh. --- src/components/Explore.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index c9a13ca1..b02e81dd 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -68,13 +68,14 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti setLoading(true) // Seed from in-memory cache if available to avoid empty flash + // Use functional update to check current state without creating dependency const cachedPosts = getCachedPosts(activeAccount.pubkey) - if (cachedPosts && cachedPosts.length > 0 && blogPosts.length === 0) { - setBlogPosts(cachedPosts) + if (cachedPosts && cachedPosts.length > 0) { + setBlogPosts(prev => prev.length === 0 ? cachedPosts : prev) } const cachedHighlights = getCachedHighlights(activeAccount.pubkey) - if (cachedHighlights && cachedHighlights.length > 0 && highlights.length === 0) { - setHighlights(cachedHighlights) + if (cachedHighlights && cachedHighlights.length > 0) { + setHighlights(prev => prev.length === 0 ? cachedHighlights : prev) } // Fetch the user's contacts (friends) @@ -210,8 +211,6 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti } loadData() - // Note: intentionally not including blogPosts/highlights length to avoid re-fetch loops - // eslint-disable-next-line react-hooks/exhaustive-deps }, [relayPool, activeAccount, refreshTrigger, eventStore, settings]) // Pull-to-refresh