From ffb8031a05d8c90202aa661f0e4adf74678f36da Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 18 Oct 2025 23:03:48 +0200 Subject: [PATCH] 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()) +} +