From a4bad34a901869d68c1a1c1c8a06c26f2b5393de Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 18 Oct 2025 09:48:48 +0200 Subject: [PATCH 01/40] chore: bump version to 0.7.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5f667987..8000a176 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "boris", - "version": "0.6.24", + "version": "0.7.0", "description": "A minimal nostr client for bookmark management", "homepage": "https://read.withboris.com/", "type": "module", From 90c74a8e9df651ee6d8384cbcb8e78fdcc838656 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 18 Oct 2025 09:50:23 +0200 Subject: [PATCH 02/40] docs: update CHANGELOG.md for v0.7.0 --- CHANGELOG.md | 121 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 120 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d535f51..466a0993 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,124 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.7.0] - 2025-10-18 + +### Added + +- Login with Bunker (NIP-46) authentication support + - Support for remote signing via Nostr Connect protocol + - Bunker URI input with validation and error handling + - Automatic reconnection on app restore with proper permissions + - Signer suggestions in error messages (Amber, nsec.app, Nostrum) +- Debug page (`/debug`) for diagnostics and testing + - Interactive NIP-04 and NIP-44 encryption/decryption testing + - Live performance timing with stopwatch display + - Bookmark loading and decryption diagnostics + - Real-time bunker logs with filtering and clearing + - Version and git commit footer +- Progressive bookmark loading with streaming updates + - Non-blocking, progressive bookmark updates via callback pattern + - Batched background hydration using EventLoader and AddressLoader + - Auto-decrypt bookmarks as they arrive from relays + - Individual decrypt buttons for encrypted bookmark events +- Bookmark grouping toggle (grouped by source vs flat chronological) + - Toggle between grouped view and flat chronological list + - Amethyst-style bookmark detection and grouping + - Display bookmarks even when they only have IDs (content loads in background) + +### Changed + +- Improved login UI with better copy and modern design + - Personable title and nostr-native language + - Highlighted 'your own highlights' in login copy + - Simplified button text to single words (Extension, Signer) + - Hide login button and user icon when logged out + - Hide Extension button when Bunker input is shown + - Auto-load bookmarks on login and page mount +- Enhanced bunker error messages + - Formatted error messages with signer suggestions + - Links to nos2x, Amber, nsec.app, and Nostrum signers + - Better error handling for missing signer extensions + - Centered and constrained bunker input field +- Centralized bookmark loading architecture + - Single shared bookmark controller for consistent loading + - Unified bookmark loading with streaming and auto-decrypt + - Consolidated bookmark loading into single centralized function + - Bookmarks passed as props throughout component tree +- Renamed UI elements for clarity + - "Bunker" button renamed to "Signer" + - Hide bookmark controls when logged out +- Settings version footer improvements + - Separate links for version (to GitHub release) and commit (to commit page) + - Proper spacing around middot separator + +### Fixed + +- NIP-46 bunker signing and decryption + - NostrConnectSigner properly reconnects with permissions on app restore + - Bunker relays added to relay pool for signing requests + - Proper setup of pool and relays before bunker reconnection + - Expose nip04/nip44 on NostrConnectAccount for bookmark decryption + - Cache wrapped nip04/nip44 objects instead of using getters + - Wait for bunker relay connections before marking signer ready + - Validate bunker URI (remote must differ from user pubkey) + - Accept remote===pubkey for Amber compatibility +- Bookmark loading and decryption + - Bookmarks load and complete properly with streaming + - Auto-decrypt private bookmarks with NIP-04 detection + - Include decrypted private bookmarks in sidebar + - Skip background event fetching when there are too many IDs + - Only build bookmarks from ready events (unencrypted or decrypted) + - Restore Debug page decrypt display via onDecryptComplete callback + - Make controller onEvent non-blocking for queryEvents completion + - Proper timeout handling for bookmark decryption (no hanging) + - Smart encryption detection with consistent padlock display + - Sequential decryption instead of concurrent to avoid queue issues + - Add extraRelays to EventLoader and AddressLoader +- PWA cache limit increased to 3 MiB for larger bundles +- Extension login error messages with nos2x link +- TypeScript and linting errors throughout + - Replace empty catch blocks with warnings + - Fix explicit any types + - Add missing useEffect dependencies + - Resolve all linting issues in App.tsx, Debug.tsx, and async utilities + +### Performance + +- Non-blocking NIP-46 operations + - Fire-and-forget NIP-46 publish for better UI responsiveness + - Non-blocking bookmark decryption with sequential processing + - Make controller onEvent non-blocking for queryEvents completion +- Optimized bookmark loading + - Batched background hydration using EventLoader and AddressLoader + - Progressive, non-blocking bookmark loading with streaming + - Shorter timeouts for debug page bookmark loading + - Remove artificial delays from bookmark decryption + +### Refactored + +- Centralized bookmark controller architecture + - Extract bookmark streaming helpers and centralize loading + - Consolidated bookmark loading into single function + - Remove deprecated bookmark service files + - Share bookmark controller between components +- Debug page organization + - Extract VersionFooter component to eliminate duplication + - Structured sections with proper layout and styling + - Apply settings page styling structure +- Simplified bunker implementation following applesauce patterns + - Clean up bunker implementation for better maintainability + - Import RELAYS from central config (DRY principle) + - Update RELAYS list with relay.nsec.app + +### Documentation + +- Comprehensive Amber.md documentation + - Amethyst-style bookmarks section + - Bunker decrypt investigation summary + - Critical queue disabling requirement + - NIP-46 setup and troubleshooting + ## [0.6.24] - 2025-01-16 ### Fixed @@ -1760,7 +1878,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Optimize relay usage following applesauce-relay best practices - Use applesauce-react event models for better profile handling -[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.24...HEAD +[Unreleased]: https://github.com/dergigi/boris/compare/v0.7.0...HEAD +[0.7.0]: https://github.com/dergigi/boris/compare/v0.6.24...v0.7.0 [0.6.24]: https://github.com/dergigi/boris/compare/v0.6.23...v0.6.24 [0.6.23]: https://github.com/dergigi/boris/compare/v0.6.22...v0.6.23 [0.6.21]: https://github.com/dergigi/boris/compare/v0.6.20...v0.6.21 From 0d50d052454d0d60dde582cc6088bd6f492574fb Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 18 Oct 2025 10:03:13 +0200 Subject: [PATCH 03/40] feat(highlights): refactor fetchers to use EOSE-based queryEvents - Replace ad-hoc Rx timeout-based queries with centralized queryEvents helper - Remove artificial timeouts (1200ms/6000ms) in favor of EOSE signals - Use KINDS.Highlights consistently instead of hardcoded 9802 - Maintain streaming callbacks for instant UI updates - Parallel queries for article #a and #e tags - Local-first relay prioritization via queryEvents --- src/services/highlights/fetchByAuthor.ts | 60 +++++---------- src/services/highlights/fetchForArticle.ts | 90 +++++----------------- src/services/highlights/fetchForUrl.ts | 58 +++++--------- 3 files changed, 60 insertions(+), 148 deletions(-) diff --git a/src/services/highlights/fetchByAuthor.ts b/src/services/highlights/fetchByAuthor.ts index 011d02eb..2f047fcc 100644 --- a/src/services/highlights/fetchByAuthor.ts +++ b/src/services/highlights/fetchByAuthor.ts @@ -1,12 +1,11 @@ -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 { UserSettings } from '../settingsService' import { rebroadcastEvents } from '../rebroadcastService' import { KINDS } from '../../config/kinds' +import { queryEvents } from '../dataFetch' export const fetchHighlights = async ( relayPool: RelayPool, @@ -15,44 +14,27 @@ export const fetchHighlights = async ( 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() - const local$ = localRelays.length > 0 - ? relayPool - .req(localRelays, { kinds: [KINDS.Highlights], 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: [KINDS.Highlights], 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())) + const rawEvents: NostrEvent[] = await queryEvents( + relayPool, + { kinds: [KINDS.Highlights], authors: [pubkey] }, + { + onEvent: (event: NostrEvent) => { + if (seenIds.has(event.id)) return + seenIds.add(event.id) + if (onHighlight) onHighlight(eventToHighlight(event)) + } + } + ) + + console.log(`📌 Fetched ${rawEvents.length} highlight events for author:`, pubkey.slice(0, 8)) + + try { + await rebroadcastEvents(rawEvents, relayPool, settings) + } catch (err) { + console.warn('Failed to rebroadcast highlight events:', err) + } - await rebroadcastEvents(rawEvents, relayPool, settings) const uniqueEvents = dedupeHighlights(rawEvents) const highlights = uniqueEvents.map(eventToHighlight) return sortHighlights(highlights) diff --git a/src/services/highlights/fetchForArticle.ts b/src/services/highlights/fetchForArticle.ts index 9f5ea08d..04fb5441 100644 --- a/src/services/highlights/fetchForArticle.ts +++ b/src/services/highlights/fetchForArticle.ts @@ -1,12 +1,11 @@ -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 { RELAYS } from '../../config/relays' -import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers' +import { KINDS } from '../../config/kinds' import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor' import { UserSettings } from '../settingsService' import { rebroadcastEvents } from '../rebroadcastService' +import { queryEvents } from '../dataFetch' export const fetchHighlightsForArticle = async ( relayPool: RelayPool, @@ -17,76 +16,29 @@ export const fetchHighlightsForArticle = async ( ): Promise => { try { const seenIds = new Set() - const processEvent = (event: NostrEvent): Highlight | null => { - if (seenIds.has(event.id)) return null + const onEvent = (event: NostrEvent) => { + if (seenIds.has(event.id)) return seenIds.add(event.id) - return eventToHighlight(event) + if (onHighlight) onHighlight(eventToHighlight(event)) } - const orderedRelays = prioritizeLocalRelays(RELAYS) - const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays) - - 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) { - 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())) - } + // Query for both #a and #e tags in parallel + const [aTagEvents, eTagEvents] = await Promise.all([ + queryEvents(relayPool, { kinds: [KINDS.Highlights], '#a': [articleCoordinate] }, { onEvent }), + eventId + ? queryEvents(relayPool, { kinds: [KINDS.Highlights], '#e': [eventId] }, { onEvent }) + : Promise.resolve([] as NostrEvent[]) + ]) const rawEvents = [...aTagEvents, ...eTagEvents] - await rebroadcastEvents(rawEvents, relayPool, settings) + console.log(`📌 Fetched ${rawEvents.length} highlight events for article:`, articleCoordinate) + + try { + await rebroadcastEvents(rawEvents, relayPool, settings) + } catch (err) { + console.warn('Failed to rebroadcast highlight events:', err) + } + const uniqueEvents = dedupeHighlights(rawEvents) const highlights: Highlight[] = uniqueEvents.map(eventToHighlight) return sortHighlights(highlights) diff --git a/src/services/highlights/fetchForUrl.ts b/src/services/highlights/fetchForUrl.ts index 0f94fce7..ba454d2e 100644 --- a/src/services/highlights/fetchForUrl.ts +++ b/src/services/highlights/fetchForUrl.ts @@ -1,12 +1,11 @@ -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 { RELAYS } from '../../config/relays' -import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers' +import { KINDS } from '../../config/kinds' import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor' import { UserSettings } from '../settingsService' import { rebroadcastEvents } from '../rebroadcastService' +import { queryEvents } from '../dataFetch' export const fetchHighlightsForUrl = async ( relayPool: RelayPool, @@ -14,55 +13,34 @@ export const fetchHighlightsForUrl = async ( onHighlight?: (highlight: Highlight) => void, settings?: UserSettings ): Promise => { - const seenIds = new Set() - const orderedRelaysUrl = prioritizeLocalRelays(RELAYS) - const { local: localRelaysUrl, remote: remoteRelaysUrl } = partitionRelays(orderedRelaysUrl) - try { - 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())) - + const seenIds = new Set() + const rawEvents: NostrEvent[] = await queryEvents( + relayPool, + { kinds: [KINDS.Highlights], '#r': [url] }, + { + onEvent: (event: NostrEvent) => { + if (seenIds.has(event.id)) return + seenIds.add(event.id) + if (onHighlight) onHighlight(eventToHighlight(event)) + } + } + ) + console.log(`📌 Fetched ${rawEvents.length} highlight events for URL:`, url) - + // Rebroadcast events - but don't let errors here break the highlight display try { await rebroadcastEvents(rawEvents, relayPool, settings) } catch (err) { console.warn('Failed to rebroadcast highlight events:', err) } - + const uniqueEvents = dedupeHighlights(rawEvents) const highlights: Highlight[] = uniqueEvents.map(eventToHighlight) return sortHighlights(highlights) } catch (err) { console.error('Error fetching highlights for URL:', err) - // Return highlights that were already streamed via callback - // Don't return empty array as that would clear already-displayed highlights return [] } } From 2e59bc937512b547807bc3acfaa5bcee9a679a19 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 18 Oct 2025 10:04:13 +0200 Subject: [PATCH 04/40] feat(highlights): add optional session cache with TTL - Add in-memory cache with 60s TTL for article/url/author queries - Check cache before network fetch to reduce redundant queries - Support force flag to bypass cache when needed - Stream cached results through onHighlight callback for consistency --- src/services/highlights/cache.ts | 96 ++++++++++++++++++++++ src/services/highlights/fetchByAuthor.ts | 25 +++++- src/services/highlights/fetchForArticle.ts | 25 +++++- src/services/highlights/fetchForUrl.ts | 25 +++++- 4 files changed, 165 insertions(+), 6 deletions(-) create mode 100644 src/services/highlights/cache.ts diff --git a/src/services/highlights/cache.ts b/src/services/highlights/cache.ts new file mode 100644 index 00000000..8ca18af1 --- /dev/null +++ b/src/services/highlights/cache.ts @@ -0,0 +1,96 @@ +import { Highlight } from '../../types/highlights' + +interface CacheEntry { + highlights: Highlight[] + timestamp: number +} + +/** + * Simple in-memory session cache for highlight queries with TTL + */ +class HighlightCache { + private cache = new Map() + private ttlMs = 60000 // 60 seconds + + /** + * Generate cache key for article coordinate + */ + articleKey(coordinate: string): string { + return `article:${coordinate}` + } + + /** + * Generate cache key for URL + */ + urlKey(url: string): string { + // Normalize URL for consistent caching + try { + const normalized = new URL(url) + normalized.hash = '' // Remove hash + return `url:${normalized.toString()}` + } catch { + return `url:${url}` + } + } + + /** + * Generate cache key for author pubkey + */ + authorKey(pubkey: string): string { + return `author:${pubkey}` + } + + /** + * Get cached highlights if not expired + */ + get(key: string): Highlight[] | null { + const entry = this.cache.get(key) + if (!entry) return null + + const now = Date.now() + if (now - entry.timestamp > this.ttlMs) { + this.cache.delete(key) + return null + } + + return entry.highlights + } + + /** + * Store highlights in cache + */ + set(key: string, highlights: Highlight[]): void { + this.cache.set(key, { + highlights, + timestamp: Date.now() + }) + } + + /** + * Clear specific cache entry + */ + clear(key: string): void { + this.cache.delete(key) + } + + /** + * Clear all cache entries + */ + clearAll(): void { + this.cache.clear() + } + + /** + * Get cache stats + */ + stats(): { size: number; keys: string[] } { + return { + size: this.cache.size, + keys: Array.from(this.cache.keys()) + } + } +} + +// Singleton instance +export const highlightCache = new HighlightCache() + diff --git a/src/services/highlights/fetchByAuthor.ts b/src/services/highlights/fetchByAuthor.ts index 2f047fcc..ae18eccd 100644 --- a/src/services/highlights/fetchByAuthor.ts +++ b/src/services/highlights/fetchByAuthor.ts @@ -6,13 +6,28 @@ import { UserSettings } from '../settingsService' import { rebroadcastEvents } from '../rebroadcastService' import { KINDS } from '../../config/kinds' import { queryEvents } from '../dataFetch' +import { highlightCache } from './cache' export const fetchHighlights = async ( relayPool: RelayPool, pubkey: string, onHighlight?: (highlight: Highlight) => void, - settings?: UserSettings + settings?: UserSettings, + force = false ): Promise => { + // Check cache first unless force refresh + if (!force) { + const cacheKey = highlightCache.authorKey(pubkey) + const cached = highlightCache.get(cacheKey) + if (cached) { + console.log(`📌 Using cached highlights for author (${cached.length} items)`) + // Stream cached highlights if callback provided + if (onHighlight) { + cached.forEach(h => onHighlight(h)) + } + return cached + } + } try { const seenIds = new Set() const rawEvents: NostrEvent[] = await queryEvents( @@ -37,7 +52,13 @@ export const fetchHighlights = async ( const uniqueEvents = dedupeHighlights(rawEvents) const highlights = uniqueEvents.map(eventToHighlight) - return sortHighlights(highlights) + const sorted = sortHighlights(highlights) + + // Cache the results + const cacheKey = highlightCache.authorKey(pubkey) + highlightCache.set(cacheKey, sorted) + + return sorted } catch { return [] } diff --git a/src/services/highlights/fetchForArticle.ts b/src/services/highlights/fetchForArticle.ts index 04fb5441..90c14c65 100644 --- a/src/services/highlights/fetchForArticle.ts +++ b/src/services/highlights/fetchForArticle.ts @@ -6,14 +6,29 @@ import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlight import { UserSettings } from '../settingsService' import { rebroadcastEvents } from '../rebroadcastService' import { queryEvents } from '../dataFetch' +import { highlightCache } from './cache' export const fetchHighlightsForArticle = async ( relayPool: RelayPool, articleCoordinate: string, eventId?: string, onHighlight?: (highlight: Highlight) => void, - settings?: UserSettings + settings?: UserSettings, + force = false ): Promise => { + // Check cache first unless force refresh + if (!force) { + const cacheKey = highlightCache.articleKey(articleCoordinate) + const cached = highlightCache.get(cacheKey) + if (cached) { + console.log(`📌 Using cached highlights for article (${cached.length} items)`) + // Stream cached highlights if callback provided + if (onHighlight) { + cached.forEach(h => onHighlight(h)) + } + return cached + } + } try { const seenIds = new Set() const onEvent = (event: NostrEvent) => { @@ -41,7 +56,13 @@ export const fetchHighlightsForArticle = async ( const uniqueEvents = dedupeHighlights(rawEvents) const highlights: Highlight[] = uniqueEvents.map(eventToHighlight) - return sortHighlights(highlights) + const sorted = sortHighlights(highlights) + + // Cache the results + const cacheKey = highlightCache.articleKey(articleCoordinate) + highlightCache.set(cacheKey, sorted) + + return sorted } catch { return [] } diff --git a/src/services/highlights/fetchForUrl.ts b/src/services/highlights/fetchForUrl.ts index ba454d2e..c1e569f0 100644 --- a/src/services/highlights/fetchForUrl.ts +++ b/src/services/highlights/fetchForUrl.ts @@ -6,13 +6,28 @@ import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlight import { UserSettings } from '../settingsService' import { rebroadcastEvents } from '../rebroadcastService' import { queryEvents } from '../dataFetch' +import { highlightCache } from './cache' export const fetchHighlightsForUrl = async ( relayPool: RelayPool, url: string, onHighlight?: (highlight: Highlight) => void, - settings?: UserSettings + settings?: UserSettings, + force = false ): Promise => { + // Check cache first unless force refresh + if (!force) { + const cacheKey = highlightCache.urlKey(url) + const cached = highlightCache.get(cacheKey) + if (cached) { + console.log(`📌 Using cached highlights for URL (${cached.length} items)`) + // Stream cached highlights if callback provided + if (onHighlight) { + cached.forEach(h => onHighlight(h)) + } + return cached + } + } try { const seenIds = new Set() const rawEvents: NostrEvent[] = await queryEvents( @@ -38,7 +53,13 @@ export const fetchHighlightsForUrl = async ( const uniqueEvents = dedupeHighlights(rawEvents) const highlights: Highlight[] = uniqueEvents.map(eventToHighlight) - return sortHighlights(highlights) + const sorted = sortHighlights(highlights) + + // Cache the results + const cacheKey = highlightCache.urlKey(url) + highlightCache.set(cacheKey, sorted) + + return sorted } catch (err) { console.error('Error fetching highlights for URL:', err) return [] From f00f26dfe01ba1facbef32eeeeafa537b04c3cb2 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 18 Oct 2025 10:05:56 +0200 Subject: [PATCH 05/40] feat(debug): add Highlight Loading section with streaming metrics - Add query mode selector (Article/#a, URL/#r, Author) - Stream highlight events as they arrive with onEvent callback - Track timing metrics: total load time and time-to-first-event - Display highlight summaries with content, tags, and metadata - Support EOSE-based completion via queryEvents helper - Mirror bookmark loading section UX for consistency --- src/components/Debug.tsx | 233 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 231 insertions(+), 2 deletions(-) diff --git a/src/components/Debug.tsx b/src/components/Debug.tsx index e85bdbe3..8a40f044 100644 --- a/src/components/Debug.tsx +++ b/src/components/Debug.tsx @@ -80,12 +80,23 @@ const Debug: React.FC = ({ // Individual event decryption results const [decryptedEvents, setDecryptedEvents] = useState>(new Map()) + // Highlight loading state + const [highlightMode, setHighlightMode] = useState<'article' | 'url' | 'author'>('article') + const [highlightArticleCoord, setHighlightArticleCoord] = useState('') + const [highlightUrl, setHighlightUrl] = useState('') + const [highlightAuthor, setHighlightAuthor] = useState('') + const [isLoadingHighlights, setIsLoadingHighlights] = useState(false) + const [highlightEvents, setHighlightEvents] = useState([]) + const [tLoadHighlights, setTLoadHighlights] = useState(null) + const [tFirstHighlight, setTFirstHighlight] = useState(null) + // Live timing state const [liveTiming, setLiveTiming] = useState<{ nip44?: { type: 'encrypt' | 'decrypt'; startTime: number } nip04?: { type: 'encrypt' | 'decrypt'; startTime: number } loadBookmarks?: { startTime: number } decryptBookmarks?: { startTime: number } + loadHighlights?: { startTime: number } }>({}) useEffect(() => { @@ -315,6 +326,87 @@ const Debug: React.FC = ({ DebugBus.info('debug', 'Cleared bookmark data') } + const handleLoadHighlights = async () => { + if (!relayPool) { + DebugBus.warn('debug', 'Cannot load highlights: missing relayPool') + return + } + + const getValue = () => { + if (highlightMode === 'article') return highlightArticleCoord.trim() + if (highlightMode === 'url') return highlightUrl.trim() + return highlightAuthor.trim() + } + + const value = getValue() + if (!value) { + DebugBus.warn('debug', 'Please provide a value to query') + return + } + + try { + setIsLoadingHighlights(true) + setHighlightEvents([]) + setTFirstHighlight(null) + DebugBus.info('debug', `Loading highlights (${highlightMode}: ${value})...`) + + const start = performance.now() + setLiveTiming(prev => ({ ...prev, loadHighlights: { startTime: start } })) + + let firstEventTime: number | null = null + const seenIds = new Set() + + // Import highlight services + const { queryEvents } = await import('../services/dataFetch') + const { KINDS } = await import('../config/kinds') + + // Build filter based on mode + let filter: { kinds: number[]; '#a'?: string[]; '#r'?: string[]; authors?: string[] } + if (highlightMode === 'article') { + filter = { kinds: [KINDS.Highlights], '#a': [value] } + } else if (highlightMode === 'url') { + filter = { kinds: [KINDS.Highlights], '#r': [value] } + } else { + filter = { kinds: [KINDS.Highlights], authors: [value] } + } + + const events = await queryEvents(relayPool, filter, { + onEvent: (evt) => { + if (seenIds.has(evt.id)) return + seenIds.add(evt.id) + + if (firstEventTime === null) { + firstEventTime = performance.now() - start + setTFirstHighlight(Math.round(firstEventTime)) + } + + setHighlightEvents(prev => [...prev, evt]) + } + }) + + const elapsed = Math.round(performance.now() - start) + setTLoadHighlights(elapsed) + setLiveTiming(prev => { + const { loadHighlights, ...rest } = prev + return rest + }) + + DebugBus.info('debug', `Loaded ${events.length} highlight events in ${elapsed}ms`) + } catch (err) { + console.error('Failed to load highlights:', err) + DebugBus.error('debug', `Failed to load highlights: ${err instanceof Error ? err.message : String(err)}`) + } finally { + setIsLoadingHighlights(false) + } + } + + const handleClearHighlights = () => { + setHighlightEvents([]) + setTLoadHighlights(null) + setTFirstHighlight(null) + DebugBus.info('debug', 'Cleared highlight data') + } + const handleBunkerLogin = async () => { if (!bunkerUri.trim()) { setBunkerError('Please enter a bunker URI') @@ -376,7 +468,7 @@ const Debug: React.FC = ({ return null } - const getBookmarkLiveTiming = (operation: 'loadBookmarks' | 'decryptBookmarks') => { + const getBookmarkLiveTiming = (operation: 'loadBookmarks' | 'decryptBookmarks' | 'loadHighlights') => { const timing = liveTiming[operation] if (timing) { const elapsed = Math.round(performance.now() - timing.startTime) @@ -390,7 +482,7 @@ const Debug: React.FC = ({ value?: string | number | null; mode?: 'nip44' | 'nip04'; type?: 'encrypt' | 'decrypt'; - bookmarkOp?: 'loadBookmarks' | 'decryptBookmarks'; + bookmarkOp?: 'loadBookmarks' | 'decryptBookmarks' | 'loadHighlights'; }) => { const liveValue = bookmarkOp ? getBookmarkLiveTiming(bookmarkOp) : (mode && type ? getLiveTiming(mode, type) : null) const isLive = !!liveValue @@ -647,6 +739,143 @@ const Debug: React.FC = ({ )} + {/* Highlight Loading Section */} +
+

Highlight Loading

+
Test highlight loading with EOSE-based queryEvents (kind: 9802)
+ +
+
Query Mode:
+
+ + + +
+
+ +
+ {highlightMode === 'article' && ( + setHighlightArticleCoord(e.target.value)} + disabled={isLoadingHighlights} + /> + )} + {highlightMode === 'url' && ( + setHighlightUrl(e.target.value)} + disabled={isLoadingHighlights} + /> + )} + {highlightMode === 'author' && ( + setHighlightAuthor(e.target.value)} + disabled={isLoadingHighlights} + /> + )} +
+ +
+ + +
+ +
+ + +
+ + {highlightEvents.length > 0 && ( +
+
Loaded Highlights ({highlightEvents.length}):
+
+ {highlightEvents.map((evt, idx) => { + const content = evt.content || '' + const shortContent = content.length > 100 ? content.substring(0, 100) + '...' : content + const aTag = evt.tags?.find((t: string[]) => t[0] === 'a')?.[1] + const rTag = evt.tags?.find((t: string[]) => t[0] === 'r')?.[1] + const eTag = evt.tags?.find((t: string[]) => t[0] === 'e')?.[1] + const contextTag = evt.tags?.find((t: string[]) => t[0] === 'context')?.[1] + + return ( +
+
Highlight #{idx + 1}
+
+
Author: {evt.pubkey.slice(0, 16)}...
+
Created: {new Date(evt.created_at * 1000).toLocaleString()}
+
+
+
Content:
+
"{shortContent}"
+
+ {contextTag && ( +
+
Context: {contextTag.substring(0, 60)}...
+
+ )} + {aTag &&
#a: {aTag}
} + {rTag &&
#r: {rTag}
} + {eTag &&
#e: {eTag.slice(0, 16)}...
} +
ID: {evt.id}
+
+ ) + })} +
+
+ )} +
+ {/* Debug Logs Section */}

Debug Logs

From 4dc1894ef33f4d7475b27f778bf0b3c823e88241 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 18 Oct 2025 20:19:53 +0200 Subject: [PATCH 06/40] feat(debug): default highlight loading to logged-in user - Author mode now defaults to current user's pubkey if not specified - Changed default mode from 'article' to 'author' for better UX - Updated placeholder to show logged-in user's pubkey - Updated description to clarify default behavior - Makes 'Load Highlights' button immediately useful without input --- src/components/Debug.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/components/Debug.tsx b/src/components/Debug.tsx index 8a40f044..8d268524 100644 --- a/src/components/Debug.tsx +++ b/src/components/Debug.tsx @@ -81,7 +81,7 @@ const Debug: React.FC = ({ const [decryptedEvents, setDecryptedEvents] = useState>(new Map()) // Highlight loading state - const [highlightMode, setHighlightMode] = useState<'article' | 'url' | 'author'>('article') + const [highlightMode, setHighlightMode] = useState<'article' | 'url' | 'author'>('author') const [highlightArticleCoord, setHighlightArticleCoord] = useState('') const [highlightUrl, setHighlightUrl] = useState('') const [highlightAuthor, setHighlightAuthor] = useState('') @@ -332,15 +332,17 @@ const Debug: React.FC = ({ return } + // Default to logged-in user's highlights if no specific query provided const getValue = () => { if (highlightMode === 'article') return highlightArticleCoord.trim() if (highlightMode === 'url') return highlightUrl.trim() - return highlightAuthor.trim() + const authorValue = highlightAuthor.trim() + return authorValue || pubkey || '' } const value = getValue() if (!value) { - DebugBus.warn('debug', 'Please provide a value to query') + DebugBus.warn('debug', 'Please provide a value to query or log in') return } @@ -742,7 +744,7 @@ const Debug: React.FC = ({ {/* Highlight Loading Section */}

Highlight Loading

-
Test highlight loading with EOSE-based queryEvents (kind: 9802)
+
Test highlight loading with EOSE-based queryEvents (kind: 9802). Author mode defaults to your highlights.
Query Mode:
@@ -799,7 +801,7 @@ const Debug: React.FC = ({ setHighlightAuthor(e.target.value)} disabled={isLoadingHighlights} From bc7b4ae42d1ad49b5e4e78a2fc54c12a5c7788e7 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 18 Oct 2025 20:20:59 +0200 Subject: [PATCH 07/40] feat(debug): add time-to-first-event tracking for bookmarks - Track and display time to first bookmark event arrival - Mirror highlight loading metrics for consistency - Shows how quickly local/fast relays respond - Renamed 'load' stat to 'total' for clarity - Clear first event timing on reset --- src/components/Debug.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/components/Debug.tsx b/src/components/Debug.tsx index 8d268524..d744740f 100644 --- a/src/components/Debug.tsx +++ b/src/components/Debug.tsx @@ -76,6 +76,7 @@ const Debug: React.FC = ({ const [bookmarkStats, setBookmarkStats] = useState<{ public: number; private: number } | null>(null) const [tLoadBookmarks, setTLoadBookmarks] = useState(null) const [tDecryptBookmarks, setTDecryptBookmarks] = useState(null) + const [tFirstBookmark, setTFirstBookmark] = useState(null) // Individual event decryption results const [decryptedEvents, setDecryptedEvents] = useState>(new Map()) @@ -254,10 +255,12 @@ const Debug: React.FC = ({ setBookmarkStats(null) setBookmarkEvents([]) // Clear existing events setDecryptedEvents(new Map()) + setTFirstBookmark(null) DebugBus.info('debug', 'Loading bookmark events...') // Start timing const start = performance.now() + let firstEventTime: number | null = null setLiveTiming(prev => ({ ...prev, loadBookmarks: { startTime: start } })) // Import controller at runtime to avoid circular dependencies @@ -265,6 +268,12 @@ const Debug: React.FC = ({ // Subscribe to raw events for Debug UI display const unsubscribeRaw = bookmarkController.onRawEvent((evt) => { + // Track time to first event + if (firstEventTime === null) { + firstEventTime = performance.now() - start + setTFirstBookmark(Math.round(firstEventTime)) + } + // Add event immediately with live deduplication setBookmarkEvents(prev => { const key = getEventKey(evt) @@ -322,6 +331,7 @@ const Debug: React.FC = ({ setBookmarkStats(null) setTLoadBookmarks(null) setTDecryptBookmarks(null) + setTFirstBookmark(null) setDecryptedEvents(new Map()) DebugBus.info('debug', 'Cleared bookmark data') } @@ -690,7 +700,8 @@ const Debug: React.FC = ({
- + +
From a08e4fdc24f320939025ce6b37634d6e3476a045 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 18 Oct 2025 20:22:03 +0200 Subject: [PATCH 08/40] chore: bump version to 0.7.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8000a176..ec78f599 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "boris", - "version": "0.7.0", + "version": "0.7.1", "description": "A minimal nostr client for bookmark management", "homepage": "https://read.withboris.com/", "type": "module", From 06763d5307faaab7090b74dddc22bb74f770b175 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 18 Oct 2025 20:23:18 +0200 Subject: [PATCH 09/40] fix: resolve unused variable linting error in Debug.tsx --- src/components/Debug.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/Debug.tsx b/src/components/Debug.tsx index d744740f..63b585d4 100644 --- a/src/components/Debug.tsx +++ b/src/components/Debug.tsx @@ -399,6 +399,7 @@ const Debug: React.FC = ({ const elapsed = Math.round(performance.now() - start) setTLoadHighlights(elapsed) setLiveTiming(prev => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars const { loadHighlights, ...rest } = prev return rest }) From cf3ca2d527d9877995af784562c57611d8b1257c Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 18 Oct 2025 20:23:45 +0200 Subject: [PATCH 10/40] feat: show highlight button only when viewing articles - Only display the floating highlight button when currentArticle exists or selectedUrl is a nostr article - Prevents highlight button from showing on external URLs, videos, or other content types - Improves UX by showing highlight functionality only where it's relevant --- src/components/ThreePaneLayout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ThreePaneLayout.tsx b/src/components/ThreePaneLayout.tsx index b3912f90..d5497574 100644 --- a/src/components/ThreePaneLayout.tsx +++ b/src/components/ThreePaneLayout.tsx @@ -414,7 +414,7 @@ const ThreePaneLayout: React.FC = (props) => { />
- {props.hasActiveAccount && ( + {props.hasActiveAccount && (props.currentArticle || (props.selectedUrl && props.selectedUrl.startsWith('nostr:'))) && ( Date: Sat, 18 Oct 2025 20:26:11 +0200 Subject: [PATCH 11/40] fix: show highlight button for all reading content - Show highlight button when readerContent exists (both nostr articles and external URLs) - Hide highlight button when browsing app pages like explore, settings, etc. - Ensures highlighting is available for all readable content but not for navigation pages --- src/components/ThreePaneLayout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ThreePaneLayout.tsx b/src/components/ThreePaneLayout.tsx index d5497574..2ebc0025 100644 --- a/src/components/ThreePaneLayout.tsx +++ b/src/components/ThreePaneLayout.tsx @@ -414,7 +414,7 @@ const ThreePaneLayout: React.FC = (props) => { /> - {props.hasActiveAccount && (props.currentArticle || (props.selectedUrl && props.selectedUrl.startsWith('nostr:'))) && ( + {props.hasActiveAccount && props.readerContent && ( Date: Sat, 18 Oct 2025 20:39:57 +0200 Subject: [PATCH 12/40] feat: add debug buttons for highlight loading and Web of Trust - Add three quick-load buttons: Load My Highlights, Load Friends Highlights, Load Nostrverse Highlights - Add Web of Trust section with Load Friends button to display followed npubs - Stream highlights with dedupe and timing metrics - Display friends count and scrollable list of npubs - All buttons respect loading states and account requirements --- src/components/Debug.tsx | 182 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) diff --git a/src/components/Debug.tsx b/src/components/Debug.tsx index 63b585d4..a03835ab 100644 --- a/src/components/Debug.tsx +++ b/src/components/Debug.tsx @@ -8,6 +8,7 @@ import { Accounts } from 'applesauce-accounts' import { NostrConnectSigner } from 'applesauce-signers' import { RelayPool } from 'applesauce-relay' import { Helpers } from 'applesauce-core' +import { nip19 } from 'nostr-tools' import { getDefaultBunkerPermissions } from '../services/nostrConnect' import { DebugBus, type DebugLogEntry } from '../utils/debugBus' import ThreePaneLayout from './ThreePaneLayout' @@ -16,6 +17,9 @@ import type { NostrEvent } from '../services/bookmarkHelpers' import { Bookmark } from '../types/bookmarks' import { useBookmarksUI } from '../hooks/useBookmarksUI' import { useSettings } from '../hooks/useSettings' +import { fetchHighlights, fetchHighlightsFromAuthors } from '../services/highlightService' +import { fetchNostrverseHighlights } from '../services/nostrverseService' +import { fetchContacts } from '../services/contactService' const defaultPayload = 'The quick brown fox jumps over the lazy dog.' @@ -99,6 +103,10 @@ const Debug: React.FC = ({ decryptBookmarks?: { startTime: number } loadHighlights?: { startTime: number } }>({}) + + // Web of Trust state + const [friendsPubkeys, setFriendsPubkeys] = useState>(new Set()) + const [friendsLoading, setFriendsLoading] = useState(false) useEffect(() => { return DebugBus.subscribe((e) => setLogs(prev => [...prev, e].slice(-300))) @@ -420,6 +428,119 @@ const Debug: React.FC = ({ DebugBus.info('debug', 'Cleared highlight data') } + const handleLoadMyHighlights = async () => { + if (!relayPool || !activeAccount?.pubkey) { + DebugBus.warn('debug', 'Please log in to load your highlights') + return + } + const start = performance.now() + setHighlightEvents([]) + setIsLoadingHighlights(true) + setTLoadHighlights(null) + setTFirstHighlight(null) + DebugBus.info('debug', 'Loading my highlights...') + try { + let firstEventTime: number | null = null + await fetchHighlights(relayPool, activeAccount.pubkey, (h) => { + if (firstEventTime === null) { + firstEventTime = performance.now() - start + setTFirstHighlight(Math.round(firstEventTime)) + } + setHighlightEvents(prev => { + if (prev.some(x => x.id === h.id)) return prev + const next = [...prev, { ...h, pubkey: h.pubkey, created_at: h.created_at, id: h.id, kind: 9802, tags: [], content: h.content, sig: '' } as NostrEvent] + return next.sort((a, b) => b.created_at - a.created_at) + }) + }, settings) + } finally { + setIsLoadingHighlights(false) + const elapsed = Math.round(performance.now() - start) + setTLoadHighlights(elapsed) + DebugBus.info('debug', `Loaded my highlights in ${elapsed}ms`) + } + } + + const handleLoadFriendsHighlights = async () => { + if (!relayPool || !activeAccount?.pubkey) { + DebugBus.warn('debug', 'Please log in to load friends highlights') + return + } + const start = performance.now() + setHighlightEvents([]) + setIsLoadingHighlights(true) + setTLoadHighlights(null) + setTFirstHighlight(null) + DebugBus.info('debug', 'Loading friends highlights...') + try { + const contacts = await fetchContacts(relayPool, activeAccount.pubkey) + DebugBus.info('debug', `Found ${contacts.size} friends`) + let firstEventTime: number | null = null + await fetchHighlightsFromAuthors(relayPool, Array.from(contacts), (h) => { + if (firstEventTime === null) { + firstEventTime = performance.now() - start + setTFirstHighlight(Math.round(firstEventTime)) + } + setHighlightEvents(prev => { + if (prev.some(x => x.id === h.id)) return prev + const next = [...prev, { ...h, pubkey: h.pubkey, created_at: h.created_at, id: h.id, kind: 9802, tags: [], content: h.content, sig: '' } as NostrEvent] + return next.sort((a, b) => b.created_at - a.created_at) + }) + }) + } finally { + setIsLoadingHighlights(false) + const elapsed = Math.round(performance.now() - start) + setTLoadHighlights(elapsed) + DebugBus.info('debug', `Loaded friends highlights in ${elapsed}ms`) + } + } + + const handleLoadNostrverseHighlights = async () => { + if (!relayPool) { + DebugBus.warn('debug', 'Relay pool not available') + return + } + const start = performance.now() + setHighlightEvents([]) + setIsLoadingHighlights(true) + setTLoadHighlights(null) + setTFirstHighlight(null) + DebugBus.info('debug', 'Loading nostrverse highlights...') + try { + const all = await fetchNostrverseHighlights(relayPool, 100) + setHighlightEvents(all.map(h => ({ ...h, pubkey: h.pubkey, created_at: h.created_at, id: h.id, kind: 9802, tags: [], content: h.content, sig: '' } as NostrEvent))) + } finally { + setIsLoadingHighlights(false) + const elapsed = Math.round(performance.now() - start) + setTLoadHighlights(elapsed) + DebugBus.info('debug', `Loaded nostrverse highlights in ${elapsed}ms`) + } + } + + const handleLoadFriendsList = async () => { + if (!relayPool || !activeAccount?.pubkey) { + DebugBus.warn('debug', 'Please log in to load friends list') + return + } + setFriendsLoading(true) + setFriendsPubkeys(new Set()) + DebugBus.info('debug', 'Loading friends list...') + try { + const final = await fetchContacts( + relayPool, + activeAccount.pubkey, + (partial) => setFriendsPubkeys(new Set(partial)) + ) + setFriendsPubkeys(new Set(final)) + DebugBus.info('debug', `Loaded ${final.size} friends`) + } finally { + setFriendsLoading(false) + } + } + + const friendsNpubs = useMemo(() => { + return Array.from(friendsPubkeys).map(pk => nip19.npubEncode(pk)) + }, [friendsPubkeys]) + const handleBunkerLogin = async () => { if (!bunkerUri.trim()) { setBunkerError('Please enter a bunker URI') @@ -845,6 +966,31 @@ const Debug: React.FC = ({ +
Quick load options:
+
+ + + +
+
@@ -890,6 +1036,42 @@ const Debug: React.FC = ({ )}
+ {/* Web of Trust Section */} +
+

Web of Trust

+
Load your followed contacts (friends) for highlight fetching:
+ +
+ +
+ + {friendsPubkeys.size > 0 && ( +
+
Friends Count: {friendsNpubs.length}
+
+ {friendsNpubs.map(npub => ( +
+ {npub} +
+ ))} +
+
+ )} +
+ {/* Debug Logs Section */}

Debug Logs

From 78457335c67b7ab9c571772a1dba409246de7f4d Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 18 Oct 2025 20:43:02 +0200 Subject: [PATCH 13/40] refactor: simplify nostrverse highlights loading to direct query - Use direct queryEvents with kind:9802 filter instead of service wrapper - Add streaming with onEvent callback for immediate UI updates - Track first event timing for performance analysis - Remove unused fetchNostrverseHighlights import --- src/components/Debug.tsx | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/components/Debug.tsx b/src/components/Debug.tsx index a03835ab..1d632045 100644 --- a/src/components/Debug.tsx +++ b/src/components/Debug.tsx @@ -18,7 +18,6 @@ import { Bookmark } from '../types/bookmarks' import { useBookmarksUI } from '../hooks/useBookmarksUI' import { useSettings } from '../hooks/useSettings' import { fetchHighlights, fetchHighlightsFromAuthors } from '../services/highlightService' -import { fetchNostrverseHighlights } from '../services/nostrverseService' import { fetchContacts } from '../services/contactService' const defaultPayload = 'The quick brown fox jumps over the lazy dog.' @@ -504,10 +503,25 @@ const Debug: React.FC = ({ setIsLoadingHighlights(true) setTLoadHighlights(null) setTFirstHighlight(null) - DebugBus.info('debug', 'Loading nostrverse highlights...') + DebugBus.info('debug', 'Loading nostrverse highlights (kind:9802)...') try { - const all = await fetchNostrverseHighlights(relayPool, 100) - setHighlightEvents(all.map(h => ({ ...h, pubkey: h.pubkey, created_at: h.created_at, id: h.id, kind: 9802, tags: [], content: h.content, sig: '' } as NostrEvent))) + let firstEventTime: number | null = null + const seenIds = new Set() + const { queryEvents } = await import('../services/dataFetch') + + const events = await queryEvents(relayPool, { kinds: [9802], limit: 100 }, { + onEvent: (evt) => { + if (seenIds.has(evt.id)) return + seenIds.add(evt.id) + if (firstEventTime === null) { + firstEventTime = performance.now() - start + setTFirstHighlight(Math.round(firstEventTime)) + } + setHighlightEvents(prev => [...prev, evt]) + } + }) + + DebugBus.info('debug', `Loaded ${events.length} nostrverse highlights`) } finally { setIsLoadingHighlights(false) const elapsed = Math.round(performance.now() - start) From 1ff2f285665686d3ba0a19248107fb048a506e33 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 18 Oct 2025 20:43:37 +0200 Subject: [PATCH 14/40] chore: increase nostrverse highlights limit from 100 to 500 - Better for testing and debugging with more realistic data volumes --- src/components/Debug.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Debug.tsx b/src/components/Debug.tsx index 1d632045..0f4dd98f 100644 --- a/src/components/Debug.tsx +++ b/src/components/Debug.tsx @@ -509,7 +509,7 @@ const Debug: React.FC = ({ const seenIds = new Set() const { queryEvents } = await import('../services/dataFetch') - const events = await queryEvents(relayPool, { kinds: [9802], limit: 100 }, { + const events = await queryEvents(relayPool, { kinds: [9802], limit: 500 }, { onEvent: (evt) => { if (seenIds.has(evt.id)) return seenIds.add(evt.id) From 8030e2fa007aae7264b17524d992124d597a43a0 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 18 Oct 2025 20:47:35 +0200 Subject: [PATCH 15/40] feat: make friends highlights loading non-blocking - Start fetching highlights immediately when partial contacts arrive - Track seen authors to avoid duplicate queries - Fire-and-forget pattern for partial fetches (like bookmark loading) - Only await final batch for remaining authors - Highlights stream in progressively as contacts are discovered - Matches the non-blocking pattern used in Explore.tsx and bookmark loading --- src/components/Debug.tsx | 62 +++++++++++++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 13 deletions(-) diff --git a/src/components/Debug.tsx b/src/components/Debug.tsx index 0f4dd98f..7887a5e9 100644 --- a/src/components/Debug.tsx +++ b/src/components/Debug.tsx @@ -469,22 +469,58 @@ const Debug: React.FC = ({ setIsLoadingHighlights(true) setTLoadHighlights(null) setTFirstHighlight(null) - DebugBus.info('debug', 'Loading friends highlights...') + DebugBus.info('debug', 'Loading friends highlights (non-blocking)...') + + let firstEventTime: number | null = null + const seenAuthors = new Set() + try { - const contacts = await fetchContacts(relayPool, activeAccount.pubkey) - DebugBus.info('debug', `Found ${contacts.size} friends`) - let firstEventTime: number | null = null - await fetchHighlightsFromAuthors(relayPool, Array.from(contacts), (h) => { - if (firstEventTime === null) { - firstEventTime = performance.now() - start - setTFirstHighlight(Math.round(firstEventTime)) + const contacts = await fetchContacts( + relayPool, + activeAccount.pubkey, + (partial) => { + // Non-blocking: start fetching as soon as we get partial contacts + if (partial.size > 0) { + const partialArray = Array.from(partial).filter(pk => !seenAuthors.has(pk)) + if (partialArray.length > 0) { + partialArray.forEach(pk => seenAuthors.add(pk)) + DebugBus.info('debug', `Fetching highlights from ${partialArray.length} friends (${seenAuthors.size} total)`) + + // Fire and forget - don't await + fetchHighlightsFromAuthors(relayPool, partialArray, (h) => { + if (firstEventTime === null) { + firstEventTime = performance.now() - start + setTFirstHighlight(Math.round(firstEventTime)) + } + setHighlightEvents(prev => { + if (prev.some(x => x.id === h.id)) return prev + const next = [...prev, { ...h, pubkey: h.pubkey, created_at: h.created_at, id: h.id, kind: 9802, tags: [], content: h.content, sig: '' } as NostrEvent] + return next.sort((a, b) => b.created_at - a.created_at) + }) + }).catch(err => console.error('Error fetching highlights from partial:', err)) + } + } } - setHighlightEvents(prev => { - if (prev.some(x => x.id === h.id)) return prev - const next = [...prev, { ...h, pubkey: h.pubkey, created_at: h.created_at, id: h.id, kind: 9802, tags: [], content: h.content, sig: '' } as NostrEvent] - return next.sort((a, b) => b.created_at - a.created_at) + ) + + DebugBus.info('debug', `Found ${contacts.size} total friends`) + + // Fetch any remaining authors not covered by partials + const finalAuthors = Array.from(contacts).filter(pk => !seenAuthors.has(pk)) + if (finalAuthors.length > 0) { + DebugBus.info('debug', `Fetching highlights from ${finalAuthors.length} remaining friends`) + await fetchHighlightsFromAuthors(relayPool, finalAuthors, (h) => { + if (firstEventTime === null) { + firstEventTime = performance.now() - start + setTFirstHighlight(Math.round(firstEventTime)) + } + setHighlightEvents(prev => { + if (prev.some(x => x.id === h.id)) return prev + const next = [...prev, { ...h, pubkey: h.pubkey, created_at: h.created_at, id: h.id, kind: 9802, tags: [], content: h.content, sig: '' } as NostrEvent] + return next.sort((a, b) => b.created_at - a.created_at) + }) }) - }) + } } finally { setIsLoadingHighlights(false) const elapsed = Math.round(performance.now() - start) From d6a913f2a6ce150f6cfaa365327b4e9316d9a2f8 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 18 Oct 2025 20:51:03 +0200 Subject: [PATCH 16/40] feat: add centralized contacts controller - Create contactsController similar to bookmarkController - Manage friends/contacts list in one place across the app - Auto-load contacts on login, cache results per pubkey - Stream partial contacts as they arrive - Update App.tsx to subscribe to contacts controller - Update Debug.tsx to use centralized contacts instead of fetching directly - Reset contacts on logout - Contacts won't reload unnecessarily (cached by pubkey) - Debug 'Load Friends' button forces reload to show streaming behavior --- src/App.tsx | 36 +++++++++ src/components/Debug.tsx | 100 +++++++++++-------------- src/services/contactsController.ts | 114 +++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 58 deletions(-) create mode 100644 src/services/contactsController.ts diff --git a/src/App.tsx b/src/App.tsx index dd1e2c6c..ec689147 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,7 @@ import { SkeletonThemeProvider } from './components/Skeletons' import { DebugBus } from './utils/debugBus' import { Bookmark } from './types/bookmarks' import { bookmarkController } from './services/bookmarkController' +import { contactsController } from './services/contactsController' const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR || 'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew' @@ -40,6 +41,10 @@ function AppRoutes({ const [bookmarks, setBookmarks] = useState([]) const [bookmarksLoading, setBookmarksLoading] = useState(false) + // Centralized contacts state (fed by controller) + const [contacts, setContacts] = useState>(new Set()) + const [contactsLoading, setContactsLoading] = useState(false) + // Subscribe to bookmark controller useEffect(() => { console.log('[bookmark] 🎧 Subscribing to bookmark controller') @@ -59,6 +64,25 @@ function AppRoutes({ } }, []) + // Subscribe to contacts controller + useEffect(() => { + console.log('[contacts] 🎧 Subscribing to contacts controller') + const unsubContacts = contactsController.onContacts((contacts) => { + console.log('[contacts] 📥 Received contacts:', contacts.size) + setContacts(contacts) + }) + const unsubLoading = contactsController.onLoading((loading) => { + console.log('[contacts] 📥 Loading state:', loading) + setContactsLoading(loading) + }) + + return () => { + console.log('[contacts] 🔇 Unsubscribing from contacts controller') + unsubContacts() + unsubLoading() + } + }, []) + // Auto-load bookmarks when account is ready (on login or page mount) useEffect(() => { if (activeAccount && relayPool && bookmarks.length === 0 && !bookmarksLoading) { @@ -67,6 +91,17 @@ function AppRoutes({ } }, [activeAccount, relayPool, bookmarks.length, bookmarksLoading, accountManager]) + // Auto-load contacts when account is ready (on login or page mount) + useEffect(() => { + if (activeAccount && relayPool && contacts.size === 0 && !contactsLoading) { + const pubkey = (activeAccount as { pubkey?: string }).pubkey + if (pubkey) { + console.log('[contacts] 🚀 Auto-loading contacts on mount/login') + contactsController.start({ relayPool, pubkey }) + } + } + }, [activeAccount, relayPool, contacts.size, contactsLoading]) + // Manual refresh (for sidebar button) const handleRefreshBookmarks = useCallback(async () => { if (!relayPool || !activeAccount) { @@ -81,6 +116,7 @@ function AppRoutes({ const handleLogout = () => { accountManager.clearActive() bookmarkController.reset() // Clear bookmarks via controller + contactsController.reset() // Clear contacts via controller showToast('Logged out successfully') } diff --git a/src/components/Debug.tsx b/src/components/Debug.tsx index 7887a5e9..43ea5b78 100644 --- a/src/components/Debug.tsx +++ b/src/components/Debug.tsx @@ -18,7 +18,7 @@ import { Bookmark } from '../types/bookmarks' import { useBookmarksUI } from '../hooks/useBookmarksUI' import { useSettings } from '../hooks/useSettings' import { fetchHighlights, fetchHighlightsFromAuthors } from '../services/highlightService' -import { fetchContacts } from '../services/contactService' +import { contactsController } from '../services/contactsController' const defaultPayload = 'The quick brown fox jumps over the lazy dog.' @@ -111,6 +111,12 @@ const Debug: React.FC = ({ return DebugBus.subscribe((e) => setLogs(prev => [...prev, e].slice(-300))) }, []) + // Subscribe to contacts controller for friends list display + useEffect(() => { + const unsubLoading = contactsController.onLoading(setFriendsLoading) + return unsubLoading + }, []) + // Live timer effect - triggers re-renders for live timing updates useEffect(() => { const interval = setInterval(() => { @@ -464,63 +470,40 @@ const Debug: React.FC = ({ DebugBus.warn('debug', 'Please log in to load friends highlights') return } + + // Ensure contacts are loaded first (will use cache if already loaded) + if (!contactsController.isLoadedFor(activeAccount.pubkey)) { + DebugBus.info('debug', 'Loading contacts first...') + await contactsController.start({ relayPool, pubkey: activeAccount.pubkey }) + } + + const contacts = contactsController.getContacts() + if (contacts.size === 0) { + DebugBus.warn('debug', 'No friends found') + return + } + const start = performance.now() setHighlightEvents([]) setIsLoadingHighlights(true) setTLoadHighlights(null) setTFirstHighlight(null) - DebugBus.info('debug', 'Loading friends highlights (non-blocking)...') + DebugBus.info('debug', `Loading highlights from ${contacts.size} friends...`) let firstEventTime: number | null = null - const seenAuthors = new Set() try { - const contacts = await fetchContacts( - relayPool, - activeAccount.pubkey, - (partial) => { - // Non-blocking: start fetching as soon as we get partial contacts - if (partial.size > 0) { - const partialArray = Array.from(partial).filter(pk => !seenAuthors.has(pk)) - if (partialArray.length > 0) { - partialArray.forEach(pk => seenAuthors.add(pk)) - DebugBus.info('debug', `Fetching highlights from ${partialArray.length} friends (${seenAuthors.size} total)`) - - // Fire and forget - don't await - fetchHighlightsFromAuthors(relayPool, partialArray, (h) => { - if (firstEventTime === null) { - firstEventTime = performance.now() - start - setTFirstHighlight(Math.round(firstEventTime)) - } - setHighlightEvents(prev => { - if (prev.some(x => x.id === h.id)) return prev - const next = [...prev, { ...h, pubkey: h.pubkey, created_at: h.created_at, id: h.id, kind: 9802, tags: [], content: h.content, sig: '' } as NostrEvent] - return next.sort((a, b) => b.created_at - a.created_at) - }) - }).catch(err => console.error('Error fetching highlights from partial:', err)) - } - } + await fetchHighlightsFromAuthors(relayPool, Array.from(contacts), (h) => { + if (firstEventTime === null) { + firstEventTime = performance.now() - start + setTFirstHighlight(Math.round(firstEventTime)) } - ) - - DebugBus.info('debug', `Found ${contacts.size} total friends`) - - // Fetch any remaining authors not covered by partials - const finalAuthors = Array.from(contacts).filter(pk => !seenAuthors.has(pk)) - if (finalAuthors.length > 0) { - DebugBus.info('debug', `Fetching highlights from ${finalAuthors.length} remaining friends`) - await fetchHighlightsFromAuthors(relayPool, finalAuthors, (h) => { - if (firstEventTime === null) { - firstEventTime = performance.now() - start - setTFirstHighlight(Math.round(firstEventTime)) - } - setHighlightEvents(prev => { - if (prev.some(x => x.id === h.id)) return prev - const next = [...prev, { ...h, pubkey: h.pubkey, created_at: h.created_at, id: h.id, kind: 9802, tags: [], content: h.content, sig: '' } as NostrEvent] - return next.sort((a, b) => b.created_at - a.created_at) - }) + setHighlightEvents(prev => { + if (prev.some(x => x.id === h.id)) return prev + const next = [...prev, { ...h, pubkey: h.pubkey, created_at: h.created_at, id: h.id, kind: 9802, tags: [], content: h.content, sig: '' } as NostrEvent] + return next.sort((a, b) => b.created_at - a.created_at) }) - } + }) } finally { setIsLoadingHighlights(false) const elapsed = Math.round(performance.now() - start) @@ -571,19 +554,20 @@ const Debug: React.FC = ({ DebugBus.warn('debug', 'Please log in to load friends list') return } - setFriendsLoading(true) - setFriendsPubkeys(new Set()) - DebugBus.info('debug', 'Loading friends list...') + DebugBus.info('debug', 'Loading friends list via controller...') + + // Subscribe to controller updates + const unsubscribe = contactsController.onContacts((contacts) => { + setFriendsPubkeys(new Set(contacts)) + }) + try { - const final = await fetchContacts( - relayPool, - activeAccount.pubkey, - (partial) => setFriendsPubkeys(new Set(partial)) - ) - setFriendsPubkeys(new Set(final)) - DebugBus.info('debug', `Loaded ${final.size} friends`) + // Force reload to see streaming behavior + await contactsController.start({ relayPool, pubkey: activeAccount.pubkey, force: true }) + const final = contactsController.getContacts() + DebugBus.info('debug', `Loaded ${final.size} friends from controller`) } finally { - setFriendsLoading(false) + unsubscribe() } } diff --git a/src/services/contactsController.ts b/src/services/contactsController.ts new file mode 100644 index 00000000..b6f08b75 --- /dev/null +++ b/src/services/contactsController.ts @@ -0,0 +1,114 @@ +import { RelayPool } from 'applesauce-relay' +import { fetchContacts } from './contactService' + +type ContactsCallback = (contacts: Set) => void +type LoadingCallback = (loading: boolean) => void + +/** + * Shared contacts/friends controller + * Manages the user's follow list centrally, similar to bookmarkController + */ +class ContactsController { + private contactsListeners: ContactsCallback[] = [] + private loadingListeners: LoadingCallback[] = [] + + private currentContacts: Set = new Set() + private lastLoadedPubkey: string | null = null + + onContacts(cb: ContactsCallback): () => void { + this.contactsListeners.push(cb) + return () => { + this.contactsListeners = this.contactsListeners.filter(l => l !== cb) + } + } + + onLoading(cb: LoadingCallback): () => void { + this.loadingListeners.push(cb) + return () => { + this.loadingListeners = this.loadingListeners.filter(l => l !== cb) + } + } + + private setLoading(loading: boolean): void { + this.loadingListeners.forEach(cb => cb(loading)) + } + + private emitContacts(contacts: Set): void { + this.contactsListeners.forEach(cb => cb(contacts)) + } + + /** + * Get current contacts without triggering a reload + */ + getContacts(): Set { + return new Set(this.currentContacts) + } + + /** + * Check if contacts are loaded for a specific pubkey + */ + isLoadedFor(pubkey: string): boolean { + return this.lastLoadedPubkey === pubkey && this.currentContacts.size > 0 + } + + /** + * Reset state (for logout or manual refresh) + */ + reset(): void { + this.currentContacts.clear() + this.lastLoadedPubkey = null + this.emitContacts(this.currentContacts) + } + + /** + * Load contacts for a user + * Streams partial results and caches the final list + */ + async start(options: { + relayPool: RelayPool + pubkey: string + force?: boolean + }): Promise { + const { relayPool, pubkey, force = false } = options + + // Skip if already loaded for this pubkey (unless forced) + if (!force && this.isLoadedFor(pubkey)) { + console.log('[contacts] ✅ Already loaded for', pubkey.slice(0, 8)) + this.emitContacts(this.currentContacts) + return + } + + this.setLoading(true) + console.log('[contacts] 🔍 Loading contacts for', pubkey.slice(0, 8)) + + try { + const contacts = await fetchContacts( + relayPool, + pubkey, + (partial) => { + // Stream partial updates + this.currentContacts = new Set(partial) + this.emitContacts(this.currentContacts) + console.log('[contacts] 📥 Partial contacts:', partial.size) + } + ) + + // Store final result + this.currentContacts = new Set(contacts) + this.lastLoadedPubkey = pubkey + this.emitContacts(this.currentContacts) + + console.log('[contacts] ✅ Loaded', contacts.size, 'contacts') + } catch (error) { + console.error('[contacts] ❌ Failed to load contacts:', error) + this.currentContacts.clear() + this.emitContacts(this.currentContacts) + } finally { + this.setLoading(false) + } + } +} + +// Singleton instance +export const contactsController = new ContactsController() + From 1e6718fe1e2eba8d4220823efa1dd6966958c376 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 18 Oct 2025 20:56:53 +0200 Subject: [PATCH 17/40] fix: improve Load Friends button behavior in Debug - Add local loading state for button (friendsButtonLoading) - Clear friends list before loading to show streaming - Set final result after controller completes - Add error handling and logging - Remove unused global friendsLoading subscription - Button now properly shows loading state and results --- src/components/Debug.tsx | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/components/Debug.tsx b/src/components/Debug.tsx index 43ea5b78..de2b8c73 100644 --- a/src/components/Debug.tsx +++ b/src/components/Debug.tsx @@ -105,18 +105,12 @@ const Debug: React.FC = ({ // Web of Trust state const [friendsPubkeys, setFriendsPubkeys] = useState>(new Set()) - const [friendsLoading, setFriendsLoading] = useState(false) + const [friendsButtonLoading, setFriendsButtonLoading] = useState(false) useEffect(() => { return DebugBus.subscribe((e) => setLogs(prev => [...prev, e].slice(-300))) }, []) - // Subscribe to contacts controller for friends list display - useEffect(() => { - const unsubLoading = contactsController.onLoading(setFriendsLoading) - return unsubLoading - }, []) - // Live timer effect - triggers re-renders for live timing updates useEffect(() => { const interval = setInterval(() => { @@ -554,10 +548,16 @@ const Debug: React.FC = ({ DebugBus.warn('debug', 'Please log in to load friends list') return } + + setFriendsButtonLoading(true) DebugBus.info('debug', 'Loading friends list via controller...') - // Subscribe to controller updates + // Clear current list + setFriendsPubkeys(new Set()) + + // Subscribe to controller updates to see streaming const unsubscribe = contactsController.onContacts((contacts) => { + console.log('[debug] Received contacts update:', contacts.size) setFriendsPubkeys(new Set(contacts)) }) @@ -565,9 +565,14 @@ const Debug: React.FC = ({ // Force reload to see streaming behavior await contactsController.start({ relayPool, pubkey: activeAccount.pubkey, force: true }) const final = contactsController.getContacts() + setFriendsPubkeys(new Set(final)) DebugBus.info('debug', `Loaded ${final.size} friends from controller`) + } catch (err) { + console.error('[debug] Failed to load friends:', err) + DebugBus.error('debug', `Failed to load friends: ${err instanceof Error ? err.message : String(err)}`) } finally { unsubscribe() + setFriendsButtonLoading(false) } } @@ -1079,9 +1084,9 @@ const Debug: React.FC = ({
+
+ +
+ onUpdate({ defaultExploreScopeNostrverse: !(settings.defaultExploreScopeNostrverse !== false) })} + title="Nostrverse content" + ariaLabel="Toggle nostrverse content by default in explore" + variant="ghost" + style={{ + color: (settings.defaultExploreScopeNostrverse !== false) ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined, + opacity: (settings.defaultExploreScopeNostrverse !== false) ? 1 : 0.4 + }} + /> + onUpdate({ defaultExploreScopeFriends: !(settings.defaultExploreScopeFriends !== false) })} + title="Friends content" + ariaLabel="Toggle friends content by default in explore" + variant="ghost" + style={{ + color: (settings.defaultExploreScopeFriends !== false) ? 'var(--highlight-color-friends, #f97316)' : undefined, + opacity: (settings.defaultExploreScopeFriends !== false) ? 1 : 0.4 + }} + /> + onUpdate({ defaultExploreScopeMine: !(settings.defaultExploreScopeMine !== false) })} + title="My content" + ariaLabel="Toggle my content by default in explore" + variant="ghost" + style={{ + color: (settings.defaultExploreScopeMine !== false) ? 'var(--highlight-color-mine, #eab308)' : undefined, + opacity: (settings.defaultExploreScopeMine !== false) ? 1 : 0.4 + }} + /> +
+
+
diff --git a/src/services/settingsService.ts b/src/services/settingsService.ts index a36c7879..54c278a6 100644 --- a/src/services/settingsService.ts +++ b/src/services/settingsService.ts @@ -36,6 +36,10 @@ export interface UserSettings { defaultHighlightVisibilityNostrverse?: boolean defaultHighlightVisibilityFriends?: boolean defaultHighlightVisibilityMine?: boolean + // Default explore scope + defaultExploreScopeNostrverse?: boolean + defaultExploreScopeFriends?: boolean + defaultExploreScopeMine?: boolean // Zap split weights (treated as relative weights, not strict percentages) zapSplitHighlighterWeight?: number // default 50 zapSplitBorisWeight?: number // default 2.1 From 36f14811ae5df68af315b33246a02a0949eee931 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 18 Oct 2025 21:47:34 +0200 Subject: [PATCH 27/40] refactor: add dedicated Explore section in settings Create new ExploreSettings component and organize explore-related settings. Changes: - Create src/components/Settings/ExploreSettings.tsx - Move "Default Explore Scope" from ReadingDisplaySettings to ExploreSettings - Add ExploreSettings to Settings.tsx above Zap Splits section - Better organization: explore settings now in dedicated section Settings order: 1. Theme 2. Reading Display 3. Explore (new) 4. Zap Splits 5. Layout & Behavior 6. PWA 7. Relays --- src/components/Settings.tsx | 2 + src/components/Settings/ExploreSettings.tsx | 59 +++++++++++++++++++ .../Settings/ReadingDisplaySettings.tsx | 39 ------------ 3 files changed, 61 insertions(+), 39 deletions(-) create mode 100644 src/components/Settings/ExploreSettings.tsx diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 97a4371d..f3001a06 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -6,6 +6,7 @@ import IconButton from './IconButton' import { loadFont } from '../utils/fontLoader' import ThemeSettings from './Settings/ThemeSettings' import ReadingDisplaySettings from './Settings/ReadingDisplaySettings' +import ExploreSettings from './Settings/ExploreSettings' import LayoutBehaviorSettings from './Settings/LayoutBehaviorSettings' import ZapSettings from './Settings/ZapSettings' import RelaySettings from './Settings/RelaySettings' @@ -166,6 +167,7 @@ const Settings: React.FC = ({ settings, onSave, onClose, relayPoo
+ diff --git a/src/components/Settings/ExploreSettings.tsx b/src/components/Settings/ExploreSettings.tsx new file mode 100644 index 00000000..8bb33f8f --- /dev/null +++ b/src/components/Settings/ExploreSettings.tsx @@ -0,0 +1,59 @@ +import React from 'react' +import { faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons' +import { UserSettings } from '../../services/settingsService' +import IconButton from '../IconButton' + +interface ExploreSettingsProps { + settings: UserSettings + onUpdate: (updates: Partial) => void +} + +const ExploreSettings: React.FC = ({ settings, onUpdate }) => { + return ( +
+

Explore

+ +
+ +
+ onUpdate({ defaultExploreScopeNostrverse: !(settings.defaultExploreScopeNostrverse !== false) })} + title="Nostrverse content" + ariaLabel="Toggle nostrverse content by default in explore" + variant="ghost" + style={{ + color: (settings.defaultExploreScopeNostrverse !== false) ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined, + opacity: (settings.defaultExploreScopeNostrverse !== false) ? 1 : 0.4 + }} + /> + onUpdate({ defaultExploreScopeFriends: !(settings.defaultExploreScopeFriends !== false) })} + title="Friends content" + ariaLabel="Toggle friends content by default in explore" + variant="ghost" + style={{ + color: (settings.defaultExploreScopeFriends !== false) ? 'var(--highlight-color-friends, #f97316)' : undefined, + opacity: (settings.defaultExploreScopeFriends !== false) ? 1 : 0.4 + }} + /> + onUpdate({ defaultExploreScopeMine: !(settings.defaultExploreScopeMine !== false) })} + title="My content" + ariaLabel="Toggle my content by default in explore" + variant="ghost" + style={{ + color: (settings.defaultExploreScopeMine !== false) ? 'var(--highlight-color-mine, #eab308)' : undefined, + opacity: (settings.defaultExploreScopeMine !== false) ? 1 : 0.4 + }} + /> +
+
+
+ ) +} + +export default ExploreSettings + diff --git a/src/components/Settings/ReadingDisplaySettings.tsx b/src/components/Settings/ReadingDisplaySettings.tsx index 862e2a1d..15565f79 100644 --- a/src/components/Settings/ReadingDisplaySettings.tsx +++ b/src/components/Settings/ReadingDisplaySettings.tsx @@ -98,45 +98,6 @@ const ReadingDisplaySettings: React.FC = ({ setting
-
- -
- onUpdate({ defaultExploreScopeNostrverse: !(settings.defaultExploreScopeNostrverse !== false) })} - title="Nostrverse content" - ariaLabel="Toggle nostrverse content by default in explore" - variant="ghost" - style={{ - color: (settings.defaultExploreScopeNostrverse !== false) ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined, - opacity: (settings.defaultExploreScopeNostrverse !== false) ? 1 : 0.4 - }} - /> - onUpdate({ defaultExploreScopeFriends: !(settings.defaultExploreScopeFriends !== false) })} - title="Friends content" - ariaLabel="Toggle friends content by default in explore" - variant="ghost" - style={{ - color: (settings.defaultExploreScopeFriends !== false) ? 'var(--highlight-color-friends, #f97316)' : undefined, - opacity: (settings.defaultExploreScopeFriends !== false) ? 1 : 0.4 - }} - /> - onUpdate({ defaultExploreScopeMine: !(settings.defaultExploreScopeMine !== false) })} - title="My content" - ariaLabel="Toggle my content by default in explore" - variant="ghost" - style={{ - color: (settings.defaultExploreScopeMine !== false) ? 'var(--highlight-color-mine, #eab308)' : undefined, - opacity: (settings.defaultExploreScopeMine !== false) ? 1 : 0.4 - }} - /> -
-
-
From 809437faa6b6bb9d0522e9b7bd2b31ffd67da2d3 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 18 Oct 2025 21:49:17 +0200 Subject: [PATCH 28/40] style: make Explore a section title like Zap Splits Add section-title class to Explore heading to match other section headings. --- src/components/Settings/ExploreSettings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Settings/ExploreSettings.tsx b/src/components/Settings/ExploreSettings.tsx index 8bb33f8f..84a96828 100644 --- a/src/components/Settings/ExploreSettings.tsx +++ b/src/components/Settings/ExploreSettings.tsx @@ -11,7 +11,7 @@ interface ExploreSettingsProps { const ExploreSettings: React.FC = ({ settings, onUpdate }) => { return (
-

Explore

+

Explore

From 780996c7c5b0cd208df95b72fad7d03d0eff257f Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 18 Oct 2025 21:52:25 +0200 Subject: [PATCH 29/40] fix: prevent "No highlights yet" flash on /me/highlights Fix issue where "No highlights yet" message would show briefly when navigating to /me/highlights even when user has many highlights. Root cause: - Sync effect only ran when myHighlights.length > 0 - Local highlights state could be empty during navigation - "No highlights yet" condition didn't check myHighlightsLoading Changes: - Remove length check from sync effect (always sync myHighlights) - Add myHighlightsLoading check to "No highlights yet" condition - Now shows skeleton or content, never false empty state The controller always has the highlights loaded, so we should always sync them to local state regardless of length. --- src/components/Me.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Me.tsx b/src/components/Me.tsx index 6de6f949..13a18d89 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -319,7 +319,7 @@ const Me: React.FC = ({ // Sync myHighlights from controller when viewing own profile useEffect(() => { - if (isOwnProfile && myHighlights.length > 0) { + if (isOwnProfile) { setHighlights(myHighlights) } }, [isOwnProfile, myHighlights]) @@ -457,7 +457,7 @@ const Me: React.FC = ({
) } - return highlights.length === 0 && !loading ? ( + return highlights.length === 0 && !loading && !(isOwnProfile && myHighlightsLoading) ? (
No highlights yet.
From f8a9079e5ffad12fb15f214039d5c30cdfdd0c28 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 18 Oct 2025 21:54:27 +0200 Subject: [PATCH 30/40] fix: don't manually set highlights in loadHighlightsTab for own profile The real issue: loadHighlightsTab was calling setHighlights(myHighlights) before the controller subscription had populated myHighlights, resulting in setting highlights to an empty array. Solution: For own profile, let the sync effect handle setting highlights. The controller subscription + sync effect is the single source of truth. Only fetch highlights manually when viewing other users' profiles. Flow for own profile: 1. Controller subscription populates myHighlights 2. Sync effect (useEffect) updates local highlights state 3. No manual setting needed in loadHighlightsTab This ensures highlights are always synced from the controller, never from a stale/empty initial value. --- src/components/Me.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/components/Me.tsx b/src/components/Me.tsx index 13a18d89..aec4e724 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -139,11 +139,9 @@ const Me: React.FC = ({ try { if (!hasBeenLoaded) setLoading(true) - // For own profile, use myHighlights from controller (already loaded on app start) - if (isOwnProfile) { - setHighlights(myHighlights) - } else { - // For viewing other users, fetch on-demand + // For own profile, highlights come from controller subscription (sync effect handles it) + // For viewing other users, fetch on-demand + if (!isOwnProfile) { const userHighlights = await fetchHighlights(relayPool, viewingPubkey) setHighlights(userHighlights) } From 35b2168f9a11190745e866e063a1b6e7157e10d6 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 18 Oct 2025 21:56:27 +0200 Subject: [PATCH 31/40] fix: get initial highlights state immediately from controller The subscription pattern only fires on *changes*, not initial state. When Me component mounts, we need to immediately get the current highlights from the controller, not wait for a change event. Before: - Subscribe to controller - Wait for controller to emit (only happens on changes) - Meanwhile, myHighlights stays [] After: - Get initial state immediately: highlightsController.getHighlights() - Then subscribe to future updates - myHighlights is populated right away This ensures highlights are always available when navigating to /me/highlights, even if the controller hasn't emitted any new events. --- src/components/Me.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/Me.tsx b/src/components/Me.tsx index aec4e724..380140dc 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -94,6 +94,10 @@ const Me: React.FC = ({ // Subscribe to highlights controller useEffect(() => { + // Get initial state immediately + setMyHighlights(highlightsController.getHighlights()) + + // Subscribe to updates const unsubHighlights = highlightsController.onHighlights(setMyHighlights) const unsubLoading = highlightsController.onLoading(setMyHighlightsLoading) return () => { From dcfc08287ed28a4c93337f0ac5fa7f146acaa615 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 18 Oct 2025 22:01:44 +0200 Subject: [PATCH 32/40] refactor: use centralized controllers in highlights sidebar - Subscribe to highlightsController for user's own highlights - Subscribe to contactsController for followed pubkeys - Merge controller highlights with article-specific highlights - Remove duplicate fetching logic for contacts and own highlights - Maintain article-specific highlight fetching for context-aware display --- src/hooks/useBookmarksData.ts | 65 ++++++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 21 deletions(-) diff --git a/src/hooks/useBookmarksData.ts b/src/hooks/useBookmarksData.ts index f136b340..2cf10fe0 100644 --- a/src/hooks/useBookmarksData.ts +++ b/src/hooks/useBookmarksData.ts @@ -3,9 +3,10 @@ import { RelayPool } from 'applesauce-relay' import { IAccount } from 'applesauce-accounts' import { Bookmark } from '../types/bookmarks' import { Highlight } from '../types/highlights' -import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService' -import { fetchContacts } from '../services/contactService' +import { fetchHighlightsForArticle } from '../services/highlightService' import { UserSettings } from '../services/settingsService' +import { highlightsController } from '../services/highlightsController' +import { contactsController } from '../services/contactsController' interface UseBookmarksDataParams { relayPool: RelayPool | null @@ -30,17 +31,30 @@ export const useBookmarksData = ({ settings, onRefreshBookmarks }: Omit) => { - const [highlights, setHighlights] = useState([]) + const [myHighlights, setMyHighlights] = useState([]) + const [articleHighlights, setArticleHighlights] = useState([]) const [highlightsLoading, setHighlightsLoading] = useState(true) const [followedPubkeys, setFollowedPubkeys] = useState>(new Set()) const [isRefreshing, setIsRefreshing] = useState(false) const [lastFetchTime, setLastFetchTime] = useState(null) - const handleFetchContacts = useCallback(async () => { - if (!relayPool || !activeAccount) return - const contacts = await fetchContacts(relayPool, activeAccount.pubkey) - setFollowedPubkeys(contacts) - }, [relayPool, activeAccount]) + // Subscribe to centralized controllers + useEffect(() => { + // Get initial state immediately + setMyHighlights(highlightsController.getHighlights()) + setFollowedPubkeys(new Set(contactsController.getContacts())) + + // Subscribe to updates + const unsubHighlights = highlightsController.onHighlights(setMyHighlights) + const unsubContacts = contactsController.onContacts((contacts) => { + setFollowedPubkeys(new Set(contacts)) + }) + + return () => { + unsubHighlights() + unsubContacts() + } + }, []) const handleFetchHighlights = useCallback(async () => { if (!relayPool) return @@ -48,6 +62,7 @@ export const useBookmarksData = ({ setHighlightsLoading(true) try { if (currentArticleCoordinate) { + // Fetch article-specific highlights (from all users) const highlightsMap = new Map() await fetchHighlightsForArticle( relayPool, @@ -58,22 +73,22 @@ export const useBookmarksData = ({ if (!highlightsMap.has(highlight.id)) { highlightsMap.set(highlight.id, highlight) const highlightsList = Array.from(highlightsMap.values()) - setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at)) + setArticleHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at)) } }, settings ) console.log(`🔄 Refreshed ${highlightsMap.size} highlights for article`) - } else if (activeAccount) { - const fetchedHighlights = await fetchHighlights(relayPool, activeAccount.pubkey, undefined, settings) - setHighlights(fetchedHighlights) + } else { + // No article selected - clear article highlights + setArticleHighlights([]) } } catch (err) { console.error('Failed to fetch highlights:', err) } finally { setHighlightsLoading(false) } - }, [relayPool, activeAccount, currentArticleCoordinate, currentArticleEventId, settings]) + }, [relayPool, currentArticleCoordinate, currentArticleEventId, settings]) const handleRefreshAll = useCallback(async () => { if (!relayPool || !activeAccount || isRefreshing) return @@ -82,29 +97,37 @@ export const useBookmarksData = ({ try { await onRefreshBookmarks() await handleFetchHighlights() - await handleFetchContacts() + // Contacts and own highlights are managed by controllers setLastFetchTime(Date.now()) } catch (err) { console.error('Failed to refresh data:', err) } finally { setIsRefreshing(false) } - }, [relayPool, activeAccount, isRefreshing, onRefreshBookmarks, handleFetchHighlights, handleFetchContacts]) + }, [relayPool, activeAccount, isRefreshing, onRefreshBookmarks, handleFetchHighlights]) - // Fetch highlights/contacts independently + // Fetch article-specific highlights when viewing an article useEffect(() => { if (!relayPool || !activeAccount) return - // Only fetch general highlights when not viewing an article (naddr) or external URL + // Fetch article-specific highlights when viewing an article // External URLs have their highlights fetched by useExternalUrlLoader - if (!naddr && !externalUrl) { + if (currentArticleCoordinate && !externalUrl) { handleFetchHighlights() + } else if (!naddr && !externalUrl) { + // Clear article highlights when not viewing an article + setArticleHighlights([]) + setHighlightsLoading(false) } - handleFetchContacts() - }, [relayPool, activeAccount, naddr, externalUrl, handleFetchHighlights, handleFetchContacts]) + }, [relayPool, activeAccount, currentArticleCoordinate, naddr, externalUrl, handleFetchHighlights]) + + // Merge highlights from controller with article-specific highlights + const highlights = [...myHighlights, ...articleHighlights] + .filter((h, i, arr) => arr.findIndex(x => x.id === h.id) === i) // Deduplicate + .sort((a, b) => b.created_at - a.created_at) return { highlights, - setHighlights, + setHighlights: setArticleHighlights, // For external updates (like from useExternalUrlLoader) highlightsLoading, setHighlightsLoading, followedPubkeys, From ed75bc6059adc9f0c512ba6f837defd99916c372 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 18 Oct 2025 22:05:22 +0200 Subject: [PATCH 33/40] feat: store article-specific highlights in centralized event store - Pass eventStore to fetchHighlightsForArticle in useBookmarksData - Pass eventStore to fetchHighlightsForUrl in useExternalUrlLoader - All fetched highlights now persist in the centralized event store - Enables offline access and consistent state management --- src/components/Bookmarks.tsx | 2 ++ src/hooks/useBookmarksData.ts | 9 +++++++-- src/hooks/useExternalUrlLoader.ts | 10 ++++++++-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/components/Bookmarks.tsx b/src/components/Bookmarks.tsx index 3365c1bc..cfe9924a 100644 --- a/src/components/Bookmarks.tsx +++ b/src/components/Bookmarks.tsx @@ -179,6 +179,7 @@ const Bookmarks: React.FC = ({ currentArticleCoordinate, currentArticleEventId, settings, + eventStore, onRefreshBookmarks }) @@ -242,6 +243,7 @@ const Bookmarks: React.FC = ({ useExternalUrlLoader({ url: externalUrl, relayPool, + eventStore, setSelectedUrl, setReaderContent, setReaderLoading, diff --git a/src/hooks/useBookmarksData.ts b/src/hooks/useBookmarksData.ts index 2cf10fe0..c34f98e7 100644 --- a/src/hooks/useBookmarksData.ts +++ b/src/hooks/useBookmarksData.ts @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from 'react' import { RelayPool } from 'applesauce-relay' import { IAccount } from 'applesauce-accounts' +import { IEventStore } from 'applesauce-core' import { Bookmark } from '../types/bookmarks' import { Highlight } from '../types/highlights' import { fetchHighlightsForArticle } from '../services/highlightService' @@ -16,6 +17,7 @@ interface UseBookmarksDataParams { currentArticleCoordinate?: string currentArticleEventId?: string settings?: UserSettings + eventStore?: IEventStore | null bookmarks: Bookmark[] // Passed from App.tsx (centralized loading) bookmarksLoading: boolean // Passed from App.tsx (centralized loading) onRefreshBookmarks: () => Promise @@ -29,6 +31,7 @@ export const useBookmarksData = ({ currentArticleCoordinate, currentArticleEventId, settings, + eventStore, onRefreshBookmarks }: Omit) => { const [myHighlights, setMyHighlights] = useState([]) @@ -76,7 +79,9 @@ export const useBookmarksData = ({ setArticleHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at)) } }, - settings + settings, + false, // force + eventStore || undefined ) console.log(`🔄 Refreshed ${highlightsMap.size} highlights for article`) } else { @@ -88,7 +93,7 @@ export const useBookmarksData = ({ } finally { setHighlightsLoading(false) } - }, [relayPool, currentArticleCoordinate, currentArticleEventId, settings]) + }, [relayPool, currentArticleCoordinate, currentArticleEventId, settings, eventStore]) const handleRefreshAll = useCallback(async () => { if (!relayPool || !activeAccount || isRefreshing) return diff --git a/src/hooks/useExternalUrlLoader.ts b/src/hooks/useExternalUrlLoader.ts index 598f1220..6bbdabe4 100644 --- a/src/hooks/useExternalUrlLoader.ts +++ b/src/hooks/useExternalUrlLoader.ts @@ -1,5 +1,6 @@ import { useEffect } from 'react' import { RelayPool } from 'applesauce-relay' +import { IEventStore } from 'applesauce-core' import { fetchReadableContent, ReadableContent } from '../services/readerService' import { fetchHighlightsForUrl } from '../services/highlightService' import { Highlight } from '../types/highlights' @@ -20,6 +21,7 @@ function getFilenameFromUrl(url: string): string { interface UseExternalUrlLoaderProps { url: string | undefined relayPool: RelayPool | null + eventStore?: IEventStore | null setSelectedUrl: (url: string) => void setReaderContent: (content: ReadableContent | undefined) => void setReaderLoading: (loading: boolean) => void @@ -33,6 +35,7 @@ interface UseExternalUrlLoaderProps { export function useExternalUrlLoader({ url, relayPool, + eventStore, setSelectedUrl, setReaderContent, setReaderLoading, @@ -82,7 +85,10 @@ export function useExternalUrlLoader({ const next = [...prev, highlight] return next.sort((a, b) => b.created_at - a.created_at) }) - } + }, + undefined, // settings + false, // force + eventStore || undefined ) // Highlights are already set via the streaming callback // No need to set them again as that could cause a flash/disappearance @@ -109,6 +115,6 @@ export function useExternalUrlLoader({ } loadExternalUrl() - }, [url, relayPool, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId]) + }, [url, relayPool, eventStore, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId]) } From 6099e3c6a41bd5f911bf1bc752ef9c5f4c1e072d Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 18 Oct 2025 22:08:22 +0200 Subject: [PATCH 34/40] feat: store nostrverse content in centralized event store - Add eventStore parameter to fetchNostrverseBlogPosts - Add eventStore parameter to fetchNostrverseHighlights - Pass eventStore from Explore component to nostrverse fetchers - Store all nostrverse blog posts and highlights in event store - Enables offline access to nostrverse content --- src/components/Explore.tsx | 4 ++-- src/services/nostrverseService.ts | 33 +++++++++++++++++++++++++++---- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index bf12f64e..e43874f4 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -184,8 +184,8 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti const [friendsPosts, friendsHighlights, nostrversePosts, nostriverseHighlights] = await Promise.all([ fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls), fetchHighlightsFromAuthors(relayPool, contactsArray), - fetchNostrverseBlogPosts(relayPool, relayUrls, 50), - fetchNostrverseHighlights(relayPool, 100) + fetchNostrverseBlogPosts(relayPool, relayUrls, 50, eventStore || undefined), + fetchNostrverseHighlights(relayPool, 100, eventStore || undefined) ]) // Merge and deduplicate all posts diff --git a/src/services/nostrverseService.ts b/src/services/nostrverseService.ts index 586136b5..44933452 100644 --- a/src/services/nostrverseService.ts +++ b/src/services/nostrverseService.ts @@ -1,6 +1,6 @@ import { RelayPool } from 'applesauce-relay' import { NostrEvent } from 'nostr-tools' -import { Helpers } from 'applesauce-core' +import { Helpers, IEventStore } from 'applesauce-core' import { BlogPostPreview } from './exploreService' import { Highlight } from '../types/highlights' import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor' @@ -13,12 +13,14 @@ const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary * @param relayPool - The relay pool to query * @param relayUrls - Array of relay URLs to query * @param limit - Maximum number of posts to fetch (default: 50) + * @param eventStore - Optional event store to persist fetched events * @returns Array of blog post previews */ export const fetchNostrverseBlogPosts = async ( relayPool: RelayPool, relayUrls: string[], - limit = 50 + limit = 50, + eventStore?: IEventStore ): Promise => { try { console.log('📚 Fetching nostrverse blog posts (kind 30023), limit:', limit) @@ -32,6 +34,11 @@ export const fetchNostrverseBlogPosts = async ( { relayUrls, onEvent: (event: NostrEvent) => { + // Store in event store if provided + if (eventStore) { + eventStore.add(event) + } + const dTag = event.tags.find(t => t[0] === 'd')?.[1] || '' const key = `${event.pubkey}:${dTag}` const existing = uniqueEvents.get(key) @@ -73,21 +80,39 @@ export const fetchNostrverseBlogPosts = async ( * Fetches public highlights (kind:9802) from the nostrverse (not filtered by author) * @param relayPool - The relay pool to query * @param limit - Maximum number of highlights to fetch (default: 100) + * @param eventStore - Optional event store to persist fetched events * @returns Array of highlights */ export const fetchNostrverseHighlights = async ( relayPool: RelayPool, - limit = 100 + limit = 100, + eventStore?: IEventStore ): Promise => { try { console.log('💡 Fetching nostrverse highlights (kind 9802), limit:', limit) + const seenIds = new Set() const rawEvents = await queryEvents( relayPool, { kinds: [9802], limit }, - {} + { + onEvent: (event: NostrEvent) => { + if (seenIds.has(event.id)) return + seenIds.add(event.id) + + // Store in event store if provided + if (eventStore) { + eventStore.add(event) + } + } + } ) + // Store all events in event store if provided (in case some were missed in streaming) + if (eventStore) { + rawEvents.forEach(evt => eventStore.add(evt)) + } + const uniqueEvents = dedupeHighlights(rawEvents) const highlights = uniqueEvents.map(eventToHighlight) From 1d6595f7540a530fd79289b0fac18b8f319b3f19 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 18 Oct 2025 22:19:17 +0200 Subject: [PATCH 35/40] fix: deduplicate blog posts by author:d-tag instead of event ID - Use consistent deduplication key (author:d-tag) for replaceable events - Prevents duplicate blog posts when same article has multiple event IDs - Streaming updates now properly replace older versions with newer ones - Fixes issue where same blog post card appeared multiple times --- src/components/Explore.tsx | 51 ++++++++++++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index e43874f4..e3779c8d 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -121,8 +121,31 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti relayUrls, (post) => { setBlogPosts((prev) => { - const exists = prev.some(p => p.event.id === post.event.id) - if (exists) return prev + // Deduplicate by author:d-tag (replaceable event key) + const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || '' + const key = `${post.author}:${dTag}` + const existingIndex = prev.findIndex(p => { + const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || '' + return `${p.author}:${pDTag}` === key + }) + + // If exists, only replace if this one is newer + if (existingIndex >= 0) { + const existing = prev[existingIndex] + if (post.event.created_at <= existing.event.created_at) { + return prev // Keep existing (newer or same) + } + // Replace with newer version + const next = [...prev] + next[existingIndex] = 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 + }) + } + + // New post, add it const next = [...prev, post] return next.sort((a, b) => { const timeA = a.published || a.event.created_at @@ -134,9 +157,27 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti } ).then((all) => { setBlogPosts((prev) => { - const byId = new Map(prev.map(p => [p.event.id, p])) - for (const post of all) byId.set(post.event.id, post) - const merged = Array.from(byId.values()).sort((a, b) => { + // Deduplicate by author:d-tag (replaceable event key) + const byKey = new Map() + + // Add existing posts + for (const p of prev) { + const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || '' + const key = `${p.author}:${dTag}` + byKey.set(key, p) + } + + // Merge in new posts (keeping newer versions) + for (const post of all) { + const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || '' + const key = `${post.author}:${dTag}` + const existing = byKey.get(key) + if (!existing || post.event.created_at > existing.event.created_at) { + byKey.set(key, post) + } + } + + const merged = Array.from(byKey.values()).sort((a, b) => { const timeA = a.published || a.event.created_at const timeB = b.published || b.event.created_at return timeB - timeA From 340913f15fce59f89c1e7354bcfb94cf85b09c4c Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 18 Oct 2025 22:20:27 +0200 Subject: [PATCH 36/40] fix: force React to remount tab content when switching tabs - Add key prop based on activeTab to wrapper div - Forces complete unmount/remount of content when switching tabs - Prevents DOM element reuse that was causing blog posts to bleed into highlights tab --- src/components/Explore.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index e3779c8d..1c234f63 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -487,7 +487,9 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti
- {renderTabContent()} +
+ {renderTabContent()} +
) } From 1ba9595542925c9d8ac4308052b83aa10d6e1e14 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 18 Oct 2025 22:22:34 +0200 Subject: [PATCH 37/40] debug: add console logging for nostrverse highlights - Log highlight counts by source (mine, friends, nostrverse) - Log classified highlights by level - Log visibility filter state and results - Helps diagnose why nostrverse content isn't appearing --- src/components/Explore.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index 1c234f63..43d7b640 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -246,12 +246,14 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti }) // Merge and deduplicate all highlights (mine from controller + friends + nostrverse) + console.log('📊 Highlight counts - mine:', myHighlights.length, 'friends:', friendsHighlights.length, 'nostrverse:', nostriverseHighlights.length) const allHighlights = [...myHighlights, ...friendsHighlights, ...nostriverseHighlights] const highlightsByKey = new Map() for (const highlight of allHighlights) { highlightsByKey.set(highlight.id, highlight) } const uniqueHighlights = Array.from(highlightsByKey.values()).sort((a, b) => b.created_at - a.created_at) + console.log('📊 Total unique highlights after merge:', uniqueHighlights.length) // Fetch profiles for all blog post authors to cache them if (uniquePosts.length > 0) { @@ -306,12 +308,18 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti // Classify highlights with levels based on user context and apply visibility filters const classifiedHighlights = useMemo(() => { const classified = classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys) - return classified.filter(h => { + const levelCounts = { mine: 0, friends: 0, nostrverse: 0 } + classified.forEach(h => levelCounts[h.level]++) + console.log('📊 Classified highlights by level:', levelCounts, 'visibility:', visibility) + + const filtered = classified.filter(h => { if (h.level === 'mine' && !visibility.mine) return false if (h.level === 'friends' && !visibility.friends) return false if (h.level === 'nostrverse' && !visibility.nostrverse) return false return true }) + console.log('📊 After visibility filter:', filtered.length, 'highlights') + return filtered }, [highlights, activeAccount?.pubkey, followedPubkeys, visibility]) // Filter blog posts by future dates and visibility, and add level classification From 55defb645c8b64e7dea2bb7f60fcc09d2135fbc0 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 18 Oct 2025 22:25:02 +0200 Subject: [PATCH 38/40] debug: prefix all nostrverse logs with [NOSTRVERSE] - Makes it easy to filter console logs - Updated logs in nostrverseService.ts and Explore.tsx - All relevant logs now have consistent prefix --- src/components/Explore.tsx | 8 ++++---- src/services/nostrverseService.ts | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index 43d7b640..11a15c7c 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -246,14 +246,14 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti }) // Merge and deduplicate all highlights (mine from controller + friends + nostrverse) - console.log('📊 Highlight counts - mine:', myHighlights.length, 'friends:', friendsHighlights.length, 'nostrverse:', nostriverseHighlights.length) + console.log('[NOSTRVERSE] 📊 Highlight counts - mine:', myHighlights.length, 'friends:', friendsHighlights.length, 'nostrverse:', nostriverseHighlights.length) const allHighlights = [...myHighlights, ...friendsHighlights, ...nostriverseHighlights] const highlightsByKey = new Map() for (const highlight of allHighlights) { highlightsByKey.set(highlight.id, highlight) } const uniqueHighlights = Array.from(highlightsByKey.values()).sort((a, b) => b.created_at - a.created_at) - console.log('📊 Total unique highlights after merge:', uniqueHighlights.length) + console.log('[NOSTRVERSE] 📊 Total unique highlights after merge:', uniqueHighlights.length) // Fetch profiles for all blog post authors to cache them if (uniquePosts.length > 0) { @@ -310,7 +310,7 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti const classified = classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys) const levelCounts = { mine: 0, friends: 0, nostrverse: 0 } classified.forEach(h => levelCounts[h.level]++) - console.log('📊 Classified highlights by level:', levelCounts, 'visibility:', visibility) + console.log('[NOSTRVERSE] 📊 Classified highlights by level:', levelCounts, 'visibility:', visibility) const filtered = classified.filter(h => { if (h.level === 'mine' && !visibility.mine) return false @@ -318,7 +318,7 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti if (h.level === 'nostrverse' && !visibility.nostrverse) return false return true }) - console.log('📊 After visibility filter:', filtered.length, 'highlights') + console.log('[NOSTRVERSE] 📊 After visibility filter:', filtered.length, 'highlights') return filtered }, [highlights, activeAccount?.pubkey, followedPubkeys, visibility]) diff --git a/src/services/nostrverseService.ts b/src/services/nostrverseService.ts index 44933452..c1e98841 100644 --- a/src/services/nostrverseService.ts +++ b/src/services/nostrverseService.ts @@ -23,7 +23,7 @@ export const fetchNostrverseBlogPosts = async ( eventStore?: IEventStore ): Promise => { try { - console.log('📚 Fetching nostrverse blog posts (kind 30023), limit:', limit) + console.log('[NOSTRVERSE] 📚 Fetching blog posts (kind 30023), limit:', limit) // Deduplicate replaceable events by keeping the most recent version const uniqueEvents = new Map() @@ -49,7 +49,7 @@ export const fetchNostrverseBlogPosts = async ( } ) - console.log('📊 Nostrverse blog post events fetched (unique):', uniqueEvents.size) + console.log('[NOSTRVERSE] 📊 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()) @@ -67,7 +67,7 @@ export const fetchNostrverseBlogPosts = async ( return timeB - timeA // Most recent first }) - console.log('📰 Processed', blogPosts.length, 'unique nostrverse blog posts') + console.log('[NOSTRVERSE] 📰 Processed', blogPosts.length, 'unique blog posts') return blogPosts } catch (error) { @@ -89,7 +89,7 @@ export const fetchNostrverseHighlights = async ( eventStore?: IEventStore ): Promise => { try { - console.log('💡 Fetching nostrverse highlights (kind 9802), limit:', limit) + console.log('[NOSTRVERSE] 💡 Fetching highlights (kind 9802), limit:', limit) const seenIds = new Set() const rawEvents = await queryEvents( @@ -116,7 +116,7 @@ export const fetchNostrverseHighlights = async ( const uniqueEvents = dedupeHighlights(rawEvents) const highlights = uniqueEvents.map(eventToHighlight) - console.log('💡 Processed', highlights.length, 'unique nostrverse highlights') + console.log('[NOSTRVERSE] 💡 Processed', highlights.length, 'unique highlights') return sortHighlights(highlights) } catch (error) { From d54e1072b8c3ad7a1fddb0c03b39b3b8617113dd Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 18 Oct 2025 22:31:59 +0200 Subject: [PATCH 39/40] feat: load highlights from event store for instant display - Use eventStore.timeline() to query cached highlights - Seed Explore page with cached highlights immediately - Provides instant display of nostrverse highlights from store - Fresh data still fetched in background and merged - Follows applesauce pattern with useObservableMemo --- src/components/Explore.tsx | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index 11a15c7c..1cc1a8ea 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -4,10 +4,12 @@ import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArro import IconButton from './IconButton' import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons' import { Hooks } from 'applesauce-react' +import { useObservableMemo } from 'applesauce-react/hooks' import { RelayPool } from 'applesauce-relay' import { IEventStore } from 'applesauce-core' import { nip19 } from 'nostr-tools' import { useNavigate } from 'react-router-dom' +import { startWith } from 'rxjs' import { fetchContacts } from '../services/contactService' import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService' import { fetchHighlightsFromAuthors } from '../services/highlightService' @@ -23,6 +25,8 @@ import { usePullToRefresh } from 'use-pull-to-refresh' import RefreshIndicator from './RefreshIndicator' import { classifyHighlights } from '../utils/highlightClassification' import { HighlightVisibility } from './HighlightsPanel' +import { KINDS } from '../config/kinds' +import { eventToHighlight } from '../services/highlightEventProcessor' interface ExploreProps { relayPool: RelayPool @@ -47,6 +51,16 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti const [myHighlights, setMyHighlights] = useState([]) const [myHighlightsLoading, setMyHighlightsLoading] = useState(false) + // Load cached highlights from event store (instant display) + const cachedHighlightEvents = useObservableMemo( + () => eventStore.timeline({ kinds: [KINDS.Highlights] }).pipe(startWith([])), + [] + ) + const cachedHighlights = useMemo( + () => cachedHighlightEvents?.map(eventToHighlight) ?? [], + [cachedHighlightEvents] + ) + // Visibility filters (defaults from settings) const [visibility, setVisibility] = useState({ nostrverse: settings?.defaultExploreScopeNostrverse ?? false, @@ -88,9 +102,19 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti if (cachedPosts && cachedPosts.length > 0) { setBlogPosts(prev => prev.length === 0 ? cachedPosts : prev) } - const cachedHighlights = getCachedHighlights(activeAccount.pubkey) - if (cachedHighlights && cachedHighlights.length > 0) { - setHighlights(prev => prev.length === 0 ? cachedHighlights : prev) + const memoryCachedHighlights = getCachedHighlights(activeAccount.pubkey) + if (memoryCachedHighlights && memoryCachedHighlights.length > 0) { + setHighlights(prev => prev.length === 0 ? memoryCachedHighlights : prev) + } + + // Seed with cached highlights from event store (instant display) + console.log('[NOSTRVERSE] 💾 Seeding from event store:', cachedHighlights.length, 'highlights') + if (cachedHighlights.length > 0) { + setHighlights(prev => { + const byId = new Map(prev.map(h => [h.id, h])) + for (const h of cachedHighlights) byId.set(h.id, h) + return Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at) + }) } // Seed with myHighlights from controller (already loaded on app start) @@ -278,7 +302,7 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti } loadData() - }, [relayPool, activeAccount, refreshTrigger, eventStore, settings, myHighlights]) + }, [relayPool, activeAccount, refreshTrigger, eventStore, settings, myHighlights, cachedHighlights]) // Pull-to-refresh const { isRefreshing, pullPosition } = usePullToRefresh({ From ffb8031a05d8c90202aa661f0e4adf74678f36da Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 18 Oct 2025 23:03:48 +0200 Subject: [PATCH 40/40] feat: implement cached-first loading with EventStore across app - Add useStoreTimeline hook for reactive EventStore queries - Add dedupe helpers for highlights and writings - Explore: seed highlights and writings from store instantly - Article sidebar: seed article-specific highlights from store - External URLs: seed URL-specific highlights from store - Profile pages: seed other-profile highlights and writings from store - Remove debug logging - All data loads from cache first, then updates with fresh data - Follows DRY principles with single reusable hook --- src/components/Bookmarks.tsx | 4 +- src/components/Explore.tsx | 95 +++++++++++++------------------ src/components/Me.tsx | 55 +++++++++++++++++- src/hooks/useBookmarksData.ts | 35 ++++++++++-- src/hooks/useExternalUrlLoader.ts | 36 +++++++++--- src/hooks/useStoreTimeline.ts | 33 +++++++++++ src/utils/dedupe.ts | 35 ++++++++++++ 7 files changed, 222 insertions(+), 71 deletions(-) create mode 100644 src/hooks/useStoreTimeline.ts create mode 100644 src/utils/dedupe.ts diff --git a/src/components/Bookmarks.tsx b/src/components/Bookmarks.tsx index cfe9924a..47c3f751 100644 --- a/src/components/Bookmarks.tsx +++ b/src/components/Bookmarks.tsx @@ -327,10 +327,10 @@ const Bookmarks: React.FC = ({ relayPool ? : null ) : undefined} me={showMe ? ( - relayPool ? : null + relayPool ? : null ) : undefined} profile={showProfile && profilePubkey ? ( - relayPool ? : null + relayPool ? : null ) : undefined} support={showSupport ? ( relayPool ? : null diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index 1cc1a8ea..1b6069b6 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -1,15 +1,13 @@ -import React, { useState, useEffect, useMemo } from 'react' +import React, { useState, useEffect, useMemo, useCallback } from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate, faSpinner } from '@fortawesome/free-solid-svg-icons' import IconButton from './IconButton' import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons' import { Hooks } from 'applesauce-react' -import { useObservableMemo } from 'applesauce-react/hooks' import { RelayPool } from 'applesauce-relay' -import { IEventStore } from 'applesauce-core' -import { nip19 } from 'nostr-tools' +import { IEventStore, Helpers } from 'applesauce-core' +import { nip19, NostrEvent } from 'nostr-tools' import { useNavigate } from 'react-router-dom' -import { startWith } from 'rxjs' import { fetchContacts } from '../services/contactService' import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService' import { fetchHighlightsFromAuthors } from '../services/highlightService' @@ -27,6 +25,10 @@ import { classifyHighlights } from '../utils/highlightClassification' import { HighlightVisibility } from './HighlightsPanel' import { KINDS } from '../config/kinds' import { eventToHighlight } from '../services/highlightEventProcessor' +import { useStoreTimeline } from '../hooks/useStoreTimeline' +import { dedupeHighlightsById, dedupeWritingsByReplaceable } from '../utils/dedupe' + +const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers interface ExploreProps { relayPool: RelayPool @@ -51,15 +53,19 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti const [myHighlights, setMyHighlights] = useState([]) const [myHighlightsLoading, setMyHighlightsLoading] = useState(false) - // Load cached highlights from event store (instant display) - const cachedHighlightEvents = useObservableMemo( - () => eventStore.timeline({ kinds: [KINDS.Highlights] }).pipe(startWith([])), - [] - ) - const cachedHighlights = useMemo( - () => cachedHighlightEvents?.map(eventToHighlight) ?? [], - [cachedHighlightEvents] - ) + // Load cached content from event store (instant display) + const cachedHighlights = useStoreTimeline(eventStore, { kinds: [KINDS.Highlights] }, eventToHighlight, []) + + const toBlogPostPreview = useCallback((event: NostrEvent): BlogPostPreview => ({ + event, + title: getArticleTitle(event) || 'Untitled', + summary: getArticleSummary(event), + image: getArticleImage(event), + published: getArticlePublished(event), + author: event.pubkey + }), []) + + const cachedWritings = useStoreTimeline(eventStore, { kinds: [30023] }, toBlogPostPreview, []) // Visibility filters (defaults from settings) const [visibility, setVisibility] = useState({ @@ -97,32 +103,33 @@ 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) { - setBlogPosts(prev => prev.length === 0 ? cachedPosts : prev) + const memoryCachedPosts = getCachedPosts(activeAccount.pubkey) + if (memoryCachedPosts && memoryCachedPosts.length > 0) { + setBlogPosts(prev => prev.length === 0 ? memoryCachedPosts : prev) } const memoryCachedHighlights = getCachedHighlights(activeAccount.pubkey) if (memoryCachedHighlights && memoryCachedHighlights.length > 0) { setHighlights(prev => prev.length === 0 ? memoryCachedHighlights : prev) } - // Seed with cached highlights from event store (instant display) - console.log('[NOSTRVERSE] 💾 Seeding from event store:', cachedHighlights.length, 'highlights') - if (cachedHighlights.length > 0) { + // Seed with cached content from event store (instant display) + if (cachedHighlights.length > 0 || myHighlights.length > 0) { + const merged = dedupeHighlightsById([...cachedHighlights, ...myHighlights]) setHighlights(prev => { - const byId = new Map(prev.map(h => [h.id, h])) - for (const h of cachedHighlights) byId.set(h.id, h) - return Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at) + const all = dedupeHighlightsById([...prev, ...merged]) + return all.sort((a, b) => b.created_at - a.created_at) }) } - // Seed with myHighlights from controller (already loaded on app start) - if (myHighlights.length > 0) { - setHighlights(prev => { - const byId = new Map(prev.map(h => [h.id, h])) - for (const h of myHighlights) byId.set(h.id, h) - return Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at) + // Seed with cached writings from event store + if (cachedWritings.length > 0) { + setBlogPosts(prev => { + const all = dedupeWritingsByReplaceable([...prev, ...cachedWritings]) + return all.sort((a, b) => { + const timeA = a.published || a.event.created_at + const timeB = b.published || b.event.created_at + return timeB - timeA + }) }) } @@ -255,29 +262,15 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti // Merge and deduplicate all posts const allPosts = [...friendsPosts, ...nostrversePosts] - const postsByKey = new Map() - for (const post of allPosts) { - const key = `${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1] || ''}` - const existing = postsByKey.get(key) - if (!existing || post.event.created_at > existing.event.created_at) { - postsByKey.set(key, post) - } - } - const uniquePosts = Array.from(postsByKey.values()).sort((a, b) => { + const uniquePosts = dedupeWritingsByReplaceable(allPosts).sort((a, b) => { const timeA = a.published || a.event.created_at const timeB = b.published || b.event.created_at return timeB - timeA }) // Merge and deduplicate all highlights (mine from controller + friends + nostrverse) - console.log('[NOSTRVERSE] 📊 Highlight counts - mine:', myHighlights.length, 'friends:', friendsHighlights.length, 'nostrverse:', nostriverseHighlights.length) const allHighlights = [...myHighlights, ...friendsHighlights, ...nostriverseHighlights] - const highlightsByKey = new Map() - for (const highlight of allHighlights) { - highlightsByKey.set(highlight.id, highlight) - } - const uniqueHighlights = Array.from(highlightsByKey.values()).sort((a, b) => b.created_at - a.created_at) - console.log('[NOSTRVERSE] 📊 Total unique highlights after merge:', uniqueHighlights.length) + const uniqueHighlights = dedupeHighlightsById(allHighlights).sort((a, b) => b.created_at - a.created_at) // Fetch profiles for all blog post authors to cache them if (uniquePosts.length > 0) { @@ -302,7 +295,7 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti } loadData() - }, [relayPool, activeAccount, refreshTrigger, eventStore, settings, myHighlights, cachedHighlights]) + }, [relayPool, activeAccount, refreshTrigger, eventStore, settings, myHighlights, cachedHighlights, cachedWritings]) // Pull-to-refresh const { isRefreshing, pullPosition } = usePullToRefresh({ @@ -332,18 +325,12 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti // Classify highlights with levels based on user context and apply visibility filters const classifiedHighlights = useMemo(() => { const classified = classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys) - const levelCounts = { mine: 0, friends: 0, nostrverse: 0 } - classified.forEach(h => levelCounts[h.level]++) - console.log('[NOSTRVERSE] 📊 Classified highlights by level:', levelCounts, 'visibility:', visibility) - - const filtered = classified.filter(h => { + return classified.filter(h => { if (h.level === 'mine' && !visibility.mine) return false if (h.level === 'friends' && !visibility.friends) return false if (h.level === 'nostrverse' && !visibility.nostrverse) return false return true }) - console.log('[NOSTRVERSE] 📊 After visibility filter:', filtered.length, 'highlights') - return filtered }, [highlights, activeAccount?.pubkey, followedPubkeys, visibility]) // Filter blog posts by future dates and visibility, and add level classification diff --git a/src/components/Me.tsx b/src/components/Me.tsx index 380140dc..70ff766b 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -1,10 +1,11 @@ -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useMemo } from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare, faLink, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons' import { Hooks } from 'applesauce-react' +import { IEventStore, Helpers } from 'applesauce-core' import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons' import { RelayPool } from 'applesauce-relay' -import { nip19 } from 'nostr-tools' +import { nip19, NostrEvent } from 'nostr-tools' import { useNavigate, useParams } from 'react-router-dom' import { Highlight } from '../types/highlights' import { HighlightItem } from './HighlightItem' @@ -32,9 +33,15 @@ import { filterByReadingProgress } from '../utils/readingProgressUtils' import { deriveReadsFromBookmarks } from '../utils/readsFromBookmarks' import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks' import { mergeReadItem } from '../utils/readItemMerge' +import { useStoreTimeline } from '../hooks/useStoreTimeline' +import { eventToHighlight } from '../services/highlightEventProcessor' +import { KINDS } from '../config/kinds' + +const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers interface MeProps { relayPool: RelayPool + eventStore: IEventStore activeTab?: TabType pubkey?: string // Optional pubkey for viewing other users' profiles bookmarks: Bookmark[] // From centralized App.tsx state @@ -48,6 +55,7 @@ const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started' const Me: React.FC = ({ relayPool, + eventStore, activeTab: propActiveTab, pubkey: propPubkey, bookmarks @@ -72,6 +80,30 @@ const Me: React.FC = ({ // Get myHighlights directly from controller const [myHighlights, setMyHighlights] = useState([]) const [myHighlightsLoading, setMyHighlightsLoading] = useState(false) + + // Load cached data from event store for OTHER profiles (not own) + const cachedHighlights = useStoreTimeline( + eventStore, + !isOwnProfile && viewingPubkey ? { kinds: [KINDS.Highlights], authors: [viewingPubkey] } : { kinds: [KINDS.Highlights], limit: 0 }, + eventToHighlight, + [viewingPubkey, isOwnProfile] + ) + + const toBlogPostPreview = useMemo(() => (event: NostrEvent): BlogPostPreview => ({ + event, + title: getArticleTitle(event) || 'Untitled', + summary: getArticleSummary(event), + image: getArticleImage(event), + published: getArticlePublished(event), + author: event.pubkey + }), []) + + const cachedWritings = useStoreTimeline( + eventStore, + !isOwnProfile && viewingPubkey ? { kinds: [30023], authors: [viewingPubkey] } : { kinds: [30023], limit: 0 }, + toBlogPostPreview, + [viewingPubkey, isOwnProfile] + ) const [viewMode, setViewMode] = useState('cards') const [refreshTrigger, setRefreshTrigger] = useState(0) const [bookmarkFilter, setBookmarkFilter] = useState('all') @@ -144,8 +176,14 @@ const Me: React.FC = ({ if (!hasBeenLoaded) setLoading(true) // For own profile, highlights come from controller subscription (sync effect handles it) - // For viewing other users, fetch on-demand + // For viewing other users, seed with cached data then fetch fresh if (!isOwnProfile) { + // Seed with cached highlights first + if (cachedHighlights.length > 0) { + setHighlights(cachedHighlights.sort((a, b) => b.created_at - a.created_at)) + } + + // Fetch fresh highlights const userHighlights = await fetchHighlights(relayPool, viewingPubkey) setHighlights(userHighlights) } @@ -165,6 +203,17 @@ const Me: React.FC = ({ try { if (!hasBeenLoaded) setLoading(true) + + // Seed with cached writings first + if (!isOwnProfile && cachedWritings.length > 0) { + setWritings(cachedWritings.sort((a, b) => { + const timeA = a.published || a.event.created_at + const timeB = b.published || b.event.created_at + return timeB - timeA + })) + } + + // Fetch fresh writings const userWritings = await fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS) setWritings(userWritings) setLoadedTabs(prev => new Set(prev).add('writings')) diff --git a/src/hooks/useBookmarksData.ts b/src/hooks/useBookmarksData.ts index c34f98e7..048c5b1c 100644 --- a/src/hooks/useBookmarksData.ts +++ b/src/hooks/useBookmarksData.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useMemo } from 'react' import { RelayPool } from 'applesauce-relay' import { IAccount } from 'applesauce-accounts' import { IEventStore } from 'applesauce-core' @@ -8,6 +8,9 @@ import { fetchHighlightsForArticle } from '../services/highlightService' import { UserSettings } from '../services/settingsService' import { highlightsController } from '../services/highlightsController' import { contactsController } from '../services/contactsController' +import { useStoreTimeline } from './useStoreTimeline' +import { eventToHighlight } from '../services/highlightEventProcessor' +import { KINDS } from '../config/kinds' interface UseBookmarksDataParams { relayPool: RelayPool | null @@ -41,6 +44,23 @@ export const useBookmarksData = ({ const [isRefreshing, setIsRefreshing] = useState(false) const [lastFetchTime, setLastFetchTime] = useState(null) + // Load cached article-specific highlights from event store + const articleFilter = useMemo(() => { + if (!currentArticleCoordinate) return null + return { + kinds: [KINDS.Highlights], + '#a': [currentArticleCoordinate], + ...(currentArticleEventId ? { '#e': [currentArticleEventId] } : {}) + } + }, [currentArticleCoordinate, currentArticleEventId]) + + const cachedArticleHighlights = useStoreTimeline( + eventStore || null, + articleFilter || { kinds: [KINDS.Highlights], limit: 0 }, // empty filter if no article + eventToHighlight, + [currentArticleCoordinate, currentArticleEventId] + ) + // Subscribe to centralized controllers useEffect(() => { // Get initial state immediately @@ -65,8 +85,16 @@ export const useBookmarksData = ({ setHighlightsLoading(true) try { if (currentArticleCoordinate) { - // Fetch article-specific highlights (from all users) + // Seed with cached highlights first + if (cachedArticleHighlights.length > 0) { + setArticleHighlights(cachedArticleHighlights.sort((a, b) => b.created_at - a.created_at)) + } + + // Fetch fresh article-specific highlights (from all users) const highlightsMap = new Map() + // Seed map with cached highlights + cachedArticleHighlights.forEach(h => highlightsMap.set(h.id, h)) + await fetchHighlightsForArticle( relayPool, currentArticleCoordinate, @@ -83,7 +111,6 @@ export const useBookmarksData = ({ false, // force eventStore || undefined ) - console.log(`🔄 Refreshed ${highlightsMap.size} highlights for article`) } else { // No article selected - clear article highlights setArticleHighlights([]) @@ -93,7 +120,7 @@ export const useBookmarksData = ({ } finally { setHighlightsLoading(false) } - }, [relayPool, currentArticleCoordinate, currentArticleEventId, settings, eventStore]) + }, [relayPool, currentArticleCoordinate, currentArticleEventId, settings, eventStore, cachedArticleHighlights]) const handleRefreshAll = useCallback(async () => { if (!relayPool || !activeAccount || isRefreshing) return diff --git a/src/hooks/useExternalUrlLoader.ts b/src/hooks/useExternalUrlLoader.ts index 6bbdabe4..06aadda5 100644 --- a/src/hooks/useExternalUrlLoader.ts +++ b/src/hooks/useExternalUrlLoader.ts @@ -1,9 +1,12 @@ -import { useEffect } from 'react' +import { useEffect, useMemo } from 'react' import { RelayPool } from 'applesauce-relay' import { IEventStore } from 'applesauce-core' import { fetchReadableContent, ReadableContent } from '../services/readerService' import { fetchHighlightsForUrl } from '../services/highlightService' import { Highlight } from '../types/highlights' +import { useStoreTimeline } from './useStoreTimeline' +import { eventToHighlight } from '../services/highlightEventProcessor' +import { KINDS } from '../config/kinds' // Helper to extract filename from URL function getFilenameFromUrl(url: string): string { @@ -45,6 +48,19 @@ export function useExternalUrlLoader({ setCurrentArticleCoordinate, setCurrentArticleEventId }: UseExternalUrlLoaderProps) { + // Load cached URL-specific highlights from event store + const urlFilter = useMemo(() => { + if (!url) return null + return { kinds: [KINDS.Highlights], '#r': [url] } + }, [url]) + + const cachedUrlHighlights = useStoreTimeline( + eventStore || null, + urlFilter || { kinds: [KINDS.Highlights], limit: 0 }, + eventToHighlight, + [url] + ) + useEffect(() => { if (!relayPool || !url) return @@ -69,11 +85,20 @@ export function useExternalUrlLoader({ // Fetch highlights for this URL asynchronously try { setHighlightsLoading(true) - setHighlights([]) + + // Seed with cached highlights first + if (cachedUrlHighlights.length > 0) { + setHighlights(cachedUrlHighlights.sort((a, b) => b.created_at - a.created_at)) + } else { + setHighlights([]) + } // Check if fetchHighlightsForUrl exists, otherwise skip if (typeof fetchHighlightsForUrl === 'function') { const seen = new Set() + // Seed with cached IDs + cachedUrlHighlights.forEach(h => seen.add(h.id)) + await fetchHighlightsForUrl( relayPool, url, @@ -90,11 +115,6 @@ export function useExternalUrlLoader({ false, // force eventStore || undefined ) - // Highlights are already set via the streaming callback - // No need to set them again as that could cause a flash/disappearance - console.log(`📌 Finished fetching highlights for URL`) - } else { - console.log('📌 Highlight fetching for URLs not yet implemented') } } catch (err) { console.error('Failed to fetch highlights:', err) @@ -115,6 +135,6 @@ export function useExternalUrlLoader({ } loadExternalUrl() - }, [url, relayPool, eventStore, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId]) + }, [url, relayPool, eventStore, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId, cachedUrlHighlights]) } diff --git a/src/hooks/useStoreTimeline.ts b/src/hooks/useStoreTimeline.ts new file mode 100644 index 00000000..9cf065e2 --- /dev/null +++ b/src/hooks/useStoreTimeline.ts @@ -0,0 +1,33 @@ +import { useMemo } from 'react' +import { useObservableMemo } from 'applesauce-react/hooks' +import { startWith } from 'rxjs' +import type { IEventStore } from 'applesauce-core' +import type { Filter, NostrEvent } from 'nostr-tools' + +/** + * Subscribe to EventStore timeline and map events to app types + * Provides instant cached results, then updates reactively + * + * @param eventStore - The applesauce event store + * @param filter - Nostr filter to query + * @param mapEvent - Function to transform NostrEvent to app type + * @param deps - Dependencies for memoization + * @returns Array of mapped results + */ +export function useStoreTimeline( + eventStore: IEventStore | null, + filter: Filter, + mapEvent: (event: NostrEvent) => T, + deps: unknown[] = [] +): T[] { + const events = useObservableMemo( + () => eventStore ? eventStore.timeline(filter).pipe(startWith([])) : undefined, + [eventStore, ...deps] + ) + + return useMemo( + () => events?.map(mapEvent) ?? [], + [events, mapEvent] + ) +} + diff --git a/src/utils/dedupe.ts b/src/utils/dedupe.ts new file mode 100644 index 00000000..737fc6d3 --- /dev/null +++ b/src/utils/dedupe.ts @@ -0,0 +1,35 @@ +import { Highlight } from '../types/highlights' +import { BlogPostPreview } from '../services/exploreService' + +/** + * Deduplicate highlights by ID + */ +export function dedupeHighlightsById(highlights: Highlight[]): Highlight[] { + const byId = new Map() + for (const highlight of highlights) { + byId.set(highlight.id, highlight) + } + return Array.from(byId.values()) +} + +/** + * Deduplicate blog posts by replaceable event key (author:d-tag) + * Keeps the newest version when duplicates exist + */ +export function dedupeWritingsByReplaceable(posts: BlogPostPreview[]): BlogPostPreview[] { + const byKey = new Map() + + for (const post of posts) { + const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || '' + const key = `${post.author}:${dTag}` + const existing = byKey.get(key) + + // Keep the newer version + if (!existing || post.event.created_at > existing.event.created_at) { + byKey.set(key, post) + } + } + + return Array.from(byKey.values()) +} +