From 4f21982c484c5c5bfe2282359a5ff977d3b4ceb4 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 01:08:42 +0200 Subject: [PATCH 01/42] feat(me): show bookmarks in cards view on /me/bookmarks tab --- src/components/Me.tsx | 33 +++++---------------------------- 1 file changed, 5 insertions(+), 28 deletions(-) diff --git a/src/components/Me.tsx b/src/components/Me.tsx index c1af4ab6..3445b4ad 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -1,6 +1,6 @@ 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 { faHighlighter, faBookmark, 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' @@ -21,7 +21,6 @@ import AuthorCard from './AuthorCard' import BlogPostCard from './BlogPostCard' import { BookmarkItem } from './BookmarkItem' import IconButton from './IconButton' -import { ViewMode } from './Bookmarks' import { getCachedMeData, updateCachedHighlights } from '../services/meCache' import { faBooks } from '../icons/customIcons' import { usePullToRefresh } from 'use-pull-to-refresh' @@ -109,7 +108,6 @@ const Me: React.FC = ({ toBlogPostPreview, [viewingPubkey, isOwnProfile] ) - const [viewMode, setViewMode] = useState('cards') const [refreshTrigger, setRefreshTrigger] = useState(0) const [bookmarkFilter, setBookmarkFilter] = useState('all') const [groupingMode, setGroupingMode] = useState<'grouped' | 'flat'>(() => { @@ -567,9 +565,9 @@ const Me: React.FC = ({ if (showSkeletons) { return (
-
+
{Array.from({ length: 6 }).map((_, i) => ( - + ))}
@@ -595,13 +593,13 @@ const Me: React.FC = ({ sections.filter(s => s.items.length > 0).map(section => (

{section.title}

-
+
{section.items.map((individualBookmark, index) => ( ))} @@ -623,27 +621,6 @@ const Me: React.FC = ({ ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'} variant="ghost" /> - setViewMode('compact')} - title="Compact list view" - ariaLabel="Compact list view" - variant={viewMode === 'compact' ? 'primary' : 'ghost'} - /> - setViewMode('cards')} - title="Cards view" - ariaLabel="Cards view" - variant={viewMode === 'cards' ? 'primary' : 'ghost'} - /> - setViewMode('large')} - title="Large preview view" - ariaLabel="Large preview view" - variant={viewMode === 'large' ? 'primary' : 'ghost'} - />
) From 016e369fb127a44157b7418cc056aa1eb58443e5 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 01:09:39 +0200 Subject: [PATCH 02/42] feat(highlights): only show nostrverse filter when logged out --- .../HighlightsPanel/HighlightsPanelHeader.tsx | 62 ++++++++++--------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/src/components/HighlightsPanel/HighlightsPanelHeader.tsx b/src/components/HighlightsPanel/HighlightsPanelHeader.tsx index f98b3040..84e1586d 100644 --- a/src/components/HighlightsPanel/HighlightsPanelHeader.tsx +++ b/src/components/HighlightsPanel/HighlightsPanelHeader.tsx @@ -46,36 +46,38 @@ const HighlightsPanelHeader: React.FC = ({ opacity: highlightVisibility.nostrverse ? 1 : 0.4 }} /> - onHighlightVisibilityChange({ - ...highlightVisibility, - friends: !highlightVisibility.friends - })} - title={currentUserPubkey ? "Toggle friends highlights" : "Login to see friends highlights"} - ariaLabel="Toggle friends highlights" - variant="ghost" - disabled={!currentUserPubkey} - style={{ - color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined, - opacity: highlightVisibility.friends ? 1 : 0.4 - }} - /> - onHighlightVisibilityChange({ - ...highlightVisibility, - mine: !highlightVisibility.mine - })} - title={currentUserPubkey ? "Toggle my highlights" : "Login to see your highlights"} - ariaLabel="Toggle my highlights" - variant="ghost" - disabled={!currentUserPubkey} - style={{ - color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined, - opacity: highlightVisibility.mine ? 1 : 0.4 - }} - /> + {currentUserPubkey && ( + <> + onHighlightVisibilityChange({ + ...highlightVisibility, + friends: !highlightVisibility.friends + })} + title="Toggle friends highlights" + ariaLabel="Toggle friends highlights" + variant="ghost" + style={{ + color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined, + opacity: highlightVisibility.friends ? 1 : 0.4 + }} + /> + onHighlightVisibilityChange({ + ...highlightVisibility, + mine: !highlightVisibility.mine + })} + title="Toggle my highlights" + ariaLabel="Toggle my highlights" + variant="ghost" + style={{ + color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined, + opacity: highlightVisibility.mine ? 1 : 0.4 + }} + /> + + )}
)} {onRefresh && ( From a862eb880e218290e8a9d4063c95b36d94d84708 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 01:15:01 +0200 Subject: [PATCH 03/42] feat(profile): preload all highlights and writings into event store --- src/components/Me.tsx | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/components/Me.tsx b/src/components/Me.tsx index 3445b4ad..19ac038b 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -411,6 +411,34 @@ const Me: React.FC = ({ } }, [isOwnProfile, myWritings]) + // Preload all highlights and writings for profile pages (non-blocking) + useEffect(() => { + if (!isOwnProfile && viewingPubkey && relayPool && eventStore) { + // Fire and forget - non-blocking background fetch + console.log('🔄 [Profile] Preloading highlights and writings for', viewingPubkey.slice(0, 8)) + + // Fetch highlights in background + fetchHighlights(relayPool, viewingPubkey, undefined, undefined, false, eventStore) + .then(highlights => { + console.log('✅ [Profile] Preloaded', highlights.length, 'highlights into event store') + }) + .catch(err => { + console.warn('⚠️ [Profile] Failed to preload highlights:', err) + }) + + // Fetch writings in background + fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS) + .then(writings => { + // Store writings in event store + writings.forEach(w => eventStore.add(w.event)) + console.log('✅ [Profile] Preloaded', writings.length, 'writings into event store') + }) + .catch(err => { + console.warn('⚠️ [Profile] Failed to preload writings:', err) + }) + } + }, [isOwnProfile, viewingPubkey, relayPool, eventStore]) + // Pull-to-refresh - reload active tab without clearing state const { isRefreshing, pullPosition } = usePullToRefresh({ onRefresh: () => { From 3007ae83c2a1c332b31b7a21dcd3590bf3291e7f Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 01:17:35 +0200 Subject: [PATCH 04/42] fix(profile): display cached highlights and writings instantly, fetch fresh in background --- src/components/Me.tsx | 73 ++++++++++++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 26 deletions(-) diff --git a/src/components/Me.tsx b/src/components/Me.tsx index 19ac038b..81fbd607 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -186,30 +186,41 @@ const Me: React.FC = ({ const loadHighlightsTab = async () => { if (!viewingPubkey) return - // Only show loading skeleton if tab hasn't been loaded yet + // Only show loading skeleton if tab hasn't been loaded yet AND no cached data const hasBeenLoaded = loadedTabs.has('highlights') + const hasCachedData = cachedHighlights.length > 0 try { - if (!hasBeenLoaded) setLoading(true) - // For own profile, highlights come from controller subscription (sync effect handles it) - // 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) + if (isOwnProfile) { + setLoadedTabs(prev => new Set(prev).add('highlights')) + setLoading(false) + return } - setLoadedTabs(prev => new Set(prev).add('highlights')) + // For viewing other users, seed with cached data immediately (non-blocking) + if (hasCachedData) { + setHighlights(cachedHighlights.sort((a, b) => b.created_at - a.created_at)) + setLoadedTabs(prev => new Set(prev).add('highlights')) + setLoading(false) + } else if (!hasBeenLoaded) { + setLoading(true) + } + + // Fetch fresh highlights in background and merge + fetchHighlights(relayPool, viewingPubkey) + .then(userHighlights => { + setHighlights(userHighlights) + setLoadedTabs(prev => new Set(prev).add('highlights')) + setLoading(false) + }) + .catch(err => { + console.error('Failed to load highlights:', err) + setLoading(false) + }) } catch (err) { console.error('Failed to load highlights:', err) - } finally { - if (!hasBeenLoaded) setLoading(false) + setLoading(false) } } @@ -217,10 +228,9 @@ const Me: React.FC = ({ if (!viewingPubkey) return const hasBeenLoaded = loadedTabs.has('writings') + const hasCachedData = cachedWritings.length > 0 try { - if (!hasBeenLoaded) setLoading(true) - // For own profile, use centralized controller if (isOwnProfile) { await writingsController.start({ @@ -230,26 +240,37 @@ const Me: React.FC = ({ force: refreshTrigger > 0 }) setLoadedTabs(prev => new Set(prev).add('writings')) + setLoading(false) return } - // For other profiles, seed with cached writings first - if (cachedWritings.length > 0) { + // For other profiles, seed with cached writings immediately (non-blocking) + if (hasCachedData) { setWritings(cachedWritings.sort((a, b) => { const timeA = a.published || a.event.created_at const timeB = b.published || b.event.created_at return timeB - timeA })) + setLoadedTabs(prev => new Set(prev).add('writings')) + setLoading(false) + } else if (!hasBeenLoaded) { + setLoading(true) } - // Fetch fresh writings for other profiles - const userWritings = await fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS) - setWritings(userWritings) - setLoadedTabs(prev => new Set(prev).add('writings')) + // Fetch fresh writings in background and merge + fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS) + .then(userWritings => { + setWritings(userWritings) + setLoadedTabs(prev => new Set(prev).add('writings')) + setLoading(false) + }) + .catch(err => { + console.error('Failed to load writings:', err) + setLoading(false) + }) } catch (err) { console.error('Failed to load writings:', err) - } finally { - if (!hasBeenLoaded) setLoading(false) + setLoading(false) } } From 73e2e060e396c791d419b12e14d5ea6f122c9977 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 01:19:10 +0200 Subject: [PATCH 05/42] chore: bump version to 0.7.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4c98e61c..da5416ad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "boris", - "version": "0.7.3", + "version": "0.7.4", "description": "A minimal nostr client for bookmark management", "homepage": "https://read.withboris.com/", "type": "module", From 4b0f275f578027467873fcbd34765d020e51b74b Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 01:21:38 +0200 Subject: [PATCH 06/42] docs: update CHANGELOG.md for v0.7.4 release --- CHANGELOG.md | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d902d7d..1647fabf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.7.4] - 2025-10-18 + +### Added + +- Profile page data preloading for instant tab switching + - Automatically preloads all highlights and writings when viewing a profile (`/p/` pages) + - Non-blocking background fetch stores all events in event store + - Tab switching becomes instant after initial preload + +### Changed + +- `/me/bookmarks` tab now displays in cards view only + - Removed view mode toggle buttons (compact, large) from bookmarks tab + - Cards view provides optimal bookmark browsing experience + - Grouping toggle (grouped/flat) still available +- Highlights sidebar filters simplified when logged out + - Only nostrverse filter button shown when not logged in + - Friends and personal highlight filters hidden when logged out + - Cleaner UX showing only available options + +### Fixed + +- Profile page tabs now display cached content instantly + - Highlights and writings show immediately from event store cache + - Network fetches happen in background without blocking UI + - Matches Explore and Debug page non-blocking loading pattern + - Eliminated loading delays when switching between tabs + +### Performance + +- Cache-first profile loading strategy + - Instant display of cached highlights and writings from event store + - Background refresh updates data without blocking + - Tab switches show content immediately without loading states + ## [0.7.3] - 2025-10-18 ### Added @@ -1978,7 +2013,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.7.3...HEAD +[Unreleased]: https://github.com/dergigi/boris/compare/v0.7.4...HEAD +[0.7.4]: https://github.com/dergigi/boris/compare/v0.7.3...v0.7.4 [0.7.3]: https://github.com/dergigi/boris/compare/v0.7.2...v0.7.3 [0.7.2]: https://github.com/dergigi/boris/compare/v0.7.0...v0.7.2 [0.7.0]: https://github.com/dergigi/boris/compare/v0.6.24...v0.7.0 From aa6aeb27234a911674e5232f8265e11bac8baaae Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 01:28:22 +0200 Subject: [PATCH 07/42] refactor: split Me into Me and Profile components for simpler /p/ pages - Create Profile.tsx for viewing other users (highlights + writings only) - Profile uses useStoreTimeline for instant cache-first display - Background fetches populate event store non-blocking - Extract toBlogPostPreview helper for reuse - Simplify Me.tsx to only handle own profile (/me routes) - Remove isOwnProfile branching and cached data logic from Me - Update Bookmarks.tsx to render Profile for /p/ routes - Keep code DRY and files under 210 lines --- src/components/Bookmarks.tsx | 3 +- src/components/Me.tsx | 265 ++++++++------------------------- src/components/Profile.tsx | 212 ++++++++++++++++++++++++++ src/utils/toBlogPostPreview.ts | 15 ++ 4 files changed, 294 insertions(+), 201 deletions(-) create mode 100644 src/components/Profile.tsx create mode 100644 src/utils/toBlogPostPreview.ts diff --git a/src/components/Bookmarks.tsx b/src/components/Bookmarks.tsx index 47c3f751..c222f957 100644 --- a/src/components/Bookmarks.tsx +++ b/src/components/Bookmarks.tsx @@ -17,6 +17,7 @@ import { Bookmark } from '../types/bookmarks' import ThreePaneLayout from './ThreePaneLayout' import Explore from './Explore' import Me from './Me' +import Profile from './Profile' import Support from './Support' import { classifyHighlights } from '../utils/highlightClassification' @@ -330,7 +331,7 @@ const Bookmarks: React.FC = ({ relayPool ? : null ) : undefined} profile={showProfile && profilePubkey ? ( - relayPool ? : null + relayPool ? : null ) : undefined} support={showSupport ? ( relayPool ? : null diff --git a/src/components/Me.tsx b/src/components/Me.tsx index 81fbd607..297eac44 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -1,21 +1,19 @@ -import React, { useState, useEffect, useMemo } from 'react' +import React, { useState, useEffect } from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faHighlighter, faBookmark, faPenToSquare, faLink, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons' import { Hooks } from 'applesauce-react' -import { IEventStore, Helpers } from 'applesauce-core' +import { IEventStore } from 'applesauce-core' import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons' import { RelayPool } from 'applesauce-relay' -import { nip19, NostrEvent } from 'nostr-tools' +import { nip19 } from 'nostr-tools' import { useNavigate, useParams } from 'react-router-dom' import { Highlight } from '../types/highlights' import { HighlightItem } from './HighlightItem' -import { fetchHighlights } from '../services/highlightService' import { highlightsController } from '../services/highlightsController' import { writingsController } from '../services/writingsController' import { fetchAllReads, ReadItem } from '../services/readsService' import { fetchLinks } from '../services/linksService' -import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService' -import { RELAYS } from '../config/relays' +import { BlogPostPreview } from '../services/exploreService' import { Bookmark, IndividualBookmark } from '../types/bookmarks' import AuthorCard from './AuthorCard' import BlogPostCard from './BlogPostCard' @@ -33,17 +31,11 @@ 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 bookmarksLoading?: boolean // From centralized App.tsx state (reserved for future use) } @@ -56,8 +48,7 @@ const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started' const Me: React.FC = ({ relayPool, eventStore, - activeTab: propActiveTab, - pubkey: propPubkey, + activeTab: propActiveTab, bookmarks }) => { const activeAccount = Hooks.useActiveAccount() @@ -65,9 +56,8 @@ const Me: React.FC = ({ const { filter: urlFilter } = useParams<{ filter?: string }>() const [activeTab, setActiveTab] = useState(propActiveTab || 'highlights') - // Use provided pubkey or fall back to active account - const viewingPubkey = propPubkey || activeAccount?.pubkey - const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey) + // Only for own profile + const viewingPubkey = activeAccount?.pubkey const [highlights, setHighlights] = useState([]) const [reads, setReads] = useState([]) const [, setReadsMap] = useState>(new Map()) @@ -85,29 +75,6 @@ const Me: React.FC = ({ const [myWritings, setMyWritings] = useState([]) const [myWritingsLoading, setMyWritingsLoading] = 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 [refreshTrigger, setRefreshTrigger] = useState(0) const [bookmarkFilter, setBookmarkFilter] = useState('all') const [groupingMode, setGroupingMode] = useState<'grouped' | 'flat'>(() => { @@ -186,88 +153,24 @@ const Me: React.FC = ({ const loadHighlightsTab = async () => { if (!viewingPubkey) return - // Only show loading skeleton if tab hasn't been loaded yet AND no cached data - const hasBeenLoaded = loadedTabs.has('highlights') - const hasCachedData = cachedHighlights.length > 0 - - try { - // For own profile, highlights come from controller subscription (sync effect handles it) - if (isOwnProfile) { - setLoadedTabs(prev => new Set(prev).add('highlights')) - setLoading(false) - return - } - - // For viewing other users, seed with cached data immediately (non-blocking) - if (hasCachedData) { - setHighlights(cachedHighlights.sort((a, b) => b.created_at - a.created_at)) - setLoadedTabs(prev => new Set(prev).add('highlights')) - setLoading(false) - } else if (!hasBeenLoaded) { - setLoading(true) - } - - // Fetch fresh highlights in background and merge - fetchHighlights(relayPool, viewingPubkey) - .then(userHighlights => { - setHighlights(userHighlights) - setLoadedTabs(prev => new Set(prev).add('highlights')) - setLoading(false) - }) - .catch(err => { - console.error('Failed to load highlights:', err) - setLoading(false) - }) - } catch (err) { - console.error('Failed to load highlights:', err) - setLoading(false) - } + // Highlights come from controller subscription (sync effect handles it) + setLoadedTabs(prev => new Set(prev).add('highlights')) + setLoading(false) } const loadWritingsTab = async () => { if (!viewingPubkey) return - const hasBeenLoaded = loadedTabs.has('writings') - const hasCachedData = cachedWritings.length > 0 - try { - // For own profile, use centralized controller - if (isOwnProfile) { - await writingsController.start({ - relayPool, - eventStore, - pubkey: viewingPubkey, - force: refreshTrigger > 0 - }) - setLoadedTabs(prev => new Set(prev).add('writings')) - setLoading(false) - return - } - - // For other profiles, seed with cached writings immediately (non-blocking) - if (hasCachedData) { - setWritings(cachedWritings.sort((a, b) => { - const timeA = a.published || a.event.created_at - const timeB = b.published || b.event.created_at - return timeB - timeA - })) - setLoadedTabs(prev => new Set(prev).add('writings')) - setLoading(false) - } else if (!hasBeenLoaded) { - setLoading(true) - } - - // Fetch fresh writings in background and merge - fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS) - .then(userWritings => { - setWritings(userWritings) - setLoadedTabs(prev => new Set(prev).add('writings')) - setLoading(false) - }) - .catch(err => { - console.error('Failed to load writings:', err) - setLoading(false) - }) + // Use centralized controller + await writingsController.start({ + relayPool, + eventStore, + pubkey: viewingPubkey, + force: refreshTrigger > 0 + }) + setLoadedTabs(prev => new Set(prev).add('writings')) + setLoading(false) } catch (err) { console.error('Failed to load writings:', err) setLoading(false) @@ -275,7 +178,7 @@ const Me: React.FC = ({ } const loadReadingListTab = async () => { - if (!viewingPubkey || !isOwnProfile || !activeAccount) return + if (!viewingPubkey || !activeAccount) return const hasBeenLoaded = loadedTabs.has('reading-list') @@ -291,7 +194,7 @@ const Me: React.FC = ({ } const loadReadsTab = async () => { - if (!viewingPubkey || !isOwnProfile || !activeAccount) return + if (!viewingPubkey || !activeAccount) return const hasBeenLoaded = loadedTabs.has('reads') @@ -341,7 +244,7 @@ const Me: React.FC = ({ } const loadLinksTab = async () => { - if (!viewingPubkey || !isOwnProfile || !activeAccount) return + if (!viewingPubkey || !activeAccount) return const hasBeenLoaded = loadedTabs.has('links') @@ -387,14 +290,12 @@ const Me: React.FC = ({ } // Load cached data immediately if available - if (isOwnProfile) { - const cached = getCachedMeData(viewingPubkey) - if (cached) { - setHighlights(cached.highlights) - // Bookmarks come from App.tsx centralized state, no local caching needed - setReads(cached.reads || []) - setLinks(cached.links || []) - } + const cached = getCachedMeData(viewingPubkey) + if (cached) { + setHighlights(cached.highlights) + // Bookmarks come from App.tsx centralized state, no local caching needed + setReads(cached.reads || []) + setLinks(cached.links || []) } // Load data for active tab (refresh in background if already loaded) @@ -418,47 +319,15 @@ const Me: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeTab, viewingPubkey, refreshTrigger]) - // Sync myHighlights from controller when viewing own profile + // Sync myHighlights from controller useEffect(() => { - if (isOwnProfile) { - setHighlights(myHighlights) - } - }, [isOwnProfile, myHighlights]) + setHighlights(myHighlights) + }, [myHighlights]) - // Sync myWritings from controller when viewing own profile + // Sync myWritings from controller useEffect(() => { - if (isOwnProfile) { - setWritings(myWritings) - } - }, [isOwnProfile, myWritings]) - - // Preload all highlights and writings for profile pages (non-blocking) - useEffect(() => { - if (!isOwnProfile && viewingPubkey && relayPool && eventStore) { - // Fire and forget - non-blocking background fetch - console.log('🔄 [Profile] Preloading highlights and writings for', viewingPubkey.slice(0, 8)) - - // Fetch highlights in background - fetchHighlights(relayPool, viewingPubkey, undefined, undefined, false, eventStore) - .then(highlights => { - console.log('✅ [Profile] Preloaded', highlights.length, 'highlights into event store') - }) - .catch(err => { - console.warn('⚠️ [Profile] Failed to preload highlights:', err) - }) - - // Fetch writings in background - fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS) - .then(writings => { - // Store writings in event store - writings.forEach(w => eventStore.add(w.event)) - console.log('✅ [Profile] Preloaded', writings.length, 'writings into event store') - }) - .catch(err => { - console.warn('⚠️ [Profile] Failed to preload writings:', err) - }) - } - }, [isOwnProfile, viewingPubkey, relayPool, eventStore]) + setWritings(myWritings) + }, [myWritings]) // Pull-to-refresh - reload active tab without clearing state const { isRefreshing, pullPosition } = usePullToRefresh({ @@ -474,8 +343,8 @@ const Me: React.FC = ({ const handleHighlightDelete = (highlightId: string) => { setHighlights(prev => { const updated = prev.filter(h => h.id !== highlightId) - // Update cache when highlight is deleted (own profile only) - if (isOwnProfile && viewingPubkey) { + // Update cache when highlight is deleted + if (viewingPubkey) { updateCachedHighlights(viewingPubkey, updated) } return updated @@ -579,7 +448,7 @@ const Me: React.FC = ({ // Show content progressively - no blocking error screens const hasData = highlights.length > 0 || bookmarks.length > 0 || reads.length > 0 || links.length > 0 || writings.length > 0 - const showSkeletons = (loading || (isOwnProfile && myHighlightsLoading)) && !hasData + const showSkeletons = (loading || myHighlightsLoading) && !hasData const renderTabContent = () => { switch (activeTab) { @@ -593,7 +462,7 @@ const Me: React.FC = ({
) } - return highlights.length === 0 && !loading && !(isOwnProfile && myHighlightsLoading) ? ( + return highlights.length === 0 && !loading && !myHighlightsLoading ? (
No highlights yet.
@@ -778,7 +647,7 @@ const Me: React.FC = ({ ) } - return writings.length === 0 && !loading && !(isOwnProfile && myWritingsLoading) ? ( + return writings.length === 0 && !loading && !myWritingsLoading ? (
No articles written yet.
@@ -812,43 +681,39 @@ const Me: React.FC = ({ - {isOwnProfile && ( - <> - - - - - )} + + + + + + + +
+ {renderTabContent()} +
+ + ) +} + +export default Profile + diff --git a/src/utils/toBlogPostPreview.ts b/src/utils/toBlogPostPreview.ts new file mode 100644 index 00000000..c8ddfda7 --- /dev/null +++ b/src/utils/toBlogPostPreview.ts @@ -0,0 +1,15 @@ +import { NostrEvent } from 'nostr-tools' +import { Helpers } from 'applesauce-core' +import { BlogPostPreview } from '../services/exploreService' + +const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers + +export const toBlogPostPreview = (event: NostrEvent): BlogPostPreview => ({ + event, + title: getArticleTitle(event) || 'Untitled', + summary: getArticleSummary(event), + image: getArticleImage(event), + published: getArticlePublished(event), + author: event.pubkey +}) + From 17fdd928279df5d902295c5968bed40f6ded63b8 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 01:35:00 +0200 Subject: [PATCH 08/42] fix(profile): fetch all writings for profile pages by removing limit - Make limit parameter configurable in fetchBlogPostsFromAuthors - Default limit is 100 for Explore page (multiple authors) - Pass null limit for Profile pages to fetch all writings - Fixes issue where only 1 writing was shown instead of all --- src/components/Profile.tsx | 4 ++-- src/services/exploreService.ts | 13 ++++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/components/Profile.tsx b/src/components/Profile.tsx index 71d2d5ec..2d366740 100644 --- a/src/components/Profile.tsx +++ b/src/components/Profile.tsx @@ -73,8 +73,8 @@ const Profile: React.FC = ({ console.warn('⚠️ [Profile] Failed to fetch highlights:', err) }) - // Fetch writings in background - fetchBlogPostsFromAuthors(relayPool, [pubkey], RELAYS) + // Fetch writings in background (no limit for single user profile) + fetchBlogPostsFromAuthors(relayPool, [pubkey], RELAYS, undefined, null) .then(writings => { writings.forEach(w => eventStore.add(w.event)) console.log('✅ [Profile] Fetched', writings.length, 'writings') diff --git a/src/services/exploreService.ts b/src/services/exploreService.ts index ff486c7a..0ce762a5 100644 --- a/src/services/exploreService.ts +++ b/src/services/exploreService.ts @@ -20,13 +20,16 @@ export interface BlogPostPreview { * @param relayPool - The relay pool to query * @param pubkeys - Array of pubkeys to fetch posts from * @param relayUrls - Array of relay URLs to query + * @param onPost - Optional callback for streaming posts + * @param limit - Limit for number of events to fetch (default: 100, pass null for no limit) * @returns Array of blog post previews */ export const fetchBlogPostsFromAuthors = async ( relayPool: RelayPool, pubkeys: string[], relayUrls: string[], - onPost?: (post: BlogPostPreview) => void + onPost?: (post: BlogPostPreview) => void, + limit: number | null = 100 ): Promise => { try { if (pubkeys.length === 0) { @@ -34,15 +37,19 @@ export const fetchBlogPostsFromAuthors = async ( return [] } - console.log('📚 Fetching blog posts (kind 30023) from', pubkeys.length, 'authors') + console.log('📚 Fetching blog posts (kind 30023) from', pubkeys.length, 'authors', limit ? `(limit: ${limit})` : '(no limit)') // Deduplicate replaceable events by keeping the most recent version // Group by author + d-tag identifier const uniqueEvents = new Map() + const filter = limit !== null + ? { kinds: [KINDS.BlogPost], authors: pubkeys, limit } + : { kinds: [KINDS.BlogPost], authors: pubkeys } + await queryEvents( relayPool, - { kinds: [KINDS.BlogPost], authors: pubkeys, limit: 100 }, + filter, { relayUrls, onEvent: (event: NostrEvent) => { From 32b128607914841a012ec37d5a5158476dcf3568 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 01:43:25 +0200 Subject: [PATCH 09/42] chore: remove [bookmark] debug logs - Remove all console.log statements with [bookmark] prefix from App.tsx - Remove all console.log statements with [bookmark] prefix from bookmarkController.ts - Replace verbose error logging with simple error messages - Keep code clean and reduce console clutter --- src/App.tsx | 7 ---- src/services/bookmarkController.ts | 62 +++++++----------------------- 2 files changed, 14 insertions(+), 55 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 57083fde..c55ecf74 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -54,18 +54,14 @@ function AppRoutes({ // Subscribe to bookmark controller useEffect(() => { - console.log('[bookmark] 🎧 Subscribing to bookmark controller') const unsubBookmarks = bookmarkController.onBookmarks((bookmarks) => { - console.log('[bookmark] 📥 Received bookmarks:', bookmarks.length) setBookmarks(bookmarks) }) const unsubLoading = bookmarkController.onLoading((loading) => { - console.log('[bookmark] 📥 Loading state:', loading) setBookmarksLoading(loading) }) return () => { - console.log('[bookmark] 🔇 Unsubscribing from bookmark controller') unsubBookmarks() unsubLoading() } @@ -98,7 +94,6 @@ function AppRoutes({ // Load bookmarks if (bookmarks.length === 0 && !bookmarksLoading) { - console.log('[bookmark] 🚀 Auto-loading bookmarks on mount/login') bookmarkController.start({ relayPool, activeAccount, accountManager }) } @@ -139,10 +134,8 @@ function AppRoutes({ // Manual refresh (for sidebar button) const handleRefreshBookmarks = useCallback(async () => { if (!relayPool || !activeAccount) { - console.warn('[bookmark] Cannot refresh: missing relayPool or activeAccount') return } - console.log('[bookmark] 🔄 Manual refresh triggered') bookmarkController.reset() await bookmarkController.start({ relayPool, activeAccount, accountManager }) }, [relayPool, activeAccount, accountManager]) diff --git a/src/services/bookmarkController.ts b/src/services/bookmarkController.ts index 4cdb33fb..3a5c16c5 100644 --- a/src/services/bookmarkController.ts +++ b/src/services/bookmarkController.ts @@ -126,18 +126,14 @@ class BookmarkController { generation: number ): void { if (!this.eventLoader) { - console.warn('[bookmark] ⚠️ EventLoader not initialized') return } // Filter to unique IDs not already hydrated const unique = Array.from(new Set(ids)).filter(id => !idToEvent.has(id)) if (unique.length === 0) { - console.log('[bookmark] 🔧 All IDs already hydrated, skipping') return } - - console.log('[bookmark] 🔧 Hydrating', unique.length, 'IDs using EventLoader') // Convert IDs to EventPointers const pointers: EventPointer[] = unique.map(id => ({ id })) @@ -159,8 +155,8 @@ class BookmarkController { onProgress() }, - error: (error) => { - console.error('[bookmark] ❌ EventLoader error:', error) + error: () => { + // Silent error - EventLoader handles retries } }) } @@ -175,14 +171,11 @@ class BookmarkController { generation: number ): void { if (!this.addressLoader) { - console.warn('[bookmark] ⚠️ AddressLoader not initialized') return } if (coords.length === 0) return - console.log('[bookmark] 🔧 Hydrating', coords.length, 'coordinates using AddressLoader') - // Convert coordinates to AddressPointers const pointers = coords.map(c => ({ kind: c.kind, @@ -203,8 +196,8 @@ class BookmarkController { onProgress() }, - error: (error) => { - console.error('[bookmark] ❌ AddressLoader error:', error) + error: () => { + // Silent error - AddressLoader handles retries } }) } @@ -223,10 +216,6 @@ class BookmarkController { return this.decryptedResults.has(getEventKey(evt)) }) - const unencryptedCount = allEvents.filter(evt => !hasEncryptedContent(evt)).length - const decryptedCount = readyEvents.length - unencryptedCount - console.log('[bookmark] 📋 Building bookmarks:', unencryptedCount, 'unencrypted,', decryptedCount, 'decrypted, of', allEvents.length, 'total') - if (readyEvents.length === 0) { this.bookmarksListeners.forEach(cb => cb([])) return @@ -237,17 +226,14 @@ class BookmarkController { const unencryptedEvents = readyEvents.filter(evt => !hasEncryptedContent(evt)) const decryptedEvents = readyEvents.filter(evt => hasEncryptedContent(evt)) - console.log('[bookmark] 🔧 Processing', unencryptedEvents.length, 'unencrypted events') // Process unencrypted events const { publicItemsAll: publicUnencrypted, privateItemsAll: privateUnencrypted, newestCreatedAt, latestContent, allTags } = await collectBookmarksFromEvents(unencryptedEvents, activeAccount, signerCandidate) - console.log('[bookmark] 🔧 Unencrypted returned:', publicUnencrypted.length, 'public,', privateUnencrypted.length, 'private') // Merge in decrypted results let publicItemsAll = [...publicUnencrypted] let privateItemsAll = [...privateUnencrypted] - console.log('[bookmark] 🔧 Merging', decryptedEvents.length, 'decrypted events') decryptedEvents.forEach(evt => { const eventKey = getEventKey(evt) const decrypted = this.decryptedResults.get(eventKey) @@ -256,11 +242,8 @@ class BookmarkController { privateItemsAll = [...privateItemsAll, ...decrypted.privateItems] } }) - - console.log('[bookmark] 🔧 Total after merge:', publicItemsAll.length, 'public,', privateItemsAll.length, 'private') const allItems = [...publicItemsAll, ...privateItemsAll] - console.log('[bookmark] 🔧 Total items to process:', allItems.length) // Separate hex IDs from coordinates const noteIds: string[] = [] @@ -276,14 +259,11 @@ class BookmarkController { // Helper to build and emit bookmarks const emitBookmarks = (idToEvent: Map) => { - console.log('[bookmark] 🔧 Building final bookmarks list...') const allBookmarks = dedupeBookmarksById([ ...hydrateItems(publicItemsAll, idToEvent), ...hydrateItems(privateItemsAll, idToEvent) ]) - console.log('[bookmark] 🔧 After hydration and dedup:', allBookmarks.length, 'bookmarks') - console.log('[bookmark] 🔧 Enriching and sorting...') const enriched = allBookmarks.map(b => ({ ...b, tags: b.tags || [], @@ -293,9 +273,7 @@ class BookmarkController { const sortedBookmarks = enriched .map(b => ({ ...b, urlReferences: extractUrlsFromContent(b.content) })) .sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0))) - console.log('[bookmark] 🔧 Sorted:', sortedBookmarks.length, 'bookmarks') - console.log('[bookmark] 🔧 Creating final Bookmark object...') const bookmark: Bookmark = { id: `${activeAccount.pubkey}-bookmarks`, title: `Bookmarks (${sortedBookmarks.length})`, @@ -310,18 +288,14 @@ class BookmarkController { encryptedContent: undefined } - console.log('[bookmark] 📋 Built bookmark with', sortedBookmarks.length, 'items') - console.log('[bookmark] 📤 Emitting to', this.bookmarksListeners.length, 'listeners') this.bookmarksListeners.forEach(cb => cb([bookmark])) } // Emit immediately with empty metadata (show placeholders) const idToEvent: Map = new Map() - console.log('[bookmark] 🚀 Emitting initial bookmarks with placeholders (IDs only)...') emitBookmarks(idToEvent) // Now fetch events progressively in background using batched hydrators - console.log('[bookmark] 🔧 Background hydration:', noteIds.length, 'note IDs and', coordinates.length, 'coordinates') const generation = this.hydrationGeneration const onProgress = () => emitBookmarks(idToEvent) @@ -341,9 +315,7 @@ class BookmarkController { this.hydrateByIds(noteIds, idToEvent, onProgress, generation) this.hydrateByCoordinates(coordObjs, idToEvent, onProgress, generation) } catch (error) { - console.error('[bookmark] ❌ Failed to build bookmarks:', error) - console.error('[bookmark] ❌ Error details:', error instanceof Error ? error.message : String(error)) - console.error('[bookmark] ❌ Stack:', error instanceof Error ? error.stack : 'no stack') + console.error('Failed to build bookmarks:', error) this.bookmarksListeners.forEach(cb => cb([])) } } @@ -356,7 +328,6 @@ class BookmarkController { const { relayPool, activeAccount, accountManager } = options if (!activeAccount || typeof (activeAccount as { pubkey?: string }).pubkey !== 'string') { - console.error('[bookmark] Invalid activeAccount') return } @@ -366,7 +337,6 @@ class BookmarkController { this.hydrationGeneration++ // Initialize loaders for this session - console.log('[bookmark] 🔧 Initializing EventLoader and AddressLoader with', RELAYS.length, 'relays') this.eventLoader = createEventLoader(relayPool, { eventStore: this.eventStore, extraRelays: RELAYS @@ -377,7 +347,6 @@ class BookmarkController { }) this.setLoading(true) - console.log('[bookmark] 🔍 Starting bookmark load for', account.pubkey.slice(0, 8)) try { // Get signer for auto-decryption @@ -405,7 +374,6 @@ class BookmarkController { // Add/update event this.currentEvents.set(key, evt) - console.log('[bookmark] 📨 Event:', evt.kind, evt.id.slice(0, 8), 'encrypted:', hasEncryptedContent(evt)) // Emit raw event for Debug UI this.emitRawEvent(evt) @@ -415,12 +383,13 @@ class BookmarkController { if (!isEncrypted) { // For unencrypted events, build bookmarks immediately (progressive update) this.buildAndEmitBookmarks(maybeAccount, signerCandidate) - .catch(err => console.error('[bookmark] ❌ Failed to update after event:', err)) + .catch(() => { + // Silent error - will retry on next event + }) } // Auto-decrypt if event has encrypted content (fire-and-forget, non-blocking) if (isEncrypted) { - console.log('[bookmark] 🔓 Auto-decrypting event', evt.id.slice(0, 8)) // Don't await - let it run in background collectBookmarksFromEvents([evt], account, signerCandidate) .then(({ publicItemsAll, privateItemsAll, newestCreatedAt, latestContent, allTags }) => { @@ -433,10 +402,6 @@ class BookmarkController { latestContent, allTags }) - console.log('[bookmark] ✅ Auto-decrypted:', evt.id.slice(0, 8), { - public: publicItemsAll.length, - private: privateItemsAll.length - }) // Emit decrypt complete for Debug UI this.decryptCompleteListeners.forEach(cb => @@ -445,10 +410,12 @@ class BookmarkController { // Rebuild bookmarks with newly decrypted content (progressive update) this.buildAndEmitBookmarks(maybeAccount, signerCandidate) - .catch(err => console.error('[bookmark] ❌ Failed to update after decrypt:', err)) + .catch(() => { + // Silent error - will retry on next event + }) }) - .catch((error) => { - console.error('[bookmark] ❌ Auto-decrypt failed:', evt.id.slice(0, 8), error) + .catch(() => { + // Silent error - decrypt failed }) } } @@ -457,9 +424,8 @@ class BookmarkController { // Final update after EOSE await this.buildAndEmitBookmarks(maybeAccount, signerCandidate) - console.log('[bookmark] ✅ Bookmark load complete') } catch (error) { - console.error('[bookmark] ❌ Failed to load bookmarks:', error) + console.error('Failed to load bookmarks:', error) this.bookmarksListeners.forEach(cb => cb([])) } finally { this.setLoading(false) From 7d373015b41ad473e81baaf0d1ebea737de98c39 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 10:09:09 +0200 Subject: [PATCH 10/42] feat: implement NIP-39802 reading progress with dual-write migration - Add kind 39802 (ReadingProgress) as dedicated parameterized replaceable event - Create NIP-39802 specification document in public/md/ - Implement dual-write: publish both kind 39802 and legacy kind 30078 - Implement dual-read: prefer kind 39802, fall back to kind 30078 - Add migration flags to settings (useReadingProgressKind, writeLegacyReadingPosition) - Update readingPositionService with new d-tag generation and tag helpers - Add processReadingProgress() for kind 39802 events in readingDataProcessor - Update readsService and linksService to query and process both kinds - Use event.created_at as authoritative timestamp per NIP-39802 spec - ContentPanel respects migration flags from settings - Maintain backward compatibility during migration phase --- public/md/NIP-39802.md | 179 ++++++++++++++++ src/components/ContentPanel.tsx | 11 +- src/config/kinds.ts | 3 +- src/services/linksService.ts | 26 ++- src/services/readingDataProcessor.ts | 88 +++++++- src/services/readingPositionService.ts | 279 ++++++++++++++++++++----- src/services/readsService.ts | 21 +- src/services/settingsService.ts | 3 + 8 files changed, 542 insertions(+), 68 deletions(-) create mode 100644 public/md/NIP-39802.md diff --git a/public/md/NIP-39802.md b/public/md/NIP-39802.md new file mode 100644 index 00000000..98efe1fe --- /dev/null +++ b/public/md/NIP-39802.md @@ -0,0 +1,179 @@ +# NIP-39802 + +## Reading Progress + +`draft` `optional` + +This NIP defines a parameterized replaceable event kind for tracking reading progress across articles and web content. + +## Event Kind + +- `39802`: Reading Progress (Parameterized Replaceable) + +## Event Structure + +Reading progress events use NIP-33 parameterized replaceable semantics. The `d` tag serves as the unique identifier per author and target content. + +### Tags + +- `d` (required): Unique identifier for the target content + - For Nostr articles: `30023::` (matching the article's coordinate) + - For external URLs: `url:` +- `a` (optional but recommended for Nostr articles): Article coordinate `30023::` +- `r` (optional but recommended for URLs): Raw URL of the external content +- `client` (optional): Client application identifier + +### Content + +The content is a JSON object with the following fields: + +- `progress` (required): Number between 0 and 1 representing reading progress (0 = not started, 1 = completed) +- `loc` (optional): Number representing a location marker (e.g., pixel scroll position, page number, etc.) +- `ts` (optional): Unix timestamp (seconds) when the progress was recorded. This is for display purposes only; event ordering MUST use `created_at` +- `ver` (optional): Schema version string (e.g., "1") + +### Semantics + +- The latest event by `created_at` per (`pubkey`, `d`) pair is authoritative (NIP-33 semantics) +- Clients SHOULD implement rate limiting to avoid excessive relay traffic: + - Debounce writes (recommended: 5 seconds) + - Only save when progress changes significantly (recommended: ≥1% delta) + - Skip saving very early progress (recommended: <5%) + - Always save on completion (progress = 1) and when unmounting/closing content +- The `created_at` timestamp SHOULD match the time the progress was observed +- Event ordering and replaceability MUST use `created_at`, not the optional `ts` field in content + +## Examples + +### Nostr Article Progress + +```json +{ + "kind": 39802, + "pubkey": "", + "created_at": 1734635012, + "content": "{\"progress\":0.66,\"loc\":1432,\"ts\":1734635012,\"ver\":\"1\"}", + "tags": [ + ["d", "30023::"], + ["a", "30023::"], + ["client", "boris"] + ], + "id": "", + "sig": "" +} +``` + +### External URL Progress + +```json +{ + "kind": 39802, + "pubkey": "", + "created_at": 1734635999, + "content": "{\"progress\":1,\"ts\":1734635999,\"ver\":\"1\"}", + "tags": [ + ["d", "url:aHR0cHM6Ly9leGFtcGxlLmNvbS9wb3N0"], + ["r", "https://example.com/post"], + ["client", "boris"] + ], + "id": "", + "sig": "" +} +``` + +## Querying + +### All progress for a user + +```json +{ + "kinds": [39802], + "authors": [""] +} +``` + +### Progress for a specific Nostr article + +```json +{ + "kinds": [39802], + "authors": [""], + "#d": ["30023::"] +} +``` + +Or using the `a` tag: + +```json +{ + "kinds": [39802], + "authors": [""], + "#a": ["30023::"] +} +``` + +### Progress for a specific URL + +```json +{ + "kinds": [39802], + "authors": [""], + "#r": ["https://example.com/post"] +} +``` + +## Privacy Considerations + +Reading progress events are public by default to enable interoperability between clients. Users concerned about privacy should: + +- Use clients that allow disabling progress sync +- Use clients that allow selective relay publishing +- Be aware that reading progress reveals their reading habits + +A future extension could define an encrypted variant for private progress tracking, but that is out of scope for this NIP. + +## Rationale + +### Why a dedicated kind instead of NIP-78 application data? + +While NIP-78 (kind 30078) can store arbitrary application data, a dedicated kind offers several advantages: + +1. **Discoverability**: Other clients can easily find and display reading progress without knowing application-specific `d` tag conventions +2. **Interoperability**: Standard schema enables cross-client compatibility +3. **Indexing**: Relays can efficiently index and query reading progress separately from other app data +4. **Semantics**: Clear, well-defined meaning for the event kind + +### Why parameterized replaceable (NIP-33)? + +- Each article/URL needs exactly one current progress value per user +- Automatic deduplication by relays reduces storage and bandwidth +- Simple last-write-wins semantics based on `created_at` +- Efficient querying by `d` tag + +### Why include both `d` and `a`/`r` tags? + +- `d` provides the unique key for replaceability +- `a` and `r` enable efficient filtering without parsing `d` values +- Redundancy improves relay compatibility and query flexibility + +## Implementation Notes + +- Clients SHOULD use the event's `created_at` as the authoritative timestamp for sorting and merging progress +- The optional `ts` field in content is for display purposes only (e.g., "Last read 2 hours ago") +- For URLs, the base64url encoding in the `d` tag MUST use URL-safe characters (replace `+` with `-`, `/` with `_`, remove padding `=`) +- Clients SHOULD validate that `progress` is between 0 and 1 + +## Migration from NIP-78 + +Clients currently using NIP-78 (kind 30078) for reading progress can migrate by: + +1. **Dual-write phase**: Publish both kind 39802 and legacy kind 30078 events +2. **Dual-read phase**: Read from kind 39802 first, fall back to kind 30078 +3. **Cleanup phase**: After a grace period, stop writing kind 30078 but continue reading for backward compatibility + +## References + +- [NIP-01: Basic protocol flow](https://github.com/nostr-protocol/nips/blob/master/01.md) +- [NIP-33: Parameterized Replaceable Events](https://github.com/nostr-protocol/nips/blob/master/33.md) +- [NIP-78: Application Data](https://github.com/nostr-protocol/nips/blob/master/78.md) + diff --git a/src/components/ContentPanel.tsx b/src/components/ContentPanel.tsx index 28e08ad2..19013861 100644 --- a/src/components/ContentPanel.tsx +++ b/src/components/ContentPanel.tsx @@ -177,12 +177,16 @@ const ContentPanel: React.FC = ({ position, timestamp: Math.floor(Date.now() / 1000), scrollTop: window.pageYOffset || document.documentElement.scrollTop + }, + { + useProgressKind: settings?.useReadingProgressKind !== false, + writeLegacy: settings?.writeLegacyReadingPosition !== false } ) } catch (error) { console.error('❌ [ContentPanel] Failed to save reading position:', error) } - }, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl]) + }, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, settings?.useReadingProgressKind, settings?.writeLegacyReadingPosition, selectedUrl]) const { isReadingComplete, progressPercentage, saveNow } = useReadingPosition({ enabled: isTextContent, @@ -221,7 +225,10 @@ const ContentPanel: React.FC = ({ relayPool, eventStore, activeAccount.pubkey, - articleIdentifier + articleIdentifier, + { + useProgressKind: settings?.useReadingProgressKind !== false + } ) if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) { diff --git a/src/config/kinds.ts b/src/config/kinds.ts index 4221a07d..7f567f8d 100644 --- a/src/config/kinds.ts +++ b/src/config/kinds.ts @@ -2,7 +2,8 @@ export const KINDS = { Highlights: 9802, // NIP-?? user highlights BlogPost: 30023, // NIP-23 long-form article - AppData: 30078, // NIP-78 application data (reading positions) + AppData: 30078, // NIP-78 application data (legacy reading positions) + ReadingProgress: 39802, // NIP-39802 reading progress List: 30001, // NIP-51 list (addressable) ListReplaceable: 30003, // NIP-51 replaceable list ListSimple: 10003, // NIP-51 simple list diff --git a/src/services/linksService.ts b/src/services/linksService.ts index 401cec12..d1a940f8 100644 --- a/src/services/linksService.ts +++ b/src/services/linksService.ts @@ -4,12 +4,12 @@ import { queryEvents } from './dataFetch' import { RELAYS } from '../config/relays' import { KINDS } from '../config/kinds' import { ReadItem } from './readsService' -import { processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor' +import { processReadingProgress, processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor' import { mergeReadItem } from '../utils/readItemMerge' /** * Fetches external URL links with reading progress from: - * - URLs with reading progress (kind:30078) + * - URLs with reading progress (kind:39802 and legacy kind:30078) * - Manually marked as read URLs (kind:7, kind:17) */ export async function fetchLinks( @@ -32,18 +32,32 @@ export async function fetchLinks( try { // Fetch all data sources in parallel - const [readingPositionEvents, markedAsReadArticles] = await Promise.all([ + // Query both new kind 39802 and legacy kind 30078 + const [progressEvents, legacyPositionEvents, markedAsReadArticles] = await Promise.all([ + queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [userPubkey] }, { relayUrls: RELAYS }), queryEvents(relayPool, { kinds: [KINDS.AppData], authors: [userPubkey] }, { relayUrls: RELAYS }), fetchReadArticles(relayPool, userPubkey) ]) console.log('📊 [Links] Data fetched:', { - readingPositions: readingPositionEvents.length, + readingProgress: progressEvents.length, + legacyPositions: legacyPositionEvents.length, markedAsRead: markedAsReadArticles.length }) - // Process reading positions and emit external items - processReadingPositions(readingPositionEvents, linksMap) + // Process new reading progress events (kind 39802) first + processReadingProgress(progressEvents, linksMap) + if (onItem) { + linksMap.forEach(item => { + if (item.type === 'external') { + const hasProgress = (item.readingProgress && item.readingProgress > 0) || item.markedAsRead + if (hasProgress) emitItem(item) + } + }) + } + + // Process legacy reading positions (kind 30078) - won't override newer 39802 data + processReadingPositions(legacyPositionEvents, linksMap) if (onItem) { linksMap.forEach(item => { if (item.type === 'external') { diff --git a/src/services/readingDataProcessor.ts b/src/services/readingDataProcessor.ts index a61c54ef..b6797361 100644 --- a/src/services/readingDataProcessor.ts +++ b/src/services/readingDataProcessor.ts @@ -1,8 +1,10 @@ -import { NostrEvent } from 'nostr-tools' +import { NostrEvent, nip19 } from 'nostr-tools' import { ReadItem } from './readsService' import { fallbackTitleFromUrl } from '../utils/readItemMerge' +import { KINDS } from '../config/kinds' const READING_POSITION_PREFIX = 'boris:reading-position:' +const READING_PROGRESS_KIND = KINDS.ReadingProgress // 39802 interface ReadArticle { id: string @@ -13,7 +15,86 @@ interface ReadArticle { } /** - * Processes reading position events into ReadItems + * Processes reading progress events (kind 39802) into ReadItems + */ +export function processReadingProgress( + events: NostrEvent[], + readsMap: Map +): void { + for (const event of events) { + if (event.kind !== READING_PROGRESS_KIND) continue + + const dTag = event.tags.find(t => t[0] === 'd')?.[1] + if (!dTag) continue + + try { + const content = JSON.parse(event.content) + const position = content.progress || content.position || 0 + // Use event.created_at as authoritative timestamp (NIP-39802 spec) + const timestamp = event.created_at + + let itemId: string + let itemUrl: string | undefined + let itemType: 'article' | 'external' = 'external' + + // Check if d tag is a coordinate (30023:pubkey:identifier) + if (dTag.startsWith('30023:')) { + // It's a nostr article coordinate + const parts = dTag.split(':') + if (parts.length === 3) { + // Convert to naddr for consistency with the rest of the app + try { + const naddr = nip19.naddrEncode({ + kind: parseInt(parts[0]), + pubkey: parts[1], + identifier: parts[2] + }) + itemId = naddr + itemType = 'article' + } catch (e) { + console.warn('Failed to encode naddr from coordinate:', dTag) + continue + } + } else { + continue + } + } else if (dTag.startsWith('url:')) { + // It's a URL with base64url encoding + const encoded = dTag.replace('url:', '') + try { + itemUrl = atob(encoded.replace(/-/g, '+').replace(/_/g, '/')) + itemId = itemUrl + itemType = 'external' + } catch (e) { + console.warn('Failed to decode URL from d tag:', dTag) + continue + } + } else { + // Unknown format, skip + continue + } + + // Add or update the item, preferring newer timestamps + const existing = readsMap.get(itemId) + if (!existing || !existing.readingTimestamp || timestamp > existing.readingTimestamp) { + readsMap.set(itemId, { + ...existing, + id: itemId, + source: 'reading-progress', + type: itemType, + url: itemUrl, + readingProgress: position, + readingTimestamp: timestamp + }) + } + } catch (error) { + console.warn('Failed to parse reading progress event:', error) + } + } +} + +/** + * Processes legacy reading position events (kind 30078) into ReadItems */ export function processReadingPositions( events: NostrEvent[], @@ -28,7 +109,8 @@ export function processReadingPositions( try { const positionData = JSON.parse(event.content) const position = positionData.position - const timestamp = positionData.timestamp + // For legacy events, use content timestamp if available, otherwise created_at + const timestamp = positionData.timestamp || event.created_at let itemId: string let itemUrl: string | undefined diff --git a/src/services/readingPositionService.ts b/src/services/readingPositionService.ts index 1d645c13..4a57c6be 100644 --- a/src/services/readingPositionService.ts +++ b/src/services/readingPositionService.ts @@ -1,13 +1,15 @@ import { IEventStore, mapEventsToStore } from 'applesauce-core' import { EventFactory } from 'applesauce-factory' import { RelayPool, onlyEvents } from 'applesauce-relay' -import { NostrEvent } from 'nostr-tools' +import { NostrEvent, nip19 } from 'nostr-tools' import { firstValueFrom } from 'rxjs' import { publishEvent } from './writeService' import { RELAYS } from '../config/relays' +import { KINDS } from '../config/kinds' -const APP_DATA_KIND = 30078 // NIP-78 Application Data -const READING_POSITION_PREFIX = 'boris:reading-position:' +const APP_DATA_KIND = KINDS.AppData // 30078 - Legacy NIP-78 Application Data +const READING_PROGRESS_KIND = KINDS.ReadingProgress // 39802 - NIP-39802 Reading Progress +const READING_POSITION_PREFIX = 'boris:reading-position:' // Legacy prefix export interface ReadingPosition { position: number // 0-1 scroll progress @@ -15,7 +17,14 @@ export interface ReadingPosition { scrollTop?: number // Optional: pixel position } -// Helper to extract and parse reading position from an event +export interface ReadingProgressContent { + progress: number // 0-1 scroll progress + ts?: number // Unix timestamp (optional, for display) + loc?: number // Optional: pixel position + ver?: string // Schema version +} + +// Helper to extract and parse reading position from legacy event (kind 30078) function getReadingPositionContent(event: NostrEvent): ReadingPosition | undefined { if (!event.content || event.content.length === 0) return undefined try { @@ -25,6 +34,67 @@ function getReadingPositionContent(event: NostrEvent): ReadingPosition | undefin } } +// Helper to extract and parse reading progress from new event (kind 39802) +function getReadingProgressContent(event: NostrEvent): ReadingPosition | undefined { + if (!event.content || event.content.length === 0) return undefined + try { + const content = JSON.parse(event.content) as ReadingProgressContent + return { + position: content.progress, + timestamp: content.ts || event.created_at, + scrollTop: content.loc + } + } catch { + return undefined + } +} + +// Generate d tag for kind 39802 based on target +function generateDTag(naddrOrUrl: string): string { + // If it's a nostr article (naddr format), decode and build coordinate + if (naddrOrUrl.startsWith('naddr1')) { + try { + const decoded = nip19.decode(naddrOrUrl) + if (decoded.type === 'naddr') { + return `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier || ''}` + } + } catch (e) { + console.warn('Failed to decode naddr:', naddrOrUrl) + } + } + + // For URLs, use url: prefix with base64url encoding + const base64url = btoa(naddrOrUrl) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + return `url:${base64url}` +} + +// Generate tags for kind 39802 event +function generateProgressTags(naddrOrUrl: string): string[][] { + const dTag = generateDTag(naddrOrUrl) + const tags: string[][] = [['d', dTag], ['client', 'boris']] + + // Add 'a' tag for nostr articles + if (naddrOrUrl.startsWith('naddr1')) { + try { + const decoded = nip19.decode(naddrOrUrl) + if (decoded.type === 'naddr') { + const coordinate = `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier || ''}` + tags.push(['a', coordinate]) + } + } catch (e) { + // Ignore decode errors + } + } else { + // Add 'r' tag for URLs + tags.push(['r', naddrOrUrl]) + } + + return tags +} + /** * Generate a unique identifier for an article * For Nostr articles: use the naddr directly @@ -43,80 +113,174 @@ export function generateArticleIdentifier(naddrOrUrl: string): string { } /** - * Save reading position to Nostr (Kind 30078) + * Save reading position to Nostr + * Supports both new kind 39802 and legacy kind 30078 (dual-write during migration) */ export async function saveReadingPosition( relayPool: RelayPool, eventStore: IEventStore, factory: EventFactory, articleIdentifier: string, - position: ReadingPosition + position: ReadingPosition, + options?: { + useProgressKind?: boolean // Default: true + writeLegacy?: boolean // Default: true (dual-write) + } ): Promise { + const useProgressKind = options?.useProgressKind !== false + const writeLegacy = options?.writeLegacy !== false + console.log('💾 [ReadingPosition] Saving position:', { identifier: articleIdentifier.slice(0, 32) + '...', position: position.position, positionPercent: Math.round(position.position * 100) + '%', timestamp: position.timestamp, - scrollTop: position.scrollTop + scrollTop: position.scrollTop, + useProgressKind, + writeLegacy }) - const dTag = `${READING_POSITION_PREFIX}${articleIdentifier}` + const now = Math.floor(Date.now() / 1000) - const draft = await factory.create(async () => ({ - kind: APP_DATA_KIND, - content: JSON.stringify(position), - tags: [ - ['d', dTag], - ['client', 'boris'] - ], - created_at: Math.floor(Date.now() / 1000) - })) + // Write new kind 39802 (preferred) + if (useProgressKind) { + const progressContent: ReadingProgressContent = { + progress: position.position, + ts: position.timestamp, + loc: position.scrollTop, + ver: '1' + } + + const tags = generateProgressTags(articleIdentifier) + + const draft = await factory.create(async () => ({ + kind: READING_PROGRESS_KIND, + content: JSON.stringify(progressContent), + tags, + created_at: now + })) - const signed = await factory.sign(draft) + const signed = await factory.sign(draft) + await publishEvent(relayPool, eventStore, signed) + + console.log('✅ [ReadingProgress] Saved kind 39802, event ID:', signed.id.slice(0, 8)) + } - // Use unified write service - await publishEvent(relayPool, eventStore, signed) + // Write legacy kind 30078 (for backward compatibility) + if (writeLegacy) { + const legacyDTag = `${READING_POSITION_PREFIX}${articleIdentifier}` + + const legacyDraft = await factory.create(async () => ({ + kind: APP_DATA_KIND, + content: JSON.stringify(position), + tags: [ + ['d', legacyDTag], + ['client', 'boris'] + ], + created_at: now + })) - console.log('✅ [ReadingPosition] Position saved successfully, event ID:', signed.id.slice(0, 8)) + const legacySigned = await factory.sign(legacyDraft) + await publishEvent(relayPool, eventStore, legacySigned) + + console.log('✅ [ReadingPosition] Saved legacy kind 30078, event ID:', legacySigned.id.slice(0, 8)) + } } /** * Load reading position from Nostr + * Tries new kind 39802 first, falls back to legacy kind 30078 */ export async function loadReadingPosition( relayPool: RelayPool, eventStore: IEventStore, pubkey: string, - articleIdentifier: string + articleIdentifier: string, + options?: { + useProgressKind?: boolean // Default: true + } ): Promise { - const dTag = `${READING_POSITION_PREFIX}${articleIdentifier}` + const useProgressKind = options?.useProgressKind !== false + const progressDTag = generateDTag(articleIdentifier) + const legacyDTag = `${READING_POSITION_PREFIX}${articleIdentifier}` console.log('📖 [ReadingPosition] Loading position:', { pubkey: pubkey.slice(0, 8) + '...', identifier: articleIdentifier.slice(0, 32) + '...', - dTag: dTag.slice(0, 50) + '...' + progressDTag: progressDTag.slice(0, 50) + '...', + legacyDTag: legacyDTag.slice(0, 50) + '...' }) - // First, check if we already have the position in the local event store + // Try new kind 39802 first (if enabled) + if (useProgressKind) { + try { + const localEvent = await firstValueFrom( + eventStore.replaceable(READING_PROGRESS_KIND, pubkey, progressDTag) + ) + if (localEvent) { + const content = getReadingProgressContent(localEvent) + if (content) { + console.log('✅ [ReadingProgress] Loaded kind 39802 from local store:', { + position: content.position, + positionPercent: Math.round(content.position * 100) + '%', + timestamp: content.timestamp + }) + + // Fetch from relays in background + relayPool + .subscription(RELAYS, { + kinds: [READING_PROGRESS_KIND], + authors: [pubkey], + '#d': [progressDTag] + }) + .pipe(onlyEvents(), mapEventsToStore(eventStore)) + .subscribe() + + return content + } + } + } catch (err) { + console.log('📭 No cached kind 39802 found, trying relays...') + } + + // Try fetching kind 39802 from relays + const progressResult = await fetchFromRelays( + relayPool, + eventStore, + pubkey, + READING_PROGRESS_KIND, + progressDTag, + getReadingProgressContent + ) + + if (progressResult) { + console.log('✅ [ReadingProgress] Loaded kind 39802 from relays') + return progressResult + } + } + + // Fall back to legacy kind 30078 + console.log('📭 No kind 39802 found, trying legacy kind 30078...') + try { const localEvent = await firstValueFrom( - eventStore.replaceable(APP_DATA_KIND, pubkey, dTag) + eventStore.replaceable(APP_DATA_KIND, pubkey, legacyDTag) ) if (localEvent) { const content = getReadingPositionContent(localEvent) if (content) { - console.log('✅ [ReadingPosition] Loaded from local store:', { + console.log('✅ [ReadingPosition] Loaded legacy kind 30078 from local store:', { position: content.position, positionPercent: Math.round(content.position * 100) + '%', timestamp: content.timestamp }) - // Still fetch from relays in the background to get any updates + // Fetch from relays in background relayPool .subscription(RELAYS, { kinds: [APP_DATA_KIND], authors: [pubkey], - '#d': [dTag] + '#d': [legacyDTag] }) .pipe(onlyEvents(), mapEventsToStore(eventStore)) .subscribe() @@ -125,23 +289,49 @@ export async function loadReadingPosition( } } } catch (err) { - console.log('📭 No cached reading position found, fetching from relays...') + console.log('📭 No cached legacy position found, trying relays...') } - // If not in local store, fetch from relays + // Try fetching legacy from relays + const legacyResult = await fetchFromRelays( + relayPool, + eventStore, + pubkey, + APP_DATA_KIND, + legacyDTag, + getReadingPositionContent + ) + + if (legacyResult) { + console.log('✅ [ReadingPosition] Loaded legacy kind 30078 from relays') + return legacyResult + } + + console.log('📭 No reading position found') + return null +} + +// Helper function to fetch from relays with timeout +async function fetchFromRelays( + relayPool: RelayPool, + eventStore: IEventStore, + pubkey: string, + kind: number, + dTag: string, + parser: (event: NostrEvent) => ReadingPosition | undefined +): Promise { return new Promise((resolve) => { let hasResolved = false const timeout = setTimeout(() => { if (!hasResolved) { - console.log('⏱️ Reading position load timeout - no position found') hasResolved = true resolve(null) } - }, 3000) // Shorter timeout for reading positions + }, 3000) const sub = relayPool .subscription(RELAYS, { - kinds: [APP_DATA_KIND], + kinds: [kind], authors: [pubkey], '#d': [dTag] }) @@ -153,33 +343,20 @@ export async function loadReadingPosition( hasResolved = true try { const event = await firstValueFrom( - eventStore.replaceable(APP_DATA_KIND, pubkey, dTag) + eventStore.replaceable(kind, pubkey, dTag) ) if (event) { - const content = getReadingPositionContent(event) - if (content) { - console.log('✅ [ReadingPosition] Loaded from relays:', { - position: content.position, - positionPercent: Math.round(content.position * 100) + '%', - timestamp: content.timestamp - }) - resolve(content) - } else { - console.log('⚠️ [ReadingPosition] Event found but no valid content') - resolve(null) - } + const content = parser(event) + resolve(content || null) } else { - console.log('📭 [ReadingPosition] No position found on relays') resolve(null) } } catch (err) { - console.error('❌ Error loading reading position:', err) resolve(null) } } }, - error: (err) => { - console.error('❌ Reading position subscription error:', err) + error: () => { clearTimeout(timeout) if (!hasResolved) { hasResolved = true diff --git a/src/services/readsService.ts b/src/services/readsService.ts index f54adc9b..65d03df2 100644 --- a/src/services/readsService.ts +++ b/src/services/readsService.ts @@ -8,7 +8,7 @@ import { RELAYS } from '../config/relays' import { KINDS } from '../config/kinds' import { classifyBookmarkType } from '../utils/bookmarkTypeClassifier' import { nip19 } from 'nostr-tools' -import { processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor' +import { processReadingProgress, processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor' import { mergeReadItem } from '../utils/readItemMerge' const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers @@ -61,19 +61,30 @@ export async function fetchAllReads( try { // Fetch all data sources in parallel - const [readingPositionEvents, markedAsReadArticles] = await Promise.all([ + // Query both new kind 39802 and legacy kind 30078 + const [progressEvents, legacyPositionEvents, markedAsReadArticles] = await Promise.all([ + queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [userPubkey] }, { relayUrls: RELAYS }), queryEvents(relayPool, { kinds: [KINDS.AppData], authors: [userPubkey] }, { relayUrls: RELAYS }), fetchReadArticles(relayPool, userPubkey) ]) console.log('📊 [Reads] Data fetched:', { - readingPositions: readingPositionEvents.length, + readingProgress: progressEvents.length, + legacyPositions: legacyPositionEvents.length, markedAsRead: markedAsReadArticles.length, bookmarks: bookmarks.length }) - // Process reading positions and emit items - processReadingPositions(readingPositionEvents, readsMap) + // Process new reading progress events (kind 39802) first + processReadingProgress(progressEvents, readsMap) + if (onItem) { + readsMap.forEach(item => { + if (item.type === 'article') onItem(item) + }) + } + + // Process legacy reading positions (kind 30078) - won't override newer 39802 data + processReadingPositions(legacyPositionEvents, readsMap) if (onItem) { readsMap.forEach(item => { if (item.type === 'article') onItem(item) diff --git a/src/services/settingsService.ts b/src/services/settingsService.ts index 54c278a6..62cf8920 100644 --- a/src/services/settingsService.ts +++ b/src/services/settingsService.ts @@ -60,6 +60,9 @@ export interface UserSettings { paragraphAlignment?: 'left' | 'justify' // default: justify // Reading position sync syncReadingPosition?: boolean // default: false (opt-in) + // Reading progress migration (internal flag) + useReadingProgressKind?: boolean // default: true (use kind 39802) + writeLegacyReadingPosition?: boolean // default: true (dual-write during migration) } export async function loadSettings( From 61e60272522ead07b3f93a7f99d867f714b21c8a Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 10:10:18 +0200 Subject: [PATCH 11/42] docs: add migration guide and test documentation for NIP-39802 - Create READING_PROGRESS_MIGRATION.md with detailed migration phases - Document test scenarios inline in readingPositionService and readingDataProcessor - Outline timeline for dual-write, prefer-new, and deprecation phases - Add rollback plan and settings API documentation - Include comparison table of legacy vs new event formats --- READING_PROGRESS_MIGRATION.md | 153 +++++++++++++++++++++++++ src/services/readingDataProcessor.ts | 7 ++ src/services/readingPositionService.ts | 4 + 3 files changed, 164 insertions(+) create mode 100644 READING_PROGRESS_MIGRATION.md diff --git a/READING_PROGRESS_MIGRATION.md b/READING_PROGRESS_MIGRATION.md new file mode 100644 index 00000000..ac8fdd30 --- /dev/null +++ b/READING_PROGRESS_MIGRATION.md @@ -0,0 +1,153 @@ +# Reading Progress Migration Guide + +## Overview + +Boris has migrated from using NIP-78 application data (kind 30078) to a dedicated NIP-39802 Reading Progress event kind (kind 39802). This document outlines the migration strategy and timeline. + +## Migration Phases + +### Phase A: Dual-Write (Current Phase) +**Status:** Active +**Timeline:** Initial release through Q1 2025 + +During this phase: +- ✅ Boris writes **both** kind 39802 (new) and kind 30078 (legacy) events +- ✅ Boris reads kind 39802 first, falls back to kind 30078 if not found +- ✅ Users can control migration via settings flags (internal): + - `useReadingProgressKind`: Enable/disable kind 39802 reads (default: true) + - `writeLegacyReadingPosition`: Enable/disable kind 30078 writes (default: true) + +**Benefits:** +- Backward compatibility with older Boris versions +- Cross-client compatibility during transition +- Safe rollback path if issues are discovered + +### Phase B: Prefer New Kind (Planned) +**Status:** Planned +**Timeline:** Q2 2025 + +During this phase: +- Boris will default to writing only kind 39802 +- Legacy writes (kind 30078) will be disabled by default but available via setting +- Reading will continue to support both kinds for backward compatibility + +**Migration trigger:** +- Set `writeLegacyReadingPosition: false` in user settings +- Or wait for automatic transition in a future release + +### Phase C: Legacy Deprecation (Future) +**Status:** Future +**Timeline:** Q3 2025+ + +During this phase: +- Boris will stop writing kind 30078 entirely +- Reading will still support kind 30078 for historical data +- Documentation will recommend other clients adopt NIP-39802 + +## Technical Details + +### Event Structure Comparison + +#### Legacy (kind 30078) +```json +{ + "kind": 30078, + "content": "{\"position\":0.66,\"timestamp\":1734635012,\"scrollTop\":1432}", + "tags": [ + ["d", "boris:reading-position:"], + ["client", "boris"] + ] +} +``` + +#### New (kind 39802) +```json +{ + "kind": 39802, + "content": "{\"progress\":0.66,\"ts\":1734635012,\"loc\":1432,\"ver\":\"1\"}", + "tags": [ + ["d", "30023::"], + ["a", "30023::"], + ["client", "boris"] + ] +} +``` + +### Key Differences + +1. **d tag format:** + - Legacy: `boris:reading-position:` + - New: Article coordinate or `url:` for URLs + +2. **Timestamp authority:** + - Legacy: Uses `content.timestamp` for ordering + - New: Uses `event.created_at` for ordering (per NIP-33 spec) + +3. **Content schema:** + - Legacy: `{position, timestamp, scrollTop}` + - New: `{progress, ts, loc, ver}` + +4. **Discoverability:** + - Legacy: Requires knowledge of `boris:reading-position:` prefix + - New: Standard kind with `a` and `r` tags for filtering + +## Testing Checklist + +Before disabling legacy writes, verify: + +- [ ] Reading progress syncs correctly for Nostr articles (kind 30023) +- [ ] Reading progress syncs correctly for external URLs +- [ ] Progress restores correctly on article reload +- [ ] Progress merges correctly when reading from multiple devices +- [ ] Newer timestamps take precedence (created_at ordering) +- [ ] Legacy kind 30078 events are still readable +- [ ] Migration works across relay sets +- [ ] Local-first loading works (event store cache) +- [ ] Background relay sync works correctly + +## For Other Client Developers + +If you're implementing reading progress in your Nostr client: + +1. **Adopt NIP-39802** for new implementations +2. **Read both kinds** during transition (prefer 39802, fall back to 30078) +3. **Use `created_at`** for event ordering, not content timestamps +4. **Implement rate limiting** to avoid relay spam (debounce, min delta) +5. See full spec at `/public/md/NIP-39802.md` + +## Rollback Plan + +If critical issues are discovered with kind 39802: + +1. Set `useReadingProgressKind: false` in settings +2. Boris will fall back to kind 30078 only +3. Report issues on GitHub +4. Wait for fix before re-enabling + +## Settings API + +Users can control migration behavior via settings: + +```typescript +interface UserSettings { + // ... other settings + syncReadingPosition?: boolean // Master toggle (default: false) + useReadingProgressKind?: boolean // Use kind 39802 (default: true) + writeLegacyReadingPosition?: boolean // Write kind 30078 (default: true) +} +``` + +## Timeline Summary + +| Phase | Start | End | Kind 39802 Write | Kind 30078 Write | Kind 30078 Read | +|-------|-------|-----|------------------|------------------|-----------------| +| A: Dual-Write | Now | Q1 2025 | ✅ Yes | ✅ Yes | ✅ Yes | +| B: Prefer New | Q2 2025 | Q3 2025 | ✅ Yes | ⚠️ Optional | ✅ Yes | +| C: Deprecate | Q3 2025+ | - | ✅ Yes | ❌ No | ✅ Yes (historical) | + +## Questions? + +- See NIP-39802 spec: `/public/md/NIP-39802.md` +- File issues on GitHub +- Discuss in Nostr developer channels + diff --git a/src/services/readingDataProcessor.ts b/src/services/readingDataProcessor.ts index b6797361..114aca3c 100644 --- a/src/services/readingDataProcessor.ts +++ b/src/services/readingDataProcessor.ts @@ -16,6 +16,13 @@ interface ReadArticle { /** * Processes reading progress events (kind 39802) into ReadItems + * + * Test scenarios: + * - Kind 39802 with d="30023:..." → article ReadItem with naddr id + * - Kind 39802 with d="url:..." → external ReadItem with decoded URL + * - Newer event.created_at overwrites older timestamp + * - Invalid d tag format → skip event + * - Malformed JSON content → skip event */ export function processReadingProgress( events: NostrEvent[], diff --git a/src/services/readingPositionService.ts b/src/services/readingPositionService.ts index 4a57c6be..ca150490 100644 --- a/src/services/readingPositionService.ts +++ b/src/services/readingPositionService.ts @@ -50,6 +50,10 @@ function getReadingProgressContent(event: NostrEvent): ReadingPosition | undefin } // Generate d tag for kind 39802 based on target +// Test cases: +// - naddr1... → "30023::" +// - https://example.com/post → "url:" +// - Invalid naddr → "url:" (fallback) function generateDTag(naddrOrUrl: string): string { // If it's a nostr article (naddr format), decode and build coordinate if (naddrOrUrl.startsWith('naddr1')) { From 442c138d6adbec23c1309ff621e03d77808107a5 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 10:14:37 +0200 Subject: [PATCH 12/42] refactor: simplify NIP-39802 implementation - remove migration complexity - Remove dual-write logic: only write kind 39802 - Remove legacy kind 30078 read fallback - Remove migration settings flags (useReadingProgressKind, writeLegacyReadingPosition) - Simplify readingPositionService: single write/read path - Remove processReadingPositions() legacy processor - Update readsService and linksService to only query kind 39802 - Simplify NIP-39802 spec: remove migration section - Delete READING_PROGRESS_MIGRATION.md (not needed for unreleased app) - Clean up imports and comments No backward compatibility needed since app hasn't been released yet. --- READING_PROGRESS_MIGRATION.md | 153 -------------------- public/md/NIP-39802.md | 9 -- src/components/ContentPanel.tsx | 11 +- src/config/kinds.ts | 2 +- src/services/linksService.ts | 22 +-- src/services/readingDataProcessor.ts | 61 +------- src/services/readingPositionService.ts | 192 ++++++------------------- src/services/readsService.ts | 19 +-- src/services/settingsService.ts | 3 - 9 files changed, 57 insertions(+), 415 deletions(-) delete mode 100644 READING_PROGRESS_MIGRATION.md diff --git a/READING_PROGRESS_MIGRATION.md b/READING_PROGRESS_MIGRATION.md deleted file mode 100644 index ac8fdd30..00000000 --- a/READING_PROGRESS_MIGRATION.md +++ /dev/null @@ -1,153 +0,0 @@ -# Reading Progress Migration Guide - -## Overview - -Boris has migrated from using NIP-78 application data (kind 30078) to a dedicated NIP-39802 Reading Progress event kind (kind 39802). This document outlines the migration strategy and timeline. - -## Migration Phases - -### Phase A: Dual-Write (Current Phase) -**Status:** Active -**Timeline:** Initial release through Q1 2025 - -During this phase: -- ✅ Boris writes **both** kind 39802 (new) and kind 30078 (legacy) events -- ✅ Boris reads kind 39802 first, falls back to kind 30078 if not found -- ✅ Users can control migration via settings flags (internal): - - `useReadingProgressKind`: Enable/disable kind 39802 reads (default: true) - - `writeLegacyReadingPosition`: Enable/disable kind 30078 writes (default: true) - -**Benefits:** -- Backward compatibility with older Boris versions -- Cross-client compatibility during transition -- Safe rollback path if issues are discovered - -### Phase B: Prefer New Kind (Planned) -**Status:** Planned -**Timeline:** Q2 2025 - -During this phase: -- Boris will default to writing only kind 39802 -- Legacy writes (kind 30078) will be disabled by default but available via setting -- Reading will continue to support both kinds for backward compatibility - -**Migration trigger:** -- Set `writeLegacyReadingPosition: false` in user settings -- Or wait for automatic transition in a future release - -### Phase C: Legacy Deprecation (Future) -**Status:** Future -**Timeline:** Q3 2025+ - -During this phase: -- Boris will stop writing kind 30078 entirely -- Reading will still support kind 30078 for historical data -- Documentation will recommend other clients adopt NIP-39802 - -## Technical Details - -### Event Structure Comparison - -#### Legacy (kind 30078) -```json -{ - "kind": 30078, - "content": "{\"position\":0.66,\"timestamp\":1734635012,\"scrollTop\":1432}", - "tags": [ - ["d", "boris:reading-position:"], - ["client", "boris"] - ] -} -``` - -#### New (kind 39802) -```json -{ - "kind": 39802, - "content": "{\"progress\":0.66,\"ts\":1734635012,\"loc\":1432,\"ver\":\"1\"}", - "tags": [ - ["d", "30023::"], - ["a", "30023::"], - ["client", "boris"] - ] -} -``` - -### Key Differences - -1. **d tag format:** - - Legacy: `boris:reading-position:` - - New: Article coordinate or `url:` for URLs - -2. **Timestamp authority:** - - Legacy: Uses `content.timestamp` for ordering - - New: Uses `event.created_at` for ordering (per NIP-33 spec) - -3. **Content schema:** - - Legacy: `{position, timestamp, scrollTop}` - - New: `{progress, ts, loc, ver}` - -4. **Discoverability:** - - Legacy: Requires knowledge of `boris:reading-position:` prefix - - New: Standard kind with `a` and `r` tags for filtering - -## Testing Checklist - -Before disabling legacy writes, verify: - -- [ ] Reading progress syncs correctly for Nostr articles (kind 30023) -- [ ] Reading progress syncs correctly for external URLs -- [ ] Progress restores correctly on article reload -- [ ] Progress merges correctly when reading from multiple devices -- [ ] Newer timestamps take precedence (created_at ordering) -- [ ] Legacy kind 30078 events are still readable -- [ ] Migration works across relay sets -- [ ] Local-first loading works (event store cache) -- [ ] Background relay sync works correctly - -## For Other Client Developers - -If you're implementing reading progress in your Nostr client: - -1. **Adopt NIP-39802** for new implementations -2. **Read both kinds** during transition (prefer 39802, fall back to 30078) -3. **Use `created_at`** for event ordering, not content timestamps -4. **Implement rate limiting** to avoid relay spam (debounce, min delta) -5. See full spec at `/public/md/NIP-39802.md` - -## Rollback Plan - -If critical issues are discovered with kind 39802: - -1. Set `useReadingProgressKind: false` in settings -2. Boris will fall back to kind 30078 only -3. Report issues on GitHub -4. Wait for fix before re-enabling - -## Settings API - -Users can control migration behavior via settings: - -```typescript -interface UserSettings { - // ... other settings - syncReadingPosition?: boolean // Master toggle (default: false) - useReadingProgressKind?: boolean // Use kind 39802 (default: true) - writeLegacyReadingPosition?: boolean // Write kind 30078 (default: true) -} -``` - -## Timeline Summary - -| Phase | Start | End | Kind 39802 Write | Kind 30078 Write | Kind 30078 Read | -|-------|-------|-----|------------------|------------------|-----------------| -| A: Dual-Write | Now | Q1 2025 | ✅ Yes | ✅ Yes | ✅ Yes | -| B: Prefer New | Q2 2025 | Q3 2025 | ✅ Yes | ⚠️ Optional | ✅ Yes | -| C: Deprecate | Q3 2025+ | - | ✅ Yes | ❌ No | ✅ Yes (historical) | - -## Questions? - -- See NIP-39802 spec: `/public/md/NIP-39802.md` -- File issues on GitHub -- Discuss in Nostr developer channels - diff --git a/public/md/NIP-39802.md b/public/md/NIP-39802.md index 98efe1fe..56b45e22 100644 --- a/public/md/NIP-39802.md +++ b/public/md/NIP-39802.md @@ -163,17 +163,8 @@ While NIP-78 (kind 30078) can store arbitrary application data, a dedicated kind - For URLs, the base64url encoding in the `d` tag MUST use URL-safe characters (replace `+` with `-`, `/` with `_`, remove padding `=`) - Clients SHOULD validate that `progress` is between 0 and 1 -## Migration from NIP-78 - -Clients currently using NIP-78 (kind 30078) for reading progress can migrate by: - -1. **Dual-write phase**: Publish both kind 39802 and legacy kind 30078 events -2. **Dual-read phase**: Read from kind 39802 first, fall back to kind 30078 -3. **Cleanup phase**: After a grace period, stop writing kind 30078 but continue reading for backward compatibility - ## References - [NIP-01: Basic protocol flow](https://github.com/nostr-protocol/nips/blob/master/01.md) - [NIP-33: Parameterized Replaceable Events](https://github.com/nostr-protocol/nips/blob/master/33.md) -- [NIP-78: Application Data](https://github.com/nostr-protocol/nips/blob/master/78.md) diff --git a/src/components/ContentPanel.tsx b/src/components/ContentPanel.tsx index 19013861..28e08ad2 100644 --- a/src/components/ContentPanel.tsx +++ b/src/components/ContentPanel.tsx @@ -177,16 +177,12 @@ const ContentPanel: React.FC = ({ position, timestamp: Math.floor(Date.now() / 1000), scrollTop: window.pageYOffset || document.documentElement.scrollTop - }, - { - useProgressKind: settings?.useReadingProgressKind !== false, - writeLegacy: settings?.writeLegacyReadingPosition !== false } ) } catch (error) { console.error('❌ [ContentPanel] Failed to save reading position:', error) } - }, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, settings?.useReadingProgressKind, settings?.writeLegacyReadingPosition, selectedUrl]) + }, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl]) const { isReadingComplete, progressPercentage, saveNow } = useReadingPosition({ enabled: isTextContent, @@ -225,10 +221,7 @@ const ContentPanel: React.FC = ({ relayPool, eventStore, activeAccount.pubkey, - articleIdentifier, - { - useProgressKind: settings?.useReadingProgressKind !== false - } + articleIdentifier ) if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) { diff --git a/src/config/kinds.ts b/src/config/kinds.ts index 7f567f8d..ed3cca16 100644 --- a/src/config/kinds.ts +++ b/src/config/kinds.ts @@ -2,7 +2,7 @@ export const KINDS = { Highlights: 9802, // NIP-?? user highlights BlogPost: 30023, // NIP-23 long-form article - AppData: 30078, // NIP-78 application data (legacy reading positions) + AppData: 30078, // NIP-78 application data ReadingProgress: 39802, // NIP-39802 reading progress List: 30001, // NIP-51 list (addressable) ListReplaceable: 30003, // NIP-51 replaceable list diff --git a/src/services/linksService.ts b/src/services/linksService.ts index d1a940f8..db789f0f 100644 --- a/src/services/linksService.ts +++ b/src/services/linksService.ts @@ -4,12 +4,12 @@ import { queryEvents } from './dataFetch' import { RELAYS } from '../config/relays' import { KINDS } from '../config/kinds' import { ReadItem } from './readsService' -import { processReadingProgress, processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor' +import { processReadingProgress, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor' import { mergeReadItem } from '../utils/readItemMerge' /** * Fetches external URL links with reading progress from: - * - URLs with reading progress (kind:39802 and legacy kind:30078) + * - URLs with reading progress (kind:39802) * - Manually marked as read URLs (kind:7, kind:17) */ export async function fetchLinks( @@ -32,20 +32,17 @@ export async function fetchLinks( try { // Fetch all data sources in parallel - // Query both new kind 39802 and legacy kind 30078 - const [progressEvents, legacyPositionEvents, markedAsReadArticles] = await Promise.all([ + const [progressEvents, markedAsReadArticles] = await Promise.all([ queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [userPubkey] }, { relayUrls: RELAYS }), - queryEvents(relayPool, { kinds: [KINDS.AppData], authors: [userPubkey] }, { relayUrls: RELAYS }), fetchReadArticles(relayPool, userPubkey) ]) console.log('📊 [Links] Data fetched:', { readingProgress: progressEvents.length, - legacyPositions: legacyPositionEvents.length, markedAsRead: markedAsReadArticles.length }) - // Process new reading progress events (kind 39802) first + // Process reading progress events (kind 39802) processReadingProgress(progressEvents, linksMap) if (onItem) { linksMap.forEach(item => { @@ -56,17 +53,6 @@ export async function fetchLinks( }) } - // Process legacy reading positions (kind 30078) - won't override newer 39802 data - processReadingPositions(legacyPositionEvents, linksMap) - if (onItem) { - linksMap.forEach(item => { - if (item.type === 'external') { - const hasProgress = (item.readingProgress && item.readingProgress > 0) || item.markedAsRead - if (hasProgress) emitItem(item) - } - }) - } - // Process marked-as-read and emit external items processMarkedAsRead(markedAsReadArticles, linksMap) if (onItem) { diff --git a/src/services/readingDataProcessor.ts b/src/services/readingDataProcessor.ts index 114aca3c..b01d5b22 100644 --- a/src/services/readingDataProcessor.ts +++ b/src/services/readingDataProcessor.ts @@ -3,7 +3,6 @@ import { ReadItem } from './readsService' import { fallbackTitleFromUrl } from '../utils/readItemMerge' import { KINDS } from '../config/kinds' -const READING_POSITION_PREFIX = 'boris:reading-position:' const READING_PROGRESS_KIND = KINDS.ReadingProgress // 39802 interface ReadArticle { @@ -36,7 +35,7 @@ export function processReadingProgress( try { const content = JSON.parse(event.content) - const position = content.progress || content.position || 0 + const position = content.progress || 0 // Use event.created_at as authoritative timestamp (NIP-39802 spec) const timestamp = event.created_at @@ -100,64 +99,6 @@ export function processReadingProgress( } } -/** - * Processes legacy reading position events (kind 30078) into ReadItems - */ -export function processReadingPositions( - events: NostrEvent[], - readsMap: Map -): void { - for (const event of events) { - const dTag = event.tags.find(t => t[0] === 'd')?.[1] - if (!dTag || !dTag.startsWith(READING_POSITION_PREFIX)) continue - - const identifier = dTag.replace(READING_POSITION_PREFIX, '') - - try { - const positionData = JSON.parse(event.content) - const position = positionData.position - // For legacy events, use content timestamp if available, otherwise created_at - const timestamp = positionData.timestamp || event.created_at - - let itemId: string - let itemUrl: string | undefined - let itemType: 'article' | 'external' = 'external' - - // Check if it's a nostr article (naddr format) - if (identifier.startsWith('naddr1')) { - itemId = identifier - itemType = 'article' - } else { - // It's a base64url-encoded URL - try { - itemUrl = atob(identifier.replace(/-/g, '+').replace(/_/g, '/')) - itemId = itemUrl - itemType = 'external' - } catch (e) { - console.warn('Failed to decode URL identifier:', identifier) - continue - } - } - - // Add or update the item - const existing = readsMap.get(itemId) - if (!existing || !existing.readingTimestamp || timestamp > existing.readingTimestamp) { - readsMap.set(itemId, { - ...existing, - id: itemId, - source: 'reading-progress', - type: itemType, - url: itemUrl, - readingProgress: position, - readingTimestamp: timestamp - }) - } - } catch (error) { - console.warn('Failed to parse reading position:', error) - } - } -} - /** * Processes marked-as-read articles into ReadItems */ diff --git a/src/services/readingPositionService.ts b/src/services/readingPositionService.ts index ca150490..718d9869 100644 --- a/src/services/readingPositionService.ts +++ b/src/services/readingPositionService.ts @@ -7,9 +7,7 @@ import { publishEvent } from './writeService' import { RELAYS } from '../config/relays' import { KINDS } from '../config/kinds' -const APP_DATA_KIND = KINDS.AppData // 30078 - Legacy NIP-78 Application Data const READING_PROGRESS_KIND = KINDS.ReadingProgress // 39802 - NIP-39802 Reading Progress -const READING_POSITION_PREFIX = 'boris:reading-position:' // Legacy prefix export interface ReadingPosition { position: number // 0-1 scroll progress @@ -24,17 +22,7 @@ export interface ReadingProgressContent { ver?: string // Schema version } -// Helper to extract and parse reading position from legacy event (kind 30078) -function getReadingPositionContent(event: NostrEvent): ReadingPosition | undefined { - if (!event.content || event.content.length === 0) return undefined - try { - return JSON.parse(event.content) as ReadingPosition - } catch { - return undefined - } -} - -// Helper to extract and parse reading progress from new event (kind 39802) +// Helper to extract and parse reading progress from event (kind 39802) function getReadingProgressContent(event: NostrEvent): ReadingPosition | undefined { if (!event.content || event.content.length === 0) return undefined try { @@ -117,174 +105,84 @@ export function generateArticleIdentifier(naddrOrUrl: string): string { } /** - * Save reading position to Nostr - * Supports both new kind 39802 and legacy kind 30078 (dual-write during migration) + * Save reading position to Nostr (kind 39802) */ export async function saveReadingPosition( relayPool: RelayPool, eventStore: IEventStore, factory: EventFactory, articleIdentifier: string, - position: ReadingPosition, - options?: { - useProgressKind?: boolean // Default: true - writeLegacy?: boolean // Default: true (dual-write) - } + position: ReadingPosition ): Promise { - const useProgressKind = options?.useProgressKind !== false - const writeLegacy = options?.writeLegacy !== false - - console.log('💾 [ReadingPosition] Saving position:', { + console.log('💾 [ReadingProgress] Saving position:', { identifier: articleIdentifier.slice(0, 32) + '...', position: position.position, positionPercent: Math.round(position.position * 100) + '%', timestamp: position.timestamp, - scrollTop: position.scrollTop, - useProgressKind, - writeLegacy + scrollTop: position.scrollTop }) const now = Math.floor(Date.now() / 1000) - // Write new kind 39802 (preferred) - if (useProgressKind) { - const progressContent: ReadingProgressContent = { - progress: position.position, - ts: position.timestamp, - loc: position.scrollTop, - ver: '1' - } - - const tags = generateProgressTags(articleIdentifier) - - const draft = await factory.create(async () => ({ - kind: READING_PROGRESS_KIND, - content: JSON.stringify(progressContent), - tags, - created_at: now - })) - - const signed = await factory.sign(draft) - await publishEvent(relayPool, eventStore, signed) - - console.log('✅ [ReadingProgress] Saved kind 39802, event ID:', signed.id.slice(0, 8)) + const progressContent: ReadingProgressContent = { + progress: position.position, + ts: position.timestamp, + loc: position.scrollTop, + ver: '1' } + + const tags = generateProgressTags(articleIdentifier) + + const draft = await factory.create(async () => ({ + kind: READING_PROGRESS_KIND, + content: JSON.stringify(progressContent), + tags, + created_at: now + })) - // Write legacy kind 30078 (for backward compatibility) - if (writeLegacy) { - const legacyDTag = `${READING_POSITION_PREFIX}${articleIdentifier}` - - const legacyDraft = await factory.create(async () => ({ - kind: APP_DATA_KIND, - content: JSON.stringify(position), - tags: [ - ['d', legacyDTag], - ['client', 'boris'] - ], - created_at: now - })) - - const legacySigned = await factory.sign(legacyDraft) - await publishEvent(relayPool, eventStore, legacySigned) - - console.log('✅ [ReadingPosition] Saved legacy kind 30078, event ID:', legacySigned.id.slice(0, 8)) - } + const signed = await factory.sign(draft) + await publishEvent(relayPool, eventStore, signed) + + console.log('✅ [ReadingProgress] Saved, event ID:', signed.id.slice(0, 8)) } /** - * Load reading position from Nostr - * Tries new kind 39802 first, falls back to legacy kind 30078 + * Load reading position from Nostr (kind 39802) */ export async function loadReadingPosition( relayPool: RelayPool, eventStore: IEventStore, pubkey: string, - articleIdentifier: string, - options?: { - useProgressKind?: boolean // Default: true - } + articleIdentifier: string ): Promise { - const useProgressKind = options?.useProgressKind !== false - const progressDTag = generateDTag(articleIdentifier) - const legacyDTag = `${READING_POSITION_PREFIX}${articleIdentifier}` + const dTag = generateDTag(articleIdentifier) - console.log('📖 [ReadingPosition] Loading position:', { + console.log('📖 [ReadingProgress] Loading position:', { pubkey: pubkey.slice(0, 8) + '...', identifier: articleIdentifier.slice(0, 32) + '...', - progressDTag: progressDTag.slice(0, 50) + '...', - legacyDTag: legacyDTag.slice(0, 50) + '...' + dTag: dTag.slice(0, 50) + '...' }) - // Try new kind 39802 first (if enabled) - if (useProgressKind) { - try { - const localEvent = await firstValueFrom( - eventStore.replaceable(READING_PROGRESS_KIND, pubkey, progressDTag) - ) - if (localEvent) { - const content = getReadingProgressContent(localEvent) - if (content) { - console.log('✅ [ReadingProgress] Loaded kind 39802 from local store:', { - position: content.position, - positionPercent: Math.round(content.position * 100) + '%', - timestamp: content.timestamp - }) - - // Fetch from relays in background - relayPool - .subscription(RELAYS, { - kinds: [READING_PROGRESS_KIND], - authors: [pubkey], - '#d': [progressDTag] - }) - .pipe(onlyEvents(), mapEventsToStore(eventStore)) - .subscribe() - - return content - } - } - } catch (err) { - console.log('📭 No cached kind 39802 found, trying relays...') - } - - // Try fetching kind 39802 from relays - const progressResult = await fetchFromRelays( - relayPool, - eventStore, - pubkey, - READING_PROGRESS_KIND, - progressDTag, - getReadingProgressContent - ) - - if (progressResult) { - console.log('✅ [ReadingProgress] Loaded kind 39802 from relays') - return progressResult - } - } - - // Fall back to legacy kind 30078 - console.log('📭 No kind 39802 found, trying legacy kind 30078...') - + // Check local event store first try { const localEvent = await firstValueFrom( - eventStore.replaceable(APP_DATA_KIND, pubkey, legacyDTag) + eventStore.replaceable(READING_PROGRESS_KIND, pubkey, dTag) ) if (localEvent) { - const content = getReadingPositionContent(localEvent) + const content = getReadingProgressContent(localEvent) if (content) { - console.log('✅ [ReadingPosition] Loaded legacy kind 30078 from local store:', { + console.log('✅ [ReadingProgress] Loaded from local store:', { position: content.position, positionPercent: Math.round(content.position * 100) + '%', timestamp: content.timestamp }) - // Fetch from relays in background + // Fetch from relays in background to get any updates relayPool .subscription(RELAYS, { - kinds: [APP_DATA_KIND], + kinds: [READING_PROGRESS_KIND], authors: [pubkey], - '#d': [legacyDTag] + '#d': [dTag] }) .pipe(onlyEvents(), mapEventsToStore(eventStore)) .subscribe() @@ -293,25 +191,25 @@ export async function loadReadingPosition( } } } catch (err) { - console.log('📭 No cached legacy position found, trying relays...') + console.log('📭 No cached reading progress found, fetching from relays...') } - // Try fetching legacy from relays - const legacyResult = await fetchFromRelays( + // Fetch from relays + const result = await fetchFromRelays( relayPool, eventStore, pubkey, - APP_DATA_KIND, - legacyDTag, - getReadingPositionContent + READING_PROGRESS_KIND, + dTag, + getReadingProgressContent ) - if (legacyResult) { - console.log('✅ [ReadingPosition] Loaded legacy kind 30078 from relays') - return legacyResult + if (result) { + console.log('✅ [ReadingProgress] Loaded from relays') + return result } - console.log('📭 No reading position found') + console.log('📭 No reading progress found') return null } diff --git a/src/services/readsService.ts b/src/services/readsService.ts index 65d03df2..c4ca9c11 100644 --- a/src/services/readsService.ts +++ b/src/services/readsService.ts @@ -8,7 +8,7 @@ import { RELAYS } from '../config/relays' import { KINDS } from '../config/kinds' import { classifyBookmarkType } from '../utils/bookmarkTypeClassifier' import { nip19 } from 'nostr-tools' -import { processReadingProgress, processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor' +import { processReadingProgress, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor' import { mergeReadItem } from '../utils/readItemMerge' const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers @@ -37,7 +37,7 @@ export interface ReadItem { /** * Fetches all reads from multiple sources: * - Bookmarked articles (kind:30023) and article/website URLs - * - Articles/URLs with reading progress (kind:30078) + * - Articles/URLs with reading progress (kind:39802) * - Manually marked as read articles/URLs (kind:7, kind:17) */ export async function fetchAllReads( @@ -61,21 +61,18 @@ export async function fetchAllReads( try { // Fetch all data sources in parallel - // Query both new kind 39802 and legacy kind 30078 - const [progressEvents, legacyPositionEvents, markedAsReadArticles] = await Promise.all([ + const [progressEvents, markedAsReadArticles] = await Promise.all([ queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [userPubkey] }, { relayUrls: RELAYS }), - queryEvents(relayPool, { kinds: [KINDS.AppData], authors: [userPubkey] }, { relayUrls: RELAYS }), fetchReadArticles(relayPool, userPubkey) ]) console.log('📊 [Reads] Data fetched:', { readingProgress: progressEvents.length, - legacyPositions: legacyPositionEvents.length, markedAsRead: markedAsReadArticles.length, bookmarks: bookmarks.length }) - // Process new reading progress events (kind 39802) first + // Process reading progress events (kind 39802) processReadingProgress(progressEvents, readsMap) if (onItem) { readsMap.forEach(item => { @@ -83,14 +80,6 @@ export async function fetchAllReads( }) } - // Process legacy reading positions (kind 30078) - won't override newer 39802 data - processReadingPositions(legacyPositionEvents, readsMap) - if (onItem) { - readsMap.forEach(item => { - if (item.type === 'article') onItem(item) - }) - } - // Process marked-as-read and emit items processMarkedAsRead(markedAsReadArticles, readsMap) if (onItem) { diff --git a/src/services/settingsService.ts b/src/services/settingsService.ts index 62cf8920..54c278a6 100644 --- a/src/services/settingsService.ts +++ b/src/services/settingsService.ts @@ -60,9 +60,6 @@ export interface UserSettings { paragraphAlignment?: 'left' | 'justify' // default: justify // Reading position sync syncReadingPosition?: boolean // default: false (opt-in) - // Reading progress migration (internal flag) - useReadingProgressKind?: boolean // default: true (use kind 39802) - writeLegacyReadingPosition?: boolean // default: true (dual-write during migration) } export async function loadSettings( From 3b31eceeab2200856c6caea1e5f87980fd24f4de Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 10:34:53 +0200 Subject: [PATCH 13/42] feat: improve reading progress with validation and auto-mark - Add autoMarkAsReadOnCompletion setting (opt-in, default: false) - Implement auto-mark as read when reaching 95%+ completion - Add validation for progress bounds (0-1) per NIP-39802 spec - Align completion threshold to 95% to match filter behavior - Skip invalid progress events with warning log Improvements ensure consistency between completion detection and filtering, while adding safety validation per the NIP spec. --- src/components/ContentPanel.tsx | 7 ++++--- src/hooks/useReadingPosition.ts | 4 ++-- src/services/readingDataProcessor.ts | 7 +++++++ src/services/settingsService.ts | 1 + 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/components/ContentPanel.tsx b/src/components/ContentPanel.tsx index 28e08ad2..cb6630d4 100644 --- a/src/components/ContentPanel.tsx +++ b/src/components/ContentPanel.tsx @@ -189,9 +189,10 @@ const ContentPanel: React.FC = ({ syncEnabled: settings?.syncReadingPosition, onSave: handleSavePosition, onReadingComplete: () => { - // Optional: Auto-mark as read when reading is complete - if (activeAccount && !isMarkedAsRead) { - // Could trigger auto-mark as read here if desired + // Auto-mark as read when reading is complete (if enabled in settings) + if (activeAccount && !isMarkedAsRead && settings?.autoMarkAsReadOnCompletion) { + console.log('📖 [ContentPanel] Auto-marking as read on completion') + handleMarkAsRead() } } }) diff --git a/src/hooks/useReadingPosition.ts b/src/hooks/useReadingPosition.ts index 5530d020..11997ea9 100644 --- a/src/hooks/useReadingPosition.ts +++ b/src/hooks/useReadingPosition.ts @@ -4,7 +4,7 @@ interface UseReadingPositionOptions { enabled?: boolean onPositionChange?: (position: number) => void onReadingComplete?: () => void - readingCompleteThreshold?: number // Default 0.9 (90%) + readingCompleteThreshold?: number // Default 0.95 (95%) - matches filter threshold syncEnabled?: boolean // Whether to sync positions to Nostr onSave?: (position: number) => void // Callback for saving position autoSaveInterval?: number // Auto-save interval in ms (default 5000) @@ -14,7 +14,7 @@ export const useReadingPosition = ({ enabled = true, onPositionChange, onReadingComplete, - readingCompleteThreshold = 0.9, + readingCompleteThreshold = 0.95, // Match filter threshold for consistency syncEnabled = false, onSave, autoSaveInterval = 5000 diff --git a/src/services/readingDataProcessor.ts b/src/services/readingDataProcessor.ts index b01d5b22..bfd97d79 100644 --- a/src/services/readingDataProcessor.ts +++ b/src/services/readingDataProcessor.ts @@ -36,6 +36,13 @@ export function processReadingProgress( try { const content = JSON.parse(event.content) const position = content.progress || 0 + + // Validate progress is between 0 and 1 (NIP-39802 requirement) + if (position < 0 || position > 1) { + console.warn('Invalid progress value (must be 0-1):', position, 'event:', event.id.slice(0, 8)) + continue + } + // Use event.created_at as authoritative timestamp (NIP-39802 spec) const timestamp = event.created_at diff --git a/src/services/settingsService.ts b/src/services/settingsService.ts index 54c278a6..fb4736c3 100644 --- a/src/services/settingsService.ts +++ b/src/services/settingsService.ts @@ -60,6 +60,7 @@ export interface UserSettings { paragraphAlignment?: 'left' | 'justify' // default: justify // Reading position sync syncReadingPosition?: boolean // default: false (opt-in) + autoMarkAsReadOnCompletion?: boolean // default: false (opt-in) } export async function loadSettings( From 85d87bac29fab695eddf85b57399eaa8998f650b Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 10:38:28 +0200 Subject: [PATCH 14/42] docs: improve NIP-39802 with URL cleaning guidance from NIP-84 - Add recommendation to clean URLs from tracking parameters - Add URL Handling subsection with best practices - Ensure same article from different sources maps to same progress - Inspired by NIP-84 (Highlights) URL handling guidelines --- public/md/NIP-39802.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/public/md/NIP-39802.md b/public/md/NIP-39802.md index 56b45e22..03ecb405 100644 --- a/public/md/NIP-39802.md +++ b/public/md/NIP-39802.md @@ -21,6 +21,7 @@ Reading progress events use NIP-33 parameterized replaceable semantics. The `d` - For external URLs: `url:` - `a` (optional but recommended for Nostr articles): Article coordinate `30023::` - `r` (optional but recommended for URLs): Raw URL of the external content + - Clients SHOULD clean URLs from tracking parameters and non-essential query strings before tagging - `client` (optional): Client application identifier ### Content @@ -163,6 +164,14 @@ While NIP-78 (kind 30078) can store arbitrary application data, a dedicated kind - For URLs, the base64url encoding in the `d` tag MUST use URL-safe characters (replace `+` with `-`, `/` with `_`, remove padding `=`) - Clients SHOULD validate that `progress` is between 0 and 1 +### URL Handling + +When generating events for external URLs: + +- Clients SHOULD clean URLs by removing tracking parameters (e.g., `utm_*`, `fbclid`, etc.) and other non-essential query strings +- The cleaned URL should be used for both the `r` tag and the base64url encoding in the `d` tag +- This ensures that the same article from different sources (with different tracking params) maps to the same reading progress event + ## References - [NIP-01: Basic protocol flow](https://github.com/nostr-protocol/nips/blob/master/01.md) From b6ad62a3ab8799b697153006b4f331665fa4e96b Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 10:41:02 +0200 Subject: [PATCH 15/42] refactor: rename to NIP-85 (kind 39802 for reading progress) - Rename NIP-39802.md to NIP-85.md - Update all references from NIP-39802 to NIP-85 in code comments - Add Table of Contents to NIP document - Update kinds.ts to reference NIP-85 and NIP-84 (highlights) - Maintain kind number 39802 for the event type NIP-85 is the specification number, 39802 is the event kind number. --- public/md/{NIP-39802.md => NIP-85.md} | 17 +++++++++++++++-- src/config/kinds.ts | 4 ++-- src/services/readingDataProcessor.ts | 6 +++--- src/services/readingPositionService.ts | 2 +- 4 files changed, 21 insertions(+), 8 deletions(-) rename public/md/{NIP-39802.md => NIP-85.md} (92%) diff --git a/public/md/NIP-39802.md b/public/md/NIP-85.md similarity index 92% rename from public/md/NIP-39802.md rename to public/md/NIP-85.md index 03ecb405..8bef6c5a 100644 --- a/public/md/NIP-39802.md +++ b/public/md/NIP-85.md @@ -1,10 +1,23 @@ -# NIP-39802 +# NIP-85 ## Reading Progress `draft` `optional` -This NIP defines a parameterized replaceable event kind for tracking reading progress across articles and web content. +This NIP defines kind `39802`, a parameterized replaceable event for tracking reading progress across articles and web content. + +## Table of Contents + +* [Event Kind](#event-kind) +* [Event Structure](#event-structure) + * [Tags](#tags) + * [Content](#content) + * [Semantics](#semantics) +* [Examples](#examples) +* [Querying](#querying) +* [Privacy Considerations](#privacy-considerations) +* [Rationale](#rationale) +* [Implementation Notes](#implementation-notes) ## Event Kind diff --git a/src/config/kinds.ts b/src/config/kinds.ts index ed3cca16..d8e6e9d8 100644 --- a/src/config/kinds.ts +++ b/src/config/kinds.ts @@ -1,9 +1,9 @@ // Nostr event kinds used throughout the application export const KINDS = { - Highlights: 9802, // NIP-?? user highlights + Highlights: 9802, // NIP-84 user highlights BlogPost: 30023, // NIP-23 long-form article AppData: 30078, // NIP-78 application data - ReadingProgress: 39802, // NIP-39802 reading progress + ReadingProgress: 39802, // NIP-85 reading progress List: 30001, // NIP-51 list (addressable) ListReplaceable: 30003, // NIP-51 replaceable list ListSimple: 10003, // NIP-51 simple list diff --git a/src/services/readingDataProcessor.ts b/src/services/readingDataProcessor.ts index bfd97d79..9dfbcc8a 100644 --- a/src/services/readingDataProcessor.ts +++ b/src/services/readingDataProcessor.ts @@ -3,7 +3,7 @@ import { ReadItem } from './readsService' import { fallbackTitleFromUrl } from '../utils/readItemMerge' import { KINDS } from '../config/kinds' -const READING_PROGRESS_KIND = KINDS.ReadingProgress // 39802 +const READING_PROGRESS_KIND = KINDS.ReadingProgress // 39802 - NIP-85 interface ReadArticle { id: string @@ -37,13 +37,13 @@ export function processReadingProgress( const content = JSON.parse(event.content) const position = content.progress || 0 - // Validate progress is between 0 and 1 (NIP-39802 requirement) + // Validate progress is between 0 and 1 (NIP-85 requirement) if (position < 0 || position > 1) { console.warn('Invalid progress value (must be 0-1):', position, 'event:', event.id.slice(0, 8)) continue } - // Use event.created_at as authoritative timestamp (NIP-39802 spec) + // Use event.created_at as authoritative timestamp (NIP-85 spec) const timestamp = event.created_at let itemId: string diff --git a/src/services/readingPositionService.ts b/src/services/readingPositionService.ts index 718d9869..5ea0e49a 100644 --- a/src/services/readingPositionService.ts +++ b/src/services/readingPositionService.ts @@ -7,7 +7,7 @@ import { publishEvent } from './writeService' import { RELAYS } from '../config/relays' import { KINDS } from '../config/kinds' -const READING_PROGRESS_KIND = KINDS.ReadingProgress // 39802 - NIP-39802 Reading Progress +const READING_PROGRESS_KIND = KINDS.ReadingProgress // 39802 - NIP-85 Reading Progress export interface ReadingPosition { position: number // 0-1 scroll progress From 9b6b14cfe8ba6c0a931352a9da5b4fee1f42f338 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 10:46:44 +0200 Subject: [PATCH 16/42] refactor: remove client tag from reading progress events - Remove 'client' tag from NIP-85 specification - Remove 'client' tag from code implementation - Align with Nostr principles of client-agnostic data - Follow NIP-84 pattern which doesn't include client tags Events should be client-agnostic and not include branding/tracking. --- public/md/NIP-85.md | 7 ++----- src/services/readingPositionService.ts | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/public/md/NIP-85.md b/public/md/NIP-85.md index 8bef6c5a..efe918c9 100644 --- a/public/md/NIP-85.md +++ b/public/md/NIP-85.md @@ -35,7 +35,6 @@ Reading progress events use NIP-33 parameterized replaceable semantics. The `d` - `a` (optional but recommended for Nostr articles): Article coordinate `30023::` - `r` (optional but recommended for URLs): Raw URL of the external content - Clients SHOULD clean URLs from tracking parameters and non-essential query strings before tagging -- `client` (optional): Client application identifier ### Content @@ -69,8 +68,7 @@ The content is a JSON object with the following fields: "content": "{\"progress\":0.66,\"loc\":1432,\"ts\":1734635012,\"ver\":\"1\"}", "tags": [ ["d", "30023::"], - ["a", "30023::"], - ["client", "boris"] + ["a", "30023::"] ], "id": "", "sig": "" @@ -87,8 +85,7 @@ The content is a JSON object with the following fields: "content": "{\"progress\":1,\"ts\":1734635999,\"ver\":\"1\"}", "tags": [ ["d", "url:aHR0cHM6Ly9leGFtcGxlLmNvbS9wb3N0"], - ["r", "https://example.com/post"], - ["client", "boris"] + ["r", "https://example.com/post"] ], "id": "", "sig": "" diff --git a/src/services/readingPositionService.ts b/src/services/readingPositionService.ts index 5ea0e49a..6613c6ff 100644 --- a/src/services/readingPositionService.ts +++ b/src/services/readingPositionService.ts @@ -66,7 +66,7 @@ function generateDTag(naddrOrUrl: string): string { // Generate tags for kind 39802 event function generateProgressTags(naddrOrUrl: string): string[][] { const dTag = generateDTag(naddrOrUrl) - const tags: string[][] = [['d', dTag], ['client', 'boris']] + const tags: string[][] = [['d', dTag]] // Add 'a' tag for nostr articles if (naddrOrUrl.startsWith('naddr1')) { From c0638851c600795461eaff9893317a8a7b241121 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 10:54:31 +0200 Subject: [PATCH 17/42] docs: simplify NIP-85 to match NIP-84 style and length - Remove verbose rationale section - Remove excessive querying examples - Remove privacy considerations (obvious) - Remove implementation notes fluff - Remove references section - Keep only essential: format, tags, content, examples - Match NIP-84's concise, to-the-point style From 190 lines down to ~75 lines - much more readable --- public/md/NIP-85.md | 142 +++++--------------------------------------- 1 file changed, 14 insertions(+), 128 deletions(-) diff --git a/public/md/NIP-85.md b/public/md/NIP-85.md index efe918c9..84573816 100644 --- a/public/md/NIP-85.md +++ b/public/md/NIP-85.md @@ -8,33 +8,26 @@ This NIP defines kind `39802`, a parameterized replaceable event for tracking re ## Table of Contents -* [Event Kind](#event-kind) -* [Event Structure](#event-structure) +* [Format](#format) * [Tags](#tags) * [Content](#content) - * [Semantics](#semantics) * [Examples](#examples) -* [Querying](#querying) -* [Privacy Considerations](#privacy-considerations) -* [Rationale](#rationale) -* [Implementation Notes](#implementation-notes) -## Event Kind - -- `39802`: Reading Progress (Parameterized Replaceable) - -## Event Structure +## Format Reading progress events use NIP-33 parameterized replaceable semantics. The `d` tag serves as the unique identifier per author and target content. ### Tags +Events SHOULD tag the source of the reading progress, whether nostr-native or not. `a` tags should be used for nostr events and `r` tags for URLs. + +When tagging a URL, clients generating these events SHOULD do a best effort of cleaning the URL from trackers or obvious non-useful information from the query string. + - `d` (required): Unique identifier for the target content - For Nostr articles: `30023::` (matching the article's coordinate) - For external URLs: `url:` - `a` (optional but recommended for Nostr articles): Article coordinate `30023::` - `r` (optional but recommended for URLs): Raw URL of the external content - - Clients SHOULD clean URLs from tracking parameters and non-essential query strings before tagging ### Content @@ -42,23 +35,16 @@ The content is a JSON object with the following fields: - `progress` (required): Number between 0 and 1 representing reading progress (0 = not started, 1 = completed) - `loc` (optional): Number representing a location marker (e.g., pixel scroll position, page number, etc.) -- `ts` (optional): Unix timestamp (seconds) when the progress was recorded. This is for display purposes only; event ordering MUST use `created_at` -- `ver` (optional): Schema version string (e.g., "1") +- `ts` (optional): Unix timestamp (seconds) when the progress was recorded +- `ver` (optional): Schema version string -### Semantics +The latest event by `created_at` per (`pubkey`, `d`) pair is authoritative (NIP-33 semantics). -- The latest event by `created_at` per (`pubkey`, `d`) pair is authoritative (NIP-33 semantics) -- Clients SHOULD implement rate limiting to avoid excessive relay traffic: - - Debounce writes (recommended: 5 seconds) - - Only save when progress changes significantly (recommended: ≥1% delta) - - Skip saving very early progress (recommended: <5%) - - Always save on completion (progress = 1) and when unmounting/closing content -- The `created_at` timestamp SHOULD match the time the progress was observed -- Event ordering and replaceability MUST use `created_at`, not the optional `ts` field in content +Clients SHOULD implement rate limiting to avoid excessive relay traffic (debounce writes, only save significant changes). ## Examples -### Nostr Article Progress +### Nostr Article ```json { @@ -69,13 +55,11 @@ The content is a JSON object with the following fields: "tags": [ ["d", "30023::"], ["a", "30023::"] - ], - "id": "", - "sig": "" + ] } ``` -### External URL Progress +### External URL ```json { @@ -86,104 +70,6 @@ The content is a JSON object with the following fields: "tags": [ ["d", "url:aHR0cHM6Ly9leGFtcGxlLmNvbS9wb3N0"], ["r", "https://example.com/post"] - ], - "id": "", - "sig": "" + ] } ``` - -## Querying - -### All progress for a user - -```json -{ - "kinds": [39802], - "authors": [""] -} -``` - -### Progress for a specific Nostr article - -```json -{ - "kinds": [39802], - "authors": [""], - "#d": ["30023::"] -} -``` - -Or using the `a` tag: - -```json -{ - "kinds": [39802], - "authors": [""], - "#a": ["30023::"] -} -``` - -### Progress for a specific URL - -```json -{ - "kinds": [39802], - "authors": [""], - "#r": ["https://example.com/post"] -} -``` - -## Privacy Considerations - -Reading progress events are public by default to enable interoperability between clients. Users concerned about privacy should: - -- Use clients that allow disabling progress sync -- Use clients that allow selective relay publishing -- Be aware that reading progress reveals their reading habits - -A future extension could define an encrypted variant for private progress tracking, but that is out of scope for this NIP. - -## Rationale - -### Why a dedicated kind instead of NIP-78 application data? - -While NIP-78 (kind 30078) can store arbitrary application data, a dedicated kind offers several advantages: - -1. **Discoverability**: Other clients can easily find and display reading progress without knowing application-specific `d` tag conventions -2. **Interoperability**: Standard schema enables cross-client compatibility -3. **Indexing**: Relays can efficiently index and query reading progress separately from other app data -4. **Semantics**: Clear, well-defined meaning for the event kind - -### Why parameterized replaceable (NIP-33)? - -- Each article/URL needs exactly one current progress value per user -- Automatic deduplication by relays reduces storage and bandwidth -- Simple last-write-wins semantics based on `created_at` -- Efficient querying by `d` tag - -### Why include both `d` and `a`/`r` tags? - -- `d` provides the unique key for replaceability -- `a` and `r` enable efficient filtering without parsing `d` values -- Redundancy improves relay compatibility and query flexibility - -## Implementation Notes - -- Clients SHOULD use the event's `created_at` as the authoritative timestamp for sorting and merging progress -- The optional `ts` field in content is for display purposes only (e.g., "Last read 2 hours ago") -- For URLs, the base64url encoding in the `d` tag MUST use URL-safe characters (replace `+` with `-`, `/` with `_`, remove padding `=`) -- Clients SHOULD validate that `progress` is between 0 and 1 - -### URL Handling - -When generating events for external URLs: - -- Clients SHOULD clean URLs by removing tracking parameters (e.g., `utm_*`, `fbclid`, etc.) and other non-essential query strings -- The cleaned URL should be used for both the `r` tag and the base64url encoding in the `d` tag -- This ensures that the same article from different sources (with different tracking params) maps to the same reading progress event - -## References - -- [NIP-01: Basic protocol flow](https://github.com/nostr-protocol/nips/blob/master/01.md) -- [NIP-33: Parameterized Replaceable Events](https://github.com/nostr-protocol/nips/blob/master/33.md) - From 80b26abff2674e8a0781f1b41df61c3bf528e9a2 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 11:02:20 +0200 Subject: [PATCH 18/42] feat: add reading progress indicators to blog post cards - Add reading progress loading and display in Explore component - Add reading progress loading and display in Profile component - Add reading progress loading and display in Me writings tab - Reading progress now shows as colored progress bar in all blog post cards - Progress colors: gray (started 0-10%), blue (reading 10-95%), green (completed 95%+) --- src/components/Explore.tsx | 60 ++++++++++++++++++++++++++++++++++++ src/components/Me.tsx | 60 ++++++++++++++++++++++++++++++++++++ src/components/Profile.tsx | 63 +++++++++++++++++++++++++++++++++++++- 3 files changed, 182 insertions(+), 1 deletion(-) diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index a6abf245..428feb54 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -30,6 +30,10 @@ import { useStoreTimeline } from '../hooks/useStoreTimeline' import { dedupeHighlightsById, dedupeWritingsByReplaceable } from '../utils/dedupe' import { writingsController } from '../services/writingsController' import { nostrverseWritingsController } from '../services/nostrverseWritingsController' +import { queryEvents } from '../services/dataFetch' +import { processReadingProgress } from '../services/readingDataProcessor' +import { ReadItem } from '../services/readsService' +import { RELAYS } from '../config/relays' const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers @@ -59,6 +63,9 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti const [myHighlights, setMyHighlights] = useState([]) // Remove unused loading state to avoid warnings + // Reading progress state (naddr -> progress 0-1) + const [readingProgressMap, setReadingProgressMap] = useState>(new Map()) + // Load cached content from event store (instant display) const cachedHighlights = useStoreTimeline(eventStore, { kinds: [KINDS.Highlights] }, eventToHighlight, []) @@ -169,6 +176,41 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti return () => unsub() }, []) + + // Load reading progress data + useEffect(() => { + if (!activeAccount?.pubkey) { + setReadingProgressMap(new Map()) + return + } + + const loadReadingProgress = async () => { + try { + const progressEvents = await queryEvents( + relayPool, + { kinds: [KINDS.ReadingProgress], authors: [activeAccount.pubkey] }, + { relayUrls: RELAYS } + ) + + const readsMap = new Map() + processReadingProgress(progressEvents, readsMap) + + // Convert to naddr -> progress map + const progressMap = new Map() + for (const [id, item] of readsMap.entries()) { + if (item.readingProgress !== undefined && item.type === 'article') { + progressMap.set(id, item.readingProgress) + } + } + + setReadingProgressMap(progressMap) + } catch (err) { + console.error('Failed to load reading progress:', err) + } + } + + loadReadingProgress() + }, [activeAccount?.pubkey, relayPool, refreshTrigger]) // Update visibility when settings/login state changes useEffect(() => { @@ -571,6 +613,23 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti return { ...post, level } }) }, [uniqueSortedPosts, activeAccount, followedPubkeys, visibility]) + + // Helper to get reading progress for a post + const getReadingProgress = useCallback((post: BlogPostPreview): number | undefined => { + const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] + if (!dTag) return undefined + + try { + const naddr = nip19.naddrEncode({ + kind: 30023, + pubkey: post.author, + identifier: dTag + }) + return readingProgressMap.get(naddr) + } catch (err) { + return undefined + } + }, [readingProgressMap]) const renderTabContent = () => { switch (activeTab) { @@ -596,6 +655,7 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti post={post} href={getPostUrl(post)} level={post.level} + readingProgress={getReadingProgress(post)} /> ))} diff --git a/src/components/Me.tsx b/src/components/Me.tsx index 297eac44..394f5068 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -31,6 +31,10 @@ import { filterByReadingProgress } from '../utils/readingProgressUtils' import { deriveReadsFromBookmarks } from '../utils/readsFromBookmarks' import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks' import { mergeReadItem } from '../utils/readItemMerge' +import { queryEvents } from '../services/dataFetch' +import { processReadingProgress } from '../services/readingDataProcessor' +import { RELAYS } from '../config/relays' +import { KINDS } from '../config/kinds' interface MeProps { relayPool: RelayPool @@ -93,6 +97,9 @@ const Me: React.FC = ({ ? (urlFilter as ReadingProgressFilterType) : 'all' const [readingProgressFilter, setReadingProgressFilter] = useState(initialFilter) + + // Reading progress state for writings tab (naddr -> progress 0-1) + const [readingProgressMap, setReadingProgressMap] = useState>(new Map()) // Subscribe to highlights controller useEffect(() => { @@ -148,6 +155,41 @@ const Me: React.FC = ({ } } } + + // Load reading progress data for writings tab + useEffect(() => { + if (!viewingPubkey) { + setReadingProgressMap(new Map()) + return + } + + const loadReadingProgress = async () => { + try { + const progressEvents = await queryEvents( + relayPool, + { kinds: [KINDS.ReadingProgress], authors: [viewingPubkey] }, + { relayUrls: RELAYS } + ) + + const readsMap = new Map() + processReadingProgress(progressEvents, readsMap) + + // Convert to naddr -> progress map + const progressMap = new Map() + for (const [id, item] of readsMap.entries()) { + if (item.readingProgress !== undefined && item.type === 'article') { + progressMap.set(id, item.readingProgress) + } + } + + setReadingProgressMap(progressMap) + } catch (err) { + console.error('Failed to load reading progress:', err) + } + } + + loadReadingProgress() + }, [viewingPubkey, relayPool, refreshTrigger]) // Tab-specific loading functions const loadHighlightsTab = async () => { @@ -422,6 +464,23 @@ const Me: React.FC = ({ navigate(`/r/${encodeURIComponent(url)}`) } } + + // Helper to get reading progress for a post + const getWritingReadingProgress = (post: BlogPostPreview): number | undefined => { + const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] + if (!dTag) return undefined + + try { + const naddr = nip19.naddrEncode({ + kind: 30023, + pubkey: post.author, + identifier: dTag + }) + return readingProgressMap.get(naddr) + } catch (err) { + return undefined + } + } // Merge and flatten all individual bookmarks const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || []) @@ -658,6 +717,7 @@ const Me: React.FC = ({ key={post.event.id} post={post} href={getPostUrl(post)} + readingProgress={getWritingReadingProgress(post)} /> ))} diff --git a/src/components/Profile.tsx b/src/components/Profile.tsx index 2d366740..9503c7cf 100644 --- a/src/components/Profile.tsx +++ b/src/components/Profile.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useCallback } from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faHighlighter, faPenToSquare } from '@fortawesome/free-solid-svg-icons' import { IEventStore } from 'applesauce-core' @@ -18,6 +18,10 @@ import { eventToHighlight } from '../services/highlightEventProcessor' import { toBlogPostPreview } from '../utils/toBlogPostPreview' import { usePullToRefresh } from 'use-pull-to-refresh' import RefreshIndicator from './RefreshIndicator' +import { Hooks } from 'applesauce-react' +import { queryEvents } from '../services/dataFetch' +import { processReadingProgress } from '../services/readingDataProcessor' +import { ReadItem } from '../services/readsService' interface ProfileProps { relayPool: RelayPool @@ -33,9 +37,13 @@ const Profile: React.FC = ({ activeTab: propActiveTab }) => { const navigate = useNavigate() + const activeAccount = Hooks.useActiveAccount() const [activeTab, setActiveTab] = useState<'highlights' | 'writings'>(propActiveTab || 'highlights') const [refreshTrigger, setRefreshTrigger] = useState(0) + // Reading progress state (naddr -> progress 0-1) + const [readingProgressMap, setReadingProgressMap] = useState>(new Map()) + // Load cached data from event store instantly const cachedHighlights = useStoreTimeline( eventStore, @@ -57,6 +65,41 @@ const Profile: React.FC = ({ setActiveTab(propActiveTab) } }, [propActiveTab]) + + // Load reading progress data for logged-in user + useEffect(() => { + if (!activeAccount?.pubkey) { + setReadingProgressMap(new Map()) + return + } + + const loadReadingProgress = async () => { + try { + const progressEvents = await queryEvents( + relayPool, + { kinds: [KINDS.ReadingProgress], authors: [activeAccount.pubkey] }, + { relayUrls: RELAYS } + ) + + const readsMap = new Map() + processReadingProgress(progressEvents, readsMap) + + // Convert to naddr -> progress map + const progressMap = new Map() + for (const [id, item] of readsMap.entries()) { + if (item.readingProgress !== undefined && item.type === 'article') { + progressMap.set(id, item.readingProgress) + } + } + + setReadingProgressMap(progressMap) + } catch (err) { + console.error('Failed to load reading progress:', err) + } + } + + loadReadingProgress() + }, [activeAccount?.pubkey, relayPool, refreshTrigger]) // Background fetch to populate event store (non-blocking) useEffect(() => { @@ -103,6 +146,23 @@ const Profile: React.FC = ({ }) return `/a/${naddr}` } + + // Helper to get reading progress for a post + const getReadingProgress = useCallback((post: BlogPostPreview): number | undefined => { + const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] + if (!dTag) return undefined + + try { + const naddr = nip19.naddrEncode({ + kind: 30023, + pubkey: post.author, + identifier: dTag + }) + return readingProgressMap.get(naddr) + } catch (err) { + return undefined + } + }, [readingProgressMap]) const handleHighlightDelete = () => { // Not allowed to delete other users' highlights @@ -162,6 +222,7 @@ const Profile: React.FC = ({ key={post.event.id} post={post} href={getPostUrl(post)} + readingProgress={getReadingProgress(post)} /> ))} From 5fd8976097536bda08ef8e73dcdc2f755491003f Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 11:06:57 +0200 Subject: [PATCH 19/42] refactor: create centralized reading progress controller - Add readingProgressController following the same pattern as highlightsController and writingsController - Controller manages reading progress (kind:39802) centrally with subscriptions - Remove duplicated reading progress loading logic from Explore, Profile, and Me components - Components now subscribe to controller updates instead of loading data individually - Supports incremental sync and force reload - Improves efficiency and maintainability --- src/components/Explore.tsx | 55 +++--- src/components/Me.tsx | 53 ++---- src/components/Profile.tsx | 54 +++--- src/services/readingProgressController.ts | 221 ++++++++++++++++++++++ 4 files changed, 286 insertions(+), 97 deletions(-) create mode 100644 src/services/readingProgressController.ts diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index 428feb54..07115dbd 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -30,10 +30,7 @@ import { useStoreTimeline } from '../hooks/useStoreTimeline' import { dedupeHighlightsById, dedupeWritingsByReplaceable } from '../utils/dedupe' import { writingsController } from '../services/writingsController' import { nostrverseWritingsController } from '../services/nostrverseWritingsController' -import { queryEvents } from '../services/dataFetch' -import { processReadingProgress } from '../services/readingDataProcessor' -import { ReadItem } from '../services/readsService' -import { RELAYS } from '../config/relays' +import { readingProgressController } from '../services/readingProgressController' const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers @@ -177,40 +174,32 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti return () => unsub() }, []) - // Load reading progress data + // Subscribe to reading progress controller + useEffect(() => { + // Get initial state immediately + setReadingProgressMap(readingProgressController.getProgressMap()) + + // Subscribe to updates + const unsubProgress = readingProgressController.onProgress(setReadingProgressMap) + + return () => { + unsubProgress() + } + }, []) + + // Load reading progress data when logged in useEffect(() => { if (!activeAccount?.pubkey) { - setReadingProgressMap(new Map()) return } - const loadReadingProgress = async () => { - try { - const progressEvents = await queryEvents( - relayPool, - { kinds: [KINDS.ReadingProgress], authors: [activeAccount.pubkey] }, - { relayUrls: RELAYS } - ) - - const readsMap = new Map() - processReadingProgress(progressEvents, readsMap) - - // Convert to naddr -> progress map - const progressMap = new Map() - for (const [id, item] of readsMap.entries()) { - if (item.readingProgress !== undefined && item.type === 'article') { - progressMap.set(id, item.readingProgress) - } - } - - setReadingProgressMap(progressMap) - } catch (err) { - console.error('Failed to load reading progress:', err) - } - } - - loadReadingProgress() - }, [activeAccount?.pubkey, relayPool, refreshTrigger]) + readingProgressController.start({ + relayPool, + eventStore, + pubkey: activeAccount.pubkey, + force: refreshTrigger > 0 + }) + }, [activeAccount?.pubkey, relayPool, eventStore, refreshTrigger]) // Update visibility when settings/login state changes useEffect(() => { diff --git a/src/components/Me.tsx b/src/components/Me.tsx index 394f5068..9d709b89 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -31,10 +31,7 @@ import { filterByReadingProgress } from '../utils/readingProgressUtils' import { deriveReadsFromBookmarks } from '../utils/readsFromBookmarks' import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks' import { mergeReadItem } from '../utils/readItemMerge' -import { queryEvents } from '../services/dataFetch' -import { processReadingProgress } from '../services/readingDataProcessor' -import { RELAYS } from '../config/relays' -import { KINDS } from '../config/kinds' +import { readingProgressController } from '../services/readingProgressController' interface MeProps { relayPool: RelayPool @@ -156,40 +153,32 @@ const Me: React.FC = ({ } } + // Subscribe to reading progress controller + useEffect(() => { + // Get initial state immediately + setReadingProgressMap(readingProgressController.getProgressMap()) + + // Subscribe to updates + const unsubProgress = readingProgressController.onProgress(setReadingProgressMap) + + return () => { + unsubProgress() + } + }, []) + // Load reading progress data for writings tab useEffect(() => { if (!viewingPubkey) { - setReadingProgressMap(new Map()) return } - const loadReadingProgress = async () => { - try { - const progressEvents = await queryEvents( - relayPool, - { kinds: [KINDS.ReadingProgress], authors: [viewingPubkey] }, - { relayUrls: RELAYS } - ) - - const readsMap = new Map() - processReadingProgress(progressEvents, readsMap) - - // Convert to naddr -> progress map - const progressMap = new Map() - for (const [id, item] of readsMap.entries()) { - if (item.readingProgress !== undefined && item.type === 'article') { - progressMap.set(id, item.readingProgress) - } - } - - setReadingProgressMap(progressMap) - } catch (err) { - console.error('Failed to load reading progress:', err) - } - } - - loadReadingProgress() - }, [viewingPubkey, relayPool, refreshTrigger]) + readingProgressController.start({ + relayPool, + eventStore, + pubkey: viewingPubkey, + force: refreshTrigger > 0 + }) + }, [viewingPubkey, relayPool, eventStore, refreshTrigger]) // Tab-specific loading functions const loadHighlightsTab = async () => { diff --git a/src/components/Profile.tsx b/src/components/Profile.tsx index 9503c7cf..671ac89f 100644 --- a/src/components/Profile.tsx +++ b/src/components/Profile.tsx @@ -19,9 +19,7 @@ import { toBlogPostPreview } from '../utils/toBlogPostPreview' import { usePullToRefresh } from 'use-pull-to-refresh' import RefreshIndicator from './RefreshIndicator' import { Hooks } from 'applesauce-react' -import { queryEvents } from '../services/dataFetch' -import { processReadingProgress } from '../services/readingDataProcessor' -import { ReadItem } from '../services/readsService' +import { readingProgressController } from '../services/readingProgressController' interface ProfileProps { relayPool: RelayPool @@ -66,40 +64,32 @@ const Profile: React.FC = ({ } }, [propActiveTab]) - // Load reading progress data for logged-in user + // Subscribe to reading progress controller + useEffect(() => { + // Get initial state immediately + setReadingProgressMap(readingProgressController.getProgressMap()) + + // Subscribe to updates + const unsubProgress = readingProgressController.onProgress(setReadingProgressMap) + + return () => { + unsubProgress() + } + }, []) + + // Load reading progress data when logged in useEffect(() => { if (!activeAccount?.pubkey) { - setReadingProgressMap(new Map()) return } - const loadReadingProgress = async () => { - try { - const progressEvents = await queryEvents( - relayPool, - { kinds: [KINDS.ReadingProgress], authors: [activeAccount.pubkey] }, - { relayUrls: RELAYS } - ) - - const readsMap = new Map() - processReadingProgress(progressEvents, readsMap) - - // Convert to naddr -> progress map - const progressMap = new Map() - for (const [id, item] of readsMap.entries()) { - if (item.readingProgress !== undefined && item.type === 'article') { - progressMap.set(id, item.readingProgress) - } - } - - setReadingProgressMap(progressMap) - } catch (err) { - console.error('Failed to load reading progress:', err) - } - } - - loadReadingProgress() - }, [activeAccount?.pubkey, relayPool, refreshTrigger]) + readingProgressController.start({ + relayPool, + eventStore, + pubkey: activeAccount.pubkey, + force: refreshTrigger > 0 + }) + }, [activeAccount?.pubkey, relayPool, eventStore, refreshTrigger]) // Background fetch to populate event store (non-blocking) useEffect(() => { diff --git a/src/services/readingProgressController.ts b/src/services/readingProgressController.ts new file mode 100644 index 00000000..495d374b --- /dev/null +++ b/src/services/readingProgressController.ts @@ -0,0 +1,221 @@ +import { RelayPool } from 'applesauce-relay' +import { IEventStore } from 'applesauce-core' +import { queryEvents } from './dataFetch' +import { KINDS } from '../config/kinds' +import { RELAYS } from '../config/relays' +import { processReadingProgress } from './readingDataProcessor' +import { ReadItem } from './readsService' + +type ProgressMapCallback = (progressMap: Map) => void +type LoadingCallback = (loading: boolean) => void + +const LAST_SYNCED_KEY = 'reading_progress_last_synced' + +/** + * Shared reading progress controller + * Manages the user's reading progress (kind:39802) centrally + */ +class ReadingProgressController { + private progressListeners: ProgressMapCallback[] = [] + private loadingListeners: LoadingCallback[] = [] + + private currentProgressMap: Map = new Map() + private lastLoadedPubkey: string | null = null + private generation = 0 + + onProgress(cb: ProgressMapCallback): () => void { + this.progressListeners.push(cb) + return () => { + this.progressListeners = this.progressListeners.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 emitProgress(progressMap: Map): void { + this.progressListeners.forEach(cb => cb(new Map(progressMap))) + } + + /** + * Get current reading progress map without triggering a reload + */ + getProgressMap(): Map { + return new Map(this.currentProgressMap) + } + + /** + * Get progress for a specific article by naddr + */ + getProgress(naddr: string): number | undefined { + return this.currentProgressMap.get(naddr) + } + + /** + * Check if reading progress is loaded for a specific pubkey + */ + isLoadedFor(pubkey: string): boolean { + return this.lastLoadedPubkey === pubkey + } + + /** + * Reset state (for logout or manual refresh) + */ + reset(): void { + this.generation++ + this.currentProgressMap = new Map() + this.lastLoadedPubkey = null + this.emitProgress(this.currentProgressMap) + } + + /** + * Get last synced timestamp for incremental loading + */ + private getLastSyncedAt(pubkey: string): number | null { + try { + const data = localStorage.getItem(LAST_SYNCED_KEY) + if (!data) return null + const parsed = JSON.parse(data) + return parsed[pubkey] || null + } catch { + return null + } + } + + /** + * Update last synced timestamp + */ + private updateLastSyncedAt(pubkey: string, timestamp: number): void { + try { + const data = localStorage.getItem(LAST_SYNCED_KEY) + const parsed = data ? JSON.parse(data) : {} + parsed[pubkey] = timestamp + localStorage.setItem(LAST_SYNCED_KEY, JSON.stringify(parsed)) + } catch (err) { + console.warn('Failed to update last synced timestamp:', err) + } + } + + /** + * Load and watch reading progress for a user + */ + async start(params: { + relayPool: RelayPool + eventStore: IEventStore + pubkey: string + force?: boolean + }): Promise { + const { relayPool, eventStore, pubkey, force = false } = params + const startGeneration = this.generation + + // Skip if already loaded for this pubkey and not forcing + if (!force && this.isLoadedFor(pubkey)) { + console.log('📊 [ReadingProgress] Already loaded for', pubkey.slice(0, 8)) + return + } + + console.log('📊 [ReadingProgress] Loading for', pubkey.slice(0, 8), force ? '(forced)' : '') + + this.setLoading(true) + this.lastLoadedPubkey = pubkey + + try { + // Load from event store first for instant display + const cachedEvents = await eventStore.list([ + { kinds: [KINDS.ReadingProgress], authors: [pubkey] } + ]) + + if (startGeneration !== this.generation) { + console.log('📊 [ReadingProgress] Cancelled (generation changed)') + return + } + + if (cachedEvents.length > 0) { + this.processEvents(cachedEvents) + console.log('📊 [ReadingProgress] Loaded', cachedEvents.length, 'from cache') + } + + // Fetch from relays (incremental or full) + const lastSynced = force ? null : this.getLastSyncedAt(pubkey) + const filter: any = { + kinds: [KINDS.ReadingProgress], + authors: [pubkey] + } + + if (lastSynced && !force) { + filter.since = lastSynced + console.log('📊 [ReadingProgress] Incremental sync since', new Date(lastSynced * 1000).toISOString()) + } + + const events = await queryEvents(relayPool, filter, { relayUrls: RELAYS }) + + if (startGeneration !== this.generation) { + console.log('📊 [ReadingProgress] Cancelled (generation changed)') + return + } + + if (events.length > 0) { + // Add to event store + events.forEach(e => eventStore.add(e)) + + // Process and emit + this.processEvents(events) + console.log('📊 [ReadingProgress] Loaded', events.length, 'from relays') + + // Update last synced + const now = Math.floor(Date.now() / 1000) + this.updateLastSyncedAt(pubkey, now) + } else { + console.log('📊 [ReadingProgress] No new progress events') + } + } catch (err) { + console.error('📊 [ReadingProgress] Failed to load:', err) + } finally { + if (startGeneration === this.generation) { + this.setLoading(false) + } + } + } + + /** + * Process events and update progress map + */ + private processEvents(events: any[]): void { + const readsMap = new Map() + + // Merge with existing progress + for (const [id, progress] of this.currentProgressMap.entries()) { + readsMap.set(id, { + id, + source: 'reading-progress', + type: 'article', + readingProgress: progress + }) + } + + // Process new events + processReadingProgress(events, readsMap) + + // Convert back to progress map (naddr -> progress) + const newProgressMap = new Map() + for (const [id, item] of readsMap.entries()) { + if (item.readingProgress !== undefined && item.type === 'article') { + newProgressMap.set(id, item.readingProgress) + } + } + + this.currentProgressMap = newProgressMap + this.emitProgress(this.currentProgressMap) + } +} + +export const readingProgressController = new ReadingProgressController() + From 50a4161e16789d1fe26bb88b7a2e83d636313259 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 11:08:33 +0200 Subject: [PATCH 20/42] feat: reset reading progress controller on logout - Add readingProgressController.reset() to handleLogout in App.tsx - Ensures reading progress data is cleared when user logs out - Consistent with other controllers (bookmarks, contacts, highlights) --- src/App.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/App.tsx b/src/App.tsx index c55ecf74..16bebc79 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,6 +24,7 @@ import { bookmarkController } from './services/bookmarkController' import { contactsController } from './services/contactsController' import { highlightsController } from './services/highlightsController' import { writingsController } from './services/writingsController' +import { readingProgressController } from './services/readingProgressController' // import { fetchNostrverseHighlights } from './services/nostrverseService' import { nostrverseHighlightsController } from './services/nostrverseHighlightsController' import { nostrverseWritingsController } from './services/nostrverseWritingsController' @@ -145,6 +146,7 @@ function AppRoutes({ bookmarkController.reset() // Clear bookmarks via controller contactsController.reset() // Clear contacts via controller highlightsController.reset() // Clear highlights via controller + readingProgressController.reset() // Clear reading progress via controller showToast('Logged out successfully') } From 2a7fffd5944094e00eace5d45994352da023aa72 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 11:18:21 +0200 Subject: [PATCH 21/42] fix: remove invalid eventStore.list() call in reading progress controller - EventStore doesn't have a list() method - Follow same pattern as highlightsController and just fetch from relays - Fixes TypeError: eventStore.list is not a function --- src/services/readingProgressController.ts | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/src/services/readingProgressController.ts b/src/services/readingProgressController.ts index 495d374b..5569aa39 100644 --- a/src/services/readingProgressController.ts +++ b/src/services/readingProgressController.ts @@ -128,21 +128,6 @@ class ReadingProgressController { this.lastLoadedPubkey = pubkey try { - // Load from event store first for instant display - const cachedEvents = await eventStore.list([ - { kinds: [KINDS.ReadingProgress], authors: [pubkey] } - ]) - - if (startGeneration !== this.generation) { - console.log('📊 [ReadingProgress] Cancelled (generation changed)') - return - } - - if (cachedEvents.length > 0) { - this.processEvents(cachedEvents) - console.log('📊 [ReadingProgress] Loaded', cachedEvents.length, 'from cache') - } - // Fetch from relays (incremental or full) const lastSynced = force ? null : this.getLastSyncedAt(pubkey) const filter: any = { @@ -168,13 +153,13 @@ class ReadingProgressController { // Process and emit this.processEvents(events) - console.log('📊 [ReadingProgress] Loaded', events.length, 'from relays') + console.log('📊 [ReadingProgress] Loaded', events.length, 'events') // Update last synced const now = Math.floor(Date.now() / 1000) this.updateLastSyncedAt(pubkey, now) } else { - console.log('📊 [ReadingProgress] No new progress events') + console.log('📊 [ReadingProgress] No progress events found') } } catch (err) { console.error('📊 [ReadingProgress] Failed to load:', err) From bff43f4a288a4b83c5b590f1584f100602e6c78c Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 11:30:57 +0200 Subject: [PATCH 22/42] debug: add comprehensive [progress] logging throughout reading progress flow - Add logs in readingProgressController: processing events, emitting to listeners - Add logs in Explore component: receiving updates, looking up progress - Add logs in BlogPostCard: rendering with progress - Add detailed logs in processReadingProgress: event parsing, naddr conversion - All logs prefixed with [progress] for easy filtering --- src/components/BlogPostCard.tsx | 5 ++++ src/components/Explore.tsx | 19 ++++++++++--- src/services/readingDataProcessor.ts | 34 ++++++++++++++++++----- src/services/readingProgressController.ts | 10 +++++++ 4 files changed, 57 insertions(+), 11 deletions(-) diff --git a/src/components/BlogPostCard.tsx b/src/components/BlogPostCard.tsx index d40d496a..bf317522 100644 --- a/src/components/BlogPostCard.tsx +++ b/src/components/BlogPostCard.tsx @@ -33,6 +33,11 @@ const BlogPostCard: React.FC = ({ post, href, level, readingP } else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) { progressColor = 'var(--color-text)' // Neutral text color (started) } + + // Debug log + if (readingProgress !== undefined) { + console.log('[progress] 🎴 Card render:', post.title.slice(0, 30), '=> progress:', progressPercent + '%', 'color:', progressColor) + } return ( = ({ relayPool, eventStore, settings, acti // Subscribe to reading progress controller useEffect(() => { // Get initial state immediately - setReadingProgressMap(readingProgressController.getProgressMap()) + const initialMap = readingProgressController.getProgressMap() + console.log('[progress] 🎯 Explore: Initial progress map size:', initialMap.size) + setReadingProgressMap(initialMap) // Subscribe to updates - const unsubProgress = readingProgressController.onProgress(setReadingProgressMap) + const unsubProgress = readingProgressController.onProgress((newMap) => { + console.log('[progress] 🎯 Explore: Received progress update, size:', newMap.size) + setReadingProgressMap(newMap) + }) return () => { unsubProgress() @@ -606,7 +611,10 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti // Helper to get reading progress for a post const getReadingProgress = useCallback((post: BlogPostPreview): number | undefined => { const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] - if (!dTag) return undefined + if (!dTag) { + console.log('[progress] ⚠️ No d-tag for post:', post.title) + return undefined + } try { const naddr = nip19.naddrEncode({ @@ -614,8 +622,11 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti pubkey: post.author, identifier: dTag }) - return readingProgressMap.get(naddr) + const progress = readingProgressMap.get(naddr) + console.log('[progress] 🔍 Looking up:', naddr.slice(0, 50) + '... =>', progress ? Math.round(progress * 100) + '%' : 'not found') + return progress } catch (err) { + console.error('[progress] ❌ Error encoding naddr:', err) return undefined } }, [readingProgressMap]) diff --git a/src/services/readingDataProcessor.ts b/src/services/readingDataProcessor.ts index 9dfbcc8a..7d1e3c8d 100644 --- a/src/services/readingDataProcessor.ts +++ b/src/services/readingDataProcessor.ts @@ -27,19 +27,31 @@ export function processReadingProgress( events: NostrEvent[], readsMap: Map ): void { + console.log('[progress] 🔧 processReadingProgress called with', events.length, 'events') + for (const event of events) { - if (event.kind !== READING_PROGRESS_KIND) continue + if (event.kind !== READING_PROGRESS_KIND) { + console.log('[progress] ⏭️ Skipping event with wrong kind:', event.kind) + continue + } const dTag = event.tags.find(t => t[0] === 'd')?.[1] - if (!dTag) continue + if (!dTag) { + console.log('[progress] ⚠️ Event missing d-tag:', event.id.slice(0, 8)) + continue + } + + console.log('[progress] 📝 Processing event:', event.id.slice(0, 8), 'd-tag:', dTag.slice(0, 50)) try { const content = JSON.parse(event.content) const position = content.progress || 0 + console.log('[progress] 📊 Progress value:', position, '(' + Math.round(position * 100) + '%)') + // Validate progress is between 0 and 1 (NIP-85 requirement) if (position < 0 || position > 1) { - console.warn('Invalid progress value (must be 0-1):', position, 'event:', event.id.slice(0, 8)) + console.warn('[progress] ❌ Invalid progress value (must be 0-1):', position, 'event:', event.id.slice(0, 8)) continue } @@ -64,11 +76,13 @@ export function processReadingProgress( }) itemId = naddr itemType = 'article' + console.log('[progress] ✅ Converted coordinate to naddr:', naddr.slice(0, 50)) } catch (e) { - console.warn('Failed to encode naddr from coordinate:', dTag) + console.warn('[progress] ❌ Failed to encode naddr from coordinate:', dTag) continue } } else { + console.warn('[progress] ⚠️ Invalid coordinate format:', dTag) continue } } else if (dTag.startsWith('url:')) { @@ -78,12 +92,13 @@ export function processReadingProgress( itemUrl = atob(encoded.replace(/-/g, '+').replace(/_/g, '/')) itemId = itemUrl itemType = 'external' + console.log('[progress] ✅ Decoded URL:', itemUrl.slice(0, 50)) } catch (e) { - console.warn('Failed to decode URL from d tag:', dTag) + console.warn('[progress] ❌ Failed to decode URL from d tag:', dTag) continue } } else { - // Unknown format, skip + console.warn('[progress] ⚠️ Unknown d-tag format:', dTag) continue } @@ -99,11 +114,16 @@ export function processReadingProgress( readingProgress: position, readingTimestamp: timestamp }) + console.log('[progress] ✅ Added/updated item in readsMap:', itemId.slice(0, 50), '=', Math.round(position * 100) + '%') + } else { + console.log('[progress] ⏭️ Skipping older event for:', itemId.slice(0, 50)) } } catch (error) { - console.warn('Failed to parse reading progress event:', error) + console.warn('[progress] ❌ Failed to parse reading progress event:', error) } } + + console.log('[progress] 🏁 processReadingProgress finished, readsMap size:', readsMap.size) } /** diff --git a/src/services/readingProgressController.ts b/src/services/readingProgressController.ts index 5569aa39..85c12f4a 100644 --- a/src/services/readingProgressController.ts +++ b/src/services/readingProgressController.ts @@ -42,6 +42,7 @@ class ReadingProgressController { } private emitProgress(progressMap: Map): void { + console.log('[progress] 📡 Emitting to', this.progressListeners.length, 'listeners with', progressMap.size, 'items') this.progressListeners.forEach(cb => cb(new Map(progressMap))) } @@ -174,6 +175,8 @@ class ReadingProgressController { * Process events and update progress map */ private processEvents(events: any[]): void { + console.log('[progress] 🔄 Processing', events.length, 'events') + const readsMap = new Map() // Merge with existing progress @@ -186,17 +189,24 @@ class ReadingProgressController { }) } + console.log('[progress] 📦 Starting with', readsMap.size, 'existing items') + // Process new events processReadingProgress(events, readsMap) + console.log('[progress] 📦 After processing:', readsMap.size, 'items') + // Convert back to progress map (naddr -> progress) const newProgressMap = new Map() for (const [id, item] of readsMap.entries()) { if (item.readingProgress !== undefined && item.type === 'article') { newProgressMap.set(id, item.readingProgress) + console.log('[progress] ✅ Added:', id.slice(0, 50) + '...', '=', Math.round(item.readingProgress * 100) + '%') } } + console.log('[progress] 📊 Final progress map size:', newProgressMap.size) + this.currentProgressMap = newProgressMap this.emitProgress(this.currentProgressMap) } From 205879f9483e8ddbb15bf8a8e5b753628ec46141 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 11:39:25 +0200 Subject: [PATCH 23/42] debug: add comprehensive logging for reading position calculation and event publishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add logs in useReadingPosition: scroll position calculation (throttled to 5% changes) - Add logs for scheduling and triggering auto-save - Add detailed logs in ContentPanel handleSavePosition - Add logs in saveReadingPosition: event creation, signing, publishing - Add logs in publishEvent: event store addition, relay status, publishing - All logs prefixed with [progress] for easy filtering - Shows complete flow from scroll → calculate → save → create event → publish to relays --- src/components/ContentPanel.tsx | 18 +++++++++++++----- src/hooks/useReadingPosition.ts | 16 ++++++++++++++++ src/services/readingPositionService.ts | 23 ++++++++++++++++++++--- src/services/writeService.ts | 15 ++++++++++----- 4 files changed, 59 insertions(+), 13 deletions(-) diff --git a/src/components/ContentPanel.tsx b/src/components/ContentPanel.tsx index cb6630d4..c24db06c 100644 --- a/src/components/ContentPanel.tsx +++ b/src/components/ContentPanel.tsx @@ -151,7 +151,7 @@ const ContentPanel: React.FC = ({ // Callback to save reading position const handleSavePosition = useCallback(async (position: number) => { if (!activeAccount || !relayPool || !eventStore || !articleIdentifier) { - console.log('⏭️ [ContentPanel] Skipping save - missing requirements:', { + console.log('[progress] ⏭️ ContentPanel: Skipping save - missing requirements:', { hasAccount: !!activeAccount, hasRelayPool: !!relayPool, hasEventStore: !!eventStore, @@ -160,11 +160,18 @@ const ContentPanel: React.FC = ({ return } if (!settings?.syncReadingPosition) { - console.log('⏭️ [ContentPanel] Sync disabled in settings') + console.log('[progress] ⏭️ ContentPanel: Sync disabled in settings') return } - console.log('💾 [ContentPanel] Saving position:', Math.round(position * 100) + '%', 'for article:', selectedUrl?.slice(0, 50)) + const scrollTop = window.pageYOffset || document.documentElement.scrollTop + console.log('[progress] 💾 ContentPanel: Saving position:', { + position, + percentage: Math.round(position * 100) + '%', + scrollTop, + articleIdentifier: articleIdentifier.slice(0, 50) + '...', + url: selectedUrl?.slice(0, 50) + }) try { const factory = new EventFactory({ signer: activeAccount }) @@ -176,11 +183,12 @@ const ContentPanel: React.FC = ({ { position, timestamp: Math.floor(Date.now() / 1000), - scrollTop: window.pageYOffset || document.documentElement.scrollTop + scrollTop } ) + console.log('[progress] ✅ ContentPanel: Save completed successfully') } catch (error) { - console.error('❌ [ContentPanel] Failed to save reading position:', error) + console.error('[progress] ❌ ContentPanel: Failed to save reading position:', error) } }, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl]) diff --git a/src/hooks/useReadingPosition.ts b/src/hooks/useReadingPosition.ts index 11997ea9..7fa23988 100644 --- a/src/hooks/useReadingPosition.ts +++ b/src/hooks/useReadingPosition.ts @@ -45,7 +45,9 @@ export const useReadingPosition = ({ } // Schedule new save + console.log('[progress] ⏰ Scheduling save in', autoSaveInterval + 'ms for position:', Math.round(currentPosition * 100) + '%') saveTimerRef.current = setTimeout(() => { + console.log('[progress] 💾 Auto-saving position:', Math.round(currentPosition * 100) + '%') lastSavedPosition.current = currentPosition onSave(currentPosition) }, autoSaveInterval) @@ -63,8 +65,11 @@ export const useReadingPosition = ({ // Save if position is meaningful (>= 5%) if (position >= 0.05) { + console.log('[progress] 💾 Immediate save triggered for position:', Math.round(position * 100) + '%') lastSavedPosition.current = position onSave(position) + } else { + console.log('[progress] ⏭️ Skipping save - position too low:', Math.round(position * 100) + '%') } }, [syncEnabled, onSave, position]) @@ -89,6 +94,17 @@ export const useReadingPosition = ({ const isAtBottom = scrollTop + windowHeight >= documentHeight - 5 const clampedProgress = isAtBottom ? 1 : Math.max(0, Math.min(1, scrollProgress)) + // Only log on significant changes (every 5%) to avoid flooding console + const prevPercent = Math.floor(position * 20) // Groups by 5% + const newPercent = Math.floor(clampedProgress * 20) + if (prevPercent !== newPercent) { + console.log('[progress] 📏 useReadingPosition:', Math.round(clampedProgress * 100) + '%', { + scrollTop, + documentHeight, + isAtBottom + }) + } + setPosition(clampedProgress) onPositionChange?.(clampedProgress) diff --git a/src/services/readingPositionService.ts b/src/services/readingPositionService.ts index 6613c6ff..7a09bc94 100644 --- a/src/services/readingPositionService.ts +++ b/src/services/readingPositionService.ts @@ -114,8 +114,8 @@ export async function saveReadingPosition( articleIdentifier: string, position: ReadingPosition ): Promise { - console.log('💾 [ReadingProgress] Saving position:', { - identifier: articleIdentifier.slice(0, 32) + '...', + console.log('[progress] 💾 saveReadingPosition: Starting save:', { + identifier: articleIdentifier.slice(0, 50) + '...', position: position.position, positionPercent: Math.round(position.position * 100) + '%', timestamp: position.timestamp, @@ -133,6 +133,13 @@ export async function saveReadingPosition( const tags = generateProgressTags(articleIdentifier) + console.log('[progress] 📝 Creating event with:', { + kind: READING_PROGRESS_KIND, + content: progressContent, + tags: tags.map(t => `[${t.join(', ')}]`).join(', '), + created_at: now + }) + const draft = await factory.create(async () => ({ kind: READING_PROGRESS_KIND, content: JSON.stringify(progressContent), @@ -140,10 +147,20 @@ export async function saveReadingPosition( created_at: now })) + console.log('[progress] ✍️ Signing event...') const signed = await factory.sign(draft) + + console.log('[progress] 📡 Publishing event:', { + id: signed.id, + kind: signed.kind, + pubkey: signed.pubkey.slice(0, 8) + '...', + content: signed.content, + tags: signed.tags + }) + await publishEvent(relayPool, eventStore, signed) - console.log('✅ [ReadingProgress] Saved, event ID:', signed.id.slice(0, 8)) + console.log('[progress] ✅ Event published successfully, ID:', signed.id.slice(0, 16)) } /** diff --git a/src/services/writeService.ts b/src/services/writeService.ts index d67bc4c3..a0cd4d4e 100644 --- a/src/services/writeService.ts +++ b/src/services/writeService.ts @@ -14,9 +14,12 @@ export async function publishEvent( eventStore: IEventStore, event: NostrEvent ): Promise { + const isProgressEvent = event.kind === 39802 + const logPrefix = isProgressEvent ? '[progress]' : '' + // Store the event in the local EventStore FIRST for immediate UI display eventStore.add(event) - console.log('💾 Stored event in EventStore:', event.id.slice(0, 8), `(kind ${event.kind})`) + console.log(`${logPrefix} 💾 Stored event in EventStore:`, event.id.slice(0, 8), `(kind ${event.kind})`) // Check current connection status - are we online or in flight mode? const connectedRelays = Array.from(relayPool.relays.values()) @@ -32,12 +35,13 @@ export async function publishEvent( const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays) - console.log('📍 Event relay status:', { + console.log(`${logPrefix} 📍 Event relay status:`, { targetRelays: RELAYS.length, expectedSuccessRelays: expectedSuccessRelays.length, isLocalOnly, hasRemoteConnection, - eventId: event.id.slice(0, 8) + eventId: event.id.slice(0, 8), + connectedRelays: connectedRelays.length }) // If we're in local-only mode, mark this event for later sync @@ -46,12 +50,13 @@ export async function publishEvent( } // Publish to all configured relays in the background (non-blocking) + console.log(`${logPrefix} 📤 Publishing to relays:`, RELAYS) relayPool.publish(RELAYS, event) .then(() => { - console.log('✅ Event published to', RELAYS.length, 'relay(s):', event.id.slice(0, 8)) + console.log(`${logPrefix} ✅ Event published to`, RELAYS.length, 'relay(s):', event.id.slice(0, 8)) }) .catch((error) => { - console.warn('⚠️ Failed to publish event to relays (event still saved locally):', error) + console.warn(`${logPrefix} ⚠️ Failed to publish event to relays (event still saved locally):`, error) // Surface common bunker signing errors for debugging if (error instanceof Error && error.message.includes('permission')) { From 3a10ac8691ceeff4bfa4bb2718828b2521997c7d Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 11:41:38 +0200 Subject: [PATCH 24/42] debug: add logs to show why reading position saves are skipped - Log when scheduleSave returns early (syncEnabled false, no onSave callback) - Log when position is too low (<5%) - Log when change is not significant enough (<1%) - Log ContentPanel sync status (enabled, settings, requirements) - This will help diagnose why no events are being created --- src/components/ContentPanel.tsx | 15 ++++++++++++++- src/hooks/useReadingPosition.ts | 19 ++++++++++++++++--- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/components/ContentPanel.tsx b/src/components/ContentPanel.tsx index c24db06c..22a89933 100644 --- a/src/components/ContentPanel.tsx +++ b/src/components/ContentPanel.tsx @@ -199,11 +199,24 @@ const ContentPanel: React.FC = ({ onReadingComplete: () => { // Auto-mark as read when reading is complete (if enabled in settings) if (activeAccount && !isMarkedAsRead && settings?.autoMarkAsReadOnCompletion) { - console.log('📖 [ContentPanel] Auto-marking as read on completion') + console.log('[progress] 📖 Auto-marking as read on completion') handleMarkAsRead() } } }) + + // Log sync status when it changes + useEffect(() => { + console.log('[progress] 📊 ContentPanel reading position sync status:', { + enabled: isTextContent, + syncEnabled: settings?.syncReadingPosition, + hasAccount: !!activeAccount, + hasRelayPool: !!relayPool, + hasEventStore: !!eventStore, + hasArticleIdentifier: !!articleIdentifier, + currentProgress: progressPercentage + '%' + }) + }, [isTextContent, settings?.syncReadingPosition, activeAccount, relayPool, eventStore, articleIdentifier, progressPercentage]) // Load saved reading position when article loads useEffect(() => { diff --git a/src/hooks/useReadingPosition.ts b/src/hooks/useReadingPosition.ts index 7fa23988..bb635089 100644 --- a/src/hooks/useReadingPosition.ts +++ b/src/hooks/useReadingPosition.ts @@ -27,17 +27,30 @@ export const useReadingPosition = ({ // Debounced save function const scheduleSave = useCallback((currentPosition: number) => { - if (!syncEnabled || !onSave) return + if (!syncEnabled || !onSave) { + console.log('[progress] ⏭️ scheduleSave skipped:', { syncEnabled, hasOnSave: !!onSave, position: Math.round(currentPosition * 100) + '%' }) + return + } // Don't save if position is too low (< 5%) - if (currentPosition < 0.05) return + if (currentPosition < 0.05) { + console.log('[progress] ⏭️ Position too low to save:', Math.round(currentPosition * 100) + '%') + return + } // Don't save if position hasn't changed significantly (less than 1%) // But always save if we've reached 100% (completion) const hasSignificantChange = Math.abs(currentPosition - lastSavedPosition.current) >= 0.01 const hasReachedCompletion = currentPosition === 1 && lastSavedPosition.current < 1 - if (!hasSignificantChange && !hasReachedCompletion) return + if (!hasSignificantChange && !hasReachedCompletion) { + console.log('[progress] ⏭️ No significant change:', { + current: Math.round(currentPosition * 100) + '%', + last: Math.round(lastSavedPosition.current * 100) + '%', + diff: Math.abs(currentPosition - lastSavedPosition.current) + }) + return + } // Clear existing timer if (saveTimerRef.current) { From 7c511de47479cc2aecdf84e5800756e1bd420bea Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 11:52:05 +0200 Subject: [PATCH 25/42] feat: enable reading position sync by default - Changed syncReadingPosition default from false to true in Settings.tsx - Users can still disable it in settings if they prefer - This ensures reading progress tracking works out of the box --- src/components/Settings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index f3001a06..97024b07 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -39,7 +39,7 @@ const DEFAULT_SETTINGS: UserSettings = { useLocalRelayAsCache: true, rebroadcastToAllRelays: false, paragraphAlignment: 'justify', - syncReadingPosition: false, + syncReadingPosition: true, } interface SettingsProps { From 14fce2c3dc3b50b8f0edc1d03b173a2d5e5f737f Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 11:56:27 +0200 Subject: [PATCH 26/42] debug: add detailed naddr comparison logs - Show all map keys when looking up reading progress - Show d-tag generation from naddr in save flow - This will help identify if naddr encoding/decoding is causing mismatch --- src/components/Explore.tsx | 6 +++++- src/services/readingPositionService.ts | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index f890a012..069fdc36 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -623,7 +623,11 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti identifier: dTag }) const progress = readingProgressMap.get(naddr) - console.log('[progress] 🔍 Looking up:', naddr.slice(0, 50) + '... =>', progress ? Math.round(progress * 100) + '%' : 'not found') + console.log('[progress] 🔍 Looking up:', { + naddr: naddr.slice(0, 80), + mapKeys: Array.from(readingProgressMap.keys()).map(k => k.slice(0, 80)), + progress: progress ? Math.round(progress * 100) + '%' : 'not found' + }) return progress } catch (err) { console.error('[progress] ❌ Error encoding naddr:', err) diff --git a/src/services/readingPositionService.ts b/src/services/readingPositionService.ts index 7a09bc94..f1774a54 100644 --- a/src/services/readingPositionService.ts +++ b/src/services/readingPositionService.ts @@ -48,7 +48,12 @@ function generateDTag(naddrOrUrl: string): string { try { const decoded = nip19.decode(naddrOrUrl) if (decoded.type === 'naddr') { - return `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier || ''}` + const dTag = `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier || ''}` + console.log('[progress] 📋 Generated d-tag from naddr:', { + naddr: naddrOrUrl.slice(0, 50) + '...', + dTag: dTag.slice(0, 80) + '...' + }) + return dTag } } catch (e) { console.warn('Failed to decode naddr:', naddrOrUrl) From 616038a23ac694e824b92a55a3522e0f1e198bde Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 11:59:37 +0200 Subject: [PATCH 27/42] debug: reduce log spam and show map size in lookups - Only log when progress found or map is empty - Show map size to quickly diagnose empty map issue - Show first 3 map keys as sample instead of all --- src/components/Explore.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index 069fdc36..65ac6c19 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -623,11 +623,17 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti identifier: dTag }) const progress = readingProgressMap.get(naddr) - console.log('[progress] 🔍 Looking up:', { - naddr: naddr.slice(0, 80), - mapKeys: Array.from(readingProgressMap.keys()).map(k => k.slice(0, 80)), - progress: progress ? Math.round(progress * 100) + '%' : 'not found' - }) + + // Only log first lookup to avoid spam, or when found + if (progress || readingProgressMap.size === 0) { + console.log('[progress] 🔍 Looking up:', { + title: post.title.slice(0, 30), + naddr: naddr.slice(0, 80), + mapSize: readingProgressMap.size, + mapKeys: readingProgressMap.size > 0 ? Array.from(readingProgressMap.keys()).slice(0, 3).map(k => k.slice(0, 80)) : [], + progress: progress ? Math.round(progress * 100) + '%' : 'not found' + }) + } return progress } catch (err) { console.error('[progress] ❌ Error encoding naddr:', err) From 5d14d25d0edc71fc18efd86de4e5422c98bd3855 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 12:02:22 +0200 Subject: [PATCH 28/42] debug: add detailed logging to Profile component - Show initial map size and updates in Profile - Log lookups with map contents in Profile - Helps debug reading progress on profile pages --- src/components/Profile.tsx | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/components/Profile.tsx b/src/components/Profile.tsx index 671ac89f..2a86a827 100644 --- a/src/components/Profile.tsx +++ b/src/components/Profile.tsx @@ -67,10 +67,15 @@ const Profile: React.FC = ({ // Subscribe to reading progress controller useEffect(() => { // Get initial state immediately - setReadingProgressMap(readingProgressController.getProgressMap()) + const initialMap = readingProgressController.getProgressMap() + console.log('[progress] 🎯 Profile: Initial progress map size:', initialMap.size) + setReadingProgressMap(initialMap) // Subscribe to updates - const unsubProgress = readingProgressController.onProgress(setReadingProgressMap) + const unsubProgress = readingProgressController.onProgress((newMap) => { + console.log('[progress] 🎯 Profile: Received progress update, size:', newMap.size) + setReadingProgressMap(newMap) + }) return () => { unsubProgress() @@ -148,7 +153,19 @@ const Profile: React.FC = ({ pubkey: post.author, identifier: dTag }) - return readingProgressMap.get(naddr) + const progress = readingProgressMap.get(naddr) + + // Only log when found or map is empty + if (progress || readingProgressMap.size === 0) { + console.log('[progress] 🔍 Profile lookup:', { + title: post.title?.slice(0, 30), + naddr: naddr.slice(0, 80), + mapSize: readingProgressMap.size, + mapKeys: readingProgressMap.size > 0 ? Array.from(readingProgressMap.keys()).slice(0, 3).map(k => k.slice(0, 80)) : [], + progress: progress ? Math.round(progress * 100) + '%' : 'not found' + }) + } + return progress } catch (err) { return undefined } From 1ba375e93e66c5931b6c3d79571b65dd91aa9448 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 12:03:36 +0200 Subject: [PATCH 29/42] fix: load reading progress from event store first (non-blocking) - Query local event store immediately for instant display - Then augment with relay data in background - This matches how bookmarks work: local-first, then sync - Events saved locally now appear immediately without waiting for relay propagation --- src/services/readingProgressController.ts | 32 ++++++++++++++++------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/src/services/readingProgressController.ts b/src/services/readingProgressController.ts index 85c12f4a..a169cf80 100644 --- a/src/services/readingProgressController.ts +++ b/src/services/readingProgressController.ts @@ -129,7 +129,19 @@ class ReadingProgressController { this.lastLoadedPubkey = pubkey try { - // Fetch from relays (incremental or full) + // 1. First, load from local event store (instant, non-blocking) + const localEvents = eventStore.getEvents({ + kinds: [KINDS.ReadingProgress], + authors: [pubkey] + }) + + console.log('📊 [ReadingProgress] Found', localEvents.length, 'events in local store') + + if (localEvents.length > 0) { + this.processEvents(localEvents) + } + + // 2. Then fetch from relays (incremental or full) to augment local data const lastSynced = force ? null : this.getLastSyncedAt(pubkey) const filter: any = { kinds: [KINDS.ReadingProgress], @@ -138,29 +150,31 @@ class ReadingProgressController { if (lastSynced && !force) { filter.since = lastSynced - console.log('📊 [ReadingProgress] Incremental sync since', new Date(lastSynced * 1000).toISOString()) + console.log('📊 [ReadingProgress] Incremental sync from relays since', new Date(lastSynced * 1000).toISOString()) + } else { + console.log('📊 [ReadingProgress] Full sync from relays') } - const events = await queryEvents(relayPool, filter, { relayUrls: RELAYS }) + const relayEvents = await queryEvents(relayPool, filter, { relayUrls: RELAYS }) if (startGeneration !== this.generation) { console.log('📊 [ReadingProgress] Cancelled (generation changed)') return } - if (events.length > 0) { + if (relayEvents.length > 0) { // Add to event store - events.forEach(e => eventStore.add(e)) + relayEvents.forEach(e => eventStore.add(e)) - // Process and emit - this.processEvents(events) - console.log('📊 [ReadingProgress] Loaded', events.length, 'events') + // Process and emit (merge with existing) + this.processEvents(relayEvents) + console.log('📊 [ReadingProgress] Loaded', relayEvents.length, 'events from relays') // Update last synced const now = Math.floor(Date.now() / 1000) this.updateLastSyncedAt(pubkey, now) } else { - console.log('📊 [ReadingProgress] No progress events found') + console.log('📊 [ReadingProgress] No new events from relays') } } catch (err) { console.error('📊 [ReadingProgress] Failed to load:', err) From 0e98ddeef4e64b2a86a2959423055bb8f3e1e314 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 12:04:49 +0200 Subject: [PATCH 30/42] fix: use eventStore.timeline() to query local events - Subscribe to timeline to get initial cached events - Unsubscribe immediately after reading initial value - This works with IEventStore interface correctly --- src/services/readingProgressController.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/services/readingProgressController.ts b/src/services/readingProgressController.ts index a169cf80..cb191105 100644 --- a/src/services/readingProgressController.ts +++ b/src/services/readingProgressController.ts @@ -129,17 +129,22 @@ class ReadingProgressController { this.lastLoadedPubkey = pubkey try { - // 1. First, load from local event store (instant, non-blocking) - const localEvents = eventStore.getEvents({ + // 1. First, get events from local event store timeline (instant, non-blocking) + const timeline = eventStore.timeline({ kinds: [KINDS.ReadingProgress], authors: [pubkey] }) - console.log('📊 [ReadingProgress] Found', localEvents.length, 'events in local store') + // Subscribe to get initial events synchronously + const subscription = timeline.subscribe((localEvents) => { + console.log('📊 [ReadingProgress] Found', localEvents.length, 'events in local store') + if (localEvents.length > 0) { + this.processEvents(localEvents) + } + }) - if (localEvents.length > 0) { - this.processEvents(localEvents) - } + // Unsubscribe immediately after getting initial value + subscription.unsubscribe() // 2. Then fetch from relays (incremental or full) to augment local data const lastSynced = force ? null : this.getLastSyncedAt(pubkey) From f3a83256a83b2c86115e7d759aed70de201cad90 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 12:10:53 +0200 Subject: [PATCH 31/42] debug: improve timeline subscription and add more logs - Capture events from timeline before unsubscribing - Add log to show when timeline emits - Add log after unsubscribe to show what we got - This will help debug why processEvents isn't being called --- src/services/readingProgressController.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/services/readingProgressController.ts b/src/services/readingProgressController.ts index cb191105..b995427e 100644 --- a/src/services/readingProgressController.ts +++ b/src/services/readingProgressController.ts @@ -135,17 +135,22 @@ class ReadingProgressController { authors: [pubkey] }) - // Subscribe to get initial events synchronously - const subscription = timeline.subscribe((localEvents) => { - console.log('📊 [ReadingProgress] Found', localEvents.length, 'events in local store') - if (localEvents.length > 0) { - this.processEvents(localEvents) - } + // Get the latest value from timeline - it should emit immediately + let localEvents: any[] = [] + const subscription = timeline.subscribe((events) => { + localEvents = events + console.log('📊 [ReadingProgress] Timeline emitted', events.length, 'events') }) - // Unsubscribe immediately after getting initial value + // Unsubscribe after getting value subscription.unsubscribe() + console.log('📊 [ReadingProgress] Processing', localEvents.length, 'events from local store') + + if (localEvents.length > 0) { + this.processEvents(localEvents) + } + // 2. Then fetch from relays (incremental or full) to augment local data const lastSynced = force ? null : this.getLastSyncedAt(pubkey) const filter: any = { From 16b3668e737ad2eaa80f3b4ba4f447663dc80fcc Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 12:13:45 +0200 Subject: [PATCH 32/42] debug: add logs to trace why events aren't processed - Log sample event to see format - Log map size after processEvents to see if it worked - This will show if processEvents is failing silently --- src/services/readingProgressController.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/services/readingProgressController.ts b/src/services/readingProgressController.ts index b995427e..9ac1002e 100644 --- a/src/services/readingProgressController.ts +++ b/src/services/readingProgressController.ts @@ -146,9 +146,11 @@ class ReadingProgressController { subscription.unsubscribe() console.log('📊 [ReadingProgress] Processing', localEvents.length, 'events from local store') + console.log('📊 [ReadingProgress] Sample event:', localEvents[0] ? { kind: localEvents[0].kind, author: localEvents[0].pubkey?.slice(0, 8), tags: localEvents[0].tags } : 'none') if (localEvents.length > 0) { this.processEvents(localEvents) + console.log('📊 [ReadingProgress] After processEvents, map size:', this.currentProgressMap.size) } // 2. Then fetch from relays (incremental or full) to augment local data From 4fac5f42c9951ad17f3f3aa5aab4b8afc52ba569 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 12:17:38 +0200 Subject: [PATCH 33/42] fix: remove broken timeline subscription, rely on queryEvents - Timeline subscription is async and emits empty array first - queryEvents already checks local store then relays - Simpler and actually works correctly - This is how all other controllers work (highlights, bookmarks, etc.) --- src/services/readingProgressController.ts | 27 ++--------------------- 1 file changed, 2 insertions(+), 25 deletions(-) diff --git a/src/services/readingProgressController.ts b/src/services/readingProgressController.ts index 9ac1002e..d0234e42 100644 --- a/src/services/readingProgressController.ts +++ b/src/services/readingProgressController.ts @@ -129,31 +129,8 @@ class ReadingProgressController { this.lastLoadedPubkey = pubkey try { - // 1. First, get events from local event store timeline (instant, non-blocking) - const timeline = eventStore.timeline({ - kinds: [KINDS.ReadingProgress], - authors: [pubkey] - }) - - // Get the latest value from timeline - it should emit immediately - let localEvents: any[] = [] - const subscription = timeline.subscribe((events) => { - localEvents = events - console.log('📊 [ReadingProgress] Timeline emitted', events.length, 'events') - }) - - // Unsubscribe after getting value - subscription.unsubscribe() - - console.log('📊 [ReadingProgress] Processing', localEvents.length, 'events from local store') - console.log('📊 [ReadingProgress] Sample event:', localEvents[0] ? { kind: localEvents[0].kind, author: localEvents[0].pubkey?.slice(0, 8), tags: localEvents[0].tags } : 'none') - - if (localEvents.length > 0) { - this.processEvents(localEvents) - console.log('📊 [ReadingProgress] After processEvents, map size:', this.currentProgressMap.size) - } - - // 2. Then fetch from relays (incremental or full) to augment local data + // Query events - this checks both local store AND relays + // The queryEvents function is smart enough to check local first const lastSynced = force ? null : this.getLastSyncedAt(pubkey) const filter: any = { kinds: [KINDS.ReadingProgress], From 914738abb49c2af54205f9324f979ac57d8d90b8 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 12:18:46 +0200 Subject: [PATCH 34/42] fix: force full sync when map is empty - If currentProgressMap is empty, do a full sync (no 'since' filter) - This ensures first load gets all events, not just recent ones - Incremental sync only happens when we already have data - This was the bug: lastSynced was preventing initial load of events --- src/services/readingProgressController.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/services/readingProgressController.ts b/src/services/readingProgressController.ts index d0234e42..c1d0043f 100644 --- a/src/services/readingProgressController.ts +++ b/src/services/readingProgressController.ts @@ -129,19 +129,21 @@ class ReadingProgressController { this.lastLoadedPubkey = pubkey try { - // Query events - this checks both local store AND relays - // The queryEvents function is smart enough to check local first - const lastSynced = force ? null : this.getLastSyncedAt(pubkey) + // Query events from relays + // Force full sync if map is empty (first load) or if explicitly forced + const needsFullSync = force || this.currentProgressMap.size === 0 + const lastSynced = needsFullSync ? null : this.getLastSyncedAt(pubkey) + const filter: any = { kinds: [KINDS.ReadingProgress], authors: [pubkey] } - if (lastSynced && !force) { + if (lastSynced && !needsFullSync) { filter.since = lastSynced - console.log('📊 [ReadingProgress] Incremental sync from relays since', new Date(lastSynced * 1000).toISOString()) + console.log('📊 [ReadingProgress] Incremental sync since', new Date(lastSynced * 1000).toISOString()) } else { - console.log('📊 [ReadingProgress] Full sync from relays') + console.log('📊 [ReadingProgress] Full sync (map size:', this.currentProgressMap.size + ')') } const relayEvents = await queryEvents(relayPool, filter, { relayUrls: RELAYS }) From 0740d53d375bb8c1b23034bcb8d9f429f9380d0c Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 12:27:19 +0200 Subject: [PATCH 35/42] fix: resolve all linter warnings - Add proper types (Filter, NostrEvent) to readingProgressController - Add eslint-disable comment for position dependency in useReadingPosition (position is derived from scroll and including it would cause infinite re-renders) - All lint warnings resolved - TypeScript type checks pass --- src/hooks/useReadingPosition.ts | 2 ++ src/services/readingProgressController.ts | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/hooks/useReadingPosition.ts b/src/hooks/useReadingPosition.ts index bb635089..00531ff0 100644 --- a/src/hooks/useReadingPosition.ts +++ b/src/hooks/useReadingPosition.ts @@ -148,6 +148,8 @@ export const useReadingPosition = ({ clearTimeout(saveTimerRef.current) } } + // position is intentionally not in deps - it's computed from scroll and would cause infinite re-renders + // eslint-disable-next-line react-hooks/exhaustive-deps }, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, scheduleSave]) // Reset reading complete state when enabled changes diff --git a/src/services/readingProgressController.ts b/src/services/readingProgressController.ts index c1d0043f..6fec89f8 100644 --- a/src/services/readingProgressController.ts +++ b/src/services/readingProgressController.ts @@ -1,5 +1,6 @@ import { RelayPool } from 'applesauce-relay' import { IEventStore } from 'applesauce-core' +import { Filter, NostrEvent } from 'nostr-tools' import { queryEvents } from './dataFetch' import { KINDS } from '../config/kinds' import { RELAYS } from '../config/relays' @@ -134,7 +135,7 @@ class ReadingProgressController { const needsFullSync = force || this.currentProgressMap.size === 0 const lastSynced = needsFullSync ? null : this.getLastSyncedAt(pubkey) - const filter: any = { + const filter: Filter = { kinds: [KINDS.ReadingProgress], authors: [pubkey] } @@ -179,7 +180,7 @@ class ReadingProgressController { /** * Process events and update progress map */ - private processEvents(events: any[]): void { + private processEvents(events: NostrEvent[]): void { console.log('[progress] 🔄 Processing', events.length, 'events') const readsMap = new Map() From a7d05a29f5a0da181474839d4e283cc47be88da7 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 12:29:44 +0200 Subject: [PATCH 36/42] feat: process local reading progress via eventStore.timeline() - Subscribe to timeline for immediate local events and reactive updates - Clean up timeline subscription on reset/start to avoid leaks - Keep relay sync for background augmentation - Should populate progress map even without relay roundtrip --- src/services/readingProgressController.ts | 26 +++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/services/readingProgressController.ts b/src/services/readingProgressController.ts index 6fec89f8..34610378 100644 --- a/src/services/readingProgressController.ts +++ b/src/services/readingProgressController.ts @@ -23,6 +23,7 @@ class ReadingProgressController { private currentProgressMap: Map = new Map() private lastLoadedPubkey: string | null = null private generation = 0 + private timelineSubscription: { unsubscribe: () => void } | null = null onProgress(cb: ProgressMapCallback): () => void { this.progressListeners.push(cb) @@ -73,6 +74,11 @@ class ReadingProgressController { */ reset(): void { this.generation++ + // Unsubscribe from any active timeline subscription + if (this.timelineSubscription) { + try { this.timelineSubscription.unsubscribe() } catch {} + this.timelineSubscription = null + } this.currentProgressMap = new Map() this.lastLoadedPubkey = null this.emitProgress(this.currentProgressMap) @@ -130,6 +136,26 @@ class ReadingProgressController { this.lastLoadedPubkey = pubkey try { + // Subscribe to local timeline for immediate and reactive updates + // Clean up any previous subscription first + if (this.timelineSubscription) { + try { this.timelineSubscription.unsubscribe() } catch {} + this.timelineSubscription = null + } + + const timeline$ = eventStore.timeline({ + kinds: [KINDS.ReadingProgress], + authors: [pubkey] + }) + const generationAtSubscribe = this.generation + this.timelineSubscription = timeline$.subscribe((localEvents: NostrEvent[]) => { + // Ignore if controller generation has changed (e.g., logout/login) + if (generationAtSubscribe !== this.generation) return + if (!Array.isArray(localEvents) || localEvents.length === 0) return + console.log('📊 [ReadingProgress] Timeline update with', localEvents.length, 'event(s)') + this.processEvents(localEvents) + }) + // Query events from relays // Force full sync if map is empty (first load) or if explicitly forced const needsFullSync = force || this.currentProgressMap.size === 0 From 5a79da4024b089cf6234daf30bd4c5177a1353cd Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 15:59:01 +0200 Subject: [PATCH 37/42] feat: persist reading progress in localStorage per pubkey - Seed controller state from cache on start for instant display after refresh - Persist updated progress map after processing events - Keeps progress visible even without immediate relay responses --- src/services/readingProgressController.ts | 43 +++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/services/readingProgressController.ts b/src/services/readingProgressController.ts index 34610378..7021b88d 100644 --- a/src/services/readingProgressController.ts +++ b/src/services/readingProgressController.ts @@ -11,6 +11,7 @@ type ProgressMapCallback = (progressMap: Map) => void type LoadingCallback = (loading: boolean) => void const LAST_SYNCED_KEY = 'reading_progress_last_synced' +const PROGRESS_CACHE_KEY = 'reading_progress_cache_v1' /** * Shared reading progress controller @@ -55,6 +56,35 @@ class ReadingProgressController { return new Map(this.currentProgressMap) } + /** + * Load cached progress from localStorage for a pubkey + */ + private loadCachedProgress(pubkey: string): Map { + try { + const raw = localStorage.getItem(PROGRESS_CACHE_KEY) + if (!raw) return new Map() + const parsed = JSON.parse(raw) as Record> + const forUser = parsed[pubkey] || {} + return new Map(Object.entries(forUser)) + } catch { + return new Map() + } + } + + /** + * Save current progress map to localStorage for the active pubkey + */ + private persistProgress(pubkey: string, progressMap: Map): void { + try { + const raw = localStorage.getItem(PROGRESS_CACHE_KEY) + const parsed: Record> = raw ? JSON.parse(raw) : {} + parsed[pubkey] = Object.fromEntries(progressMap.entries()) + localStorage.setItem(PROGRESS_CACHE_KEY, JSON.stringify(parsed)) + } catch (err) { + console.warn('[progress] ⚠️ Failed to persist reading progress cache:', err) + } + } + /** * Get progress for a specific article by naddr */ @@ -136,6 +166,14 @@ class ReadingProgressController { this.lastLoadedPubkey = pubkey try { + // Seed from local cache immediately (survives refresh/flight mode) + const cached = this.loadCachedProgress(pubkey) + if (cached.size > 0) { + console.log('📊 [ReadingProgress] Seeded from cache:', cached.size, 'items') + this.currentProgressMap = cached + this.emitProgress(this.currentProgressMap) + } + // Subscribe to local timeline for immediate and reactive updates // Clean up any previous subscription first if (this.timelineSubscription) { @@ -241,6 +279,11 @@ class ReadingProgressController { this.currentProgressMap = newProgressMap this.emitProgress(this.currentProgressMap) + + // Persist for current user so it survives refresh/flight mode + if (this.lastLoadedPubkey) { + this.persistProgress(this.lastLoadedPubkey, this.currentProgressMap) + } } } From b745a92a7e53eae0a417a8d0b934319c9a963c75 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 16:03:34 +0200 Subject: [PATCH 38/42] feat: allow saving 0% reading position and initial save - Remove low-position guard; allow 0% saves - One-time initial save even without significant change - Always allow immediate save regardless of position - Fix linter empty-catch warnings in readingProgressController --- src/hooks/useReadingPosition.ts | 31 ++++++++++------------- src/services/readingProgressController.ts | 12 +++++++-- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/src/hooks/useReadingPosition.ts b/src/hooks/useReadingPosition.ts index 00531ff0..14a2f606 100644 --- a/src/hooks/useReadingPosition.ts +++ b/src/hooks/useReadingPosition.ts @@ -24,6 +24,7 @@ export const useReadingPosition = ({ const hasTriggeredComplete = useRef(false) const lastSavedPosition = useRef(0) const saveTimerRef = useRef | null>(null) + const hasSavedOnce = useRef(false) // Debounced save function const scheduleSave = useCallback((currentPosition: number) => { @@ -31,23 +32,19 @@ export const useReadingPosition = ({ console.log('[progress] ⏭️ scheduleSave skipped:', { syncEnabled, hasOnSave: !!onSave, position: Math.round(currentPosition * 100) + '%' }) return } - - // Don't save if position is too low (< 5%) - if (currentPosition < 0.05) { - console.log('[progress] ⏭️ Position too low to save:', Math.round(currentPosition * 100) + '%') - return - } - + // Don't save if position hasn't changed significantly (less than 1%) // But always save if we've reached 100% (completion) const hasSignificantChange = Math.abs(currentPosition - lastSavedPosition.current) >= 0.01 const hasReachedCompletion = currentPosition === 1 && lastSavedPosition.current < 1 + const isInitialSave = !hasSavedOnce.current - if (!hasSignificantChange && !hasReachedCompletion) { + if (!hasSignificantChange && !hasReachedCompletion && !isInitialSave) { console.log('[progress] ⏭️ No significant change:', { current: Math.round(currentPosition * 100) + '%', last: Math.round(lastSavedPosition.current * 100) + '%', - diff: Math.abs(currentPosition - lastSavedPosition.current) + diff: Math.abs(currentPosition - lastSavedPosition.current), + isInitialSave }) return } @@ -62,6 +59,7 @@ export const useReadingPosition = ({ saveTimerRef.current = setTimeout(() => { console.log('[progress] 💾 Auto-saving position:', Math.round(currentPosition * 100) + '%') lastSavedPosition.current = currentPosition + hasSavedOnce.current = true onSave(currentPosition) }, autoSaveInterval) }, [syncEnabled, onSave, autoSaveInterval]) @@ -76,14 +74,11 @@ export const useReadingPosition = ({ saveTimerRef.current = null } - // Save if position is meaningful (>= 5%) - if (position >= 0.05) { - console.log('[progress] 💾 Immediate save triggered for position:', Math.round(position * 100) + '%') - lastSavedPosition.current = position - onSave(position) - } else { - console.log('[progress] ⏭️ Skipping save - position too low:', Math.round(position * 100) + '%') - } + // Always allow immediate save (including 0%) + console.log('[progress] 💾 Immediate save triggered for position:', Math.round(position * 100) + '%') + lastSavedPosition.current = position + hasSavedOnce.current = true + onSave(position) }, [syncEnabled, onSave, position]) useEffect(() => { @@ -157,6 +152,8 @@ export const useReadingPosition = ({ if (!enabled) { setIsReadingComplete(false) hasTriggeredComplete.current = false + hasSavedOnce.current = false + lastSavedPosition.current = 0 } }, [enabled]) diff --git a/src/services/readingProgressController.ts b/src/services/readingProgressController.ts index 7021b88d..2527b841 100644 --- a/src/services/readingProgressController.ts +++ b/src/services/readingProgressController.ts @@ -106,7 +106,11 @@ class ReadingProgressController { this.generation++ // Unsubscribe from any active timeline subscription if (this.timelineSubscription) { - try { this.timelineSubscription.unsubscribe() } catch {} + try { + this.timelineSubscription.unsubscribe() + } catch (err) { + console.warn('[progress] ⚠️ Failed to unsubscribe timeline on reset:', err) + } this.timelineSubscription = null } this.currentProgressMap = new Map() @@ -177,7 +181,11 @@ class ReadingProgressController { // Subscribe to local timeline for immediate and reactive updates // Clean up any previous subscription first if (this.timelineSubscription) { - try { this.timelineSubscription.unsubscribe() } catch {} + try { + this.timelineSubscription.unsubscribe() + } catch (err) { + console.warn('[progress] ⚠️ Failed to unsubscribe previous timeline:', err) + } this.timelineSubscription = null } From 87e46be86f6390efac74dfa9e3db6200ef4ca30f Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 16:07:59 +0200 Subject: [PATCH 39/42] feat(settings): restore 'auto mark as read at 100%' option - Added autoMarkAsReadOnCompletion to default settings (disabled by default) - Added toggle in Layout & Behavior section - Existing ContentPanel logic already hooks into this to trigger animation & mark-as-read --- src/components/Settings.tsx | 1 + src/components/Settings/LayoutBehaviorSettings.tsx | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 97024b07..45039dde 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -40,6 +40,7 @@ const DEFAULT_SETTINGS: UserSettings = { rebroadcastToAllRelays: false, paragraphAlignment: 'justify', syncReadingPosition: true, + autoMarkAsReadOnCompletion: false, } interface SettingsProps { diff --git a/src/components/Settings/LayoutBehaviorSettings.tsx b/src/components/Settings/LayoutBehaviorSettings.tsx index efc17384..d042e7cc 100644 --- a/src/components/Settings/LayoutBehaviorSettings.tsx +++ b/src/components/Settings/LayoutBehaviorSettings.tsx @@ -117,6 +117,19 @@ const LayoutBehaviorSettings: React.FC = ({ setting Sync reading position across devices + +
+ +
) } From 9883f2eb1a4a16526c67f59a8fbb15b7774638c1 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 16:13:56 +0200 Subject: [PATCH 40/42] chore(settings): tweak label for auto mark-as-read (remove animation note) --- src/components/Settings/LayoutBehaviorSettings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Settings/LayoutBehaviorSettings.tsx b/src/components/Settings/LayoutBehaviorSettings.tsx index d042e7cc..86cf70ae 100644 --- a/src/components/Settings/LayoutBehaviorSettings.tsx +++ b/src/components/Settings/LayoutBehaviorSettings.tsx @@ -127,7 +127,7 @@ const LayoutBehaviorSettings: React.FC = ({ setting onChange={(e) => onUpdate({ autoMarkAsReadOnCompletion: e.target.checked })} className="setting-checkbox" /> - Automatically mark as read at 100% (with animation) + Automatically mark as read at 100% From 676be1a93252b790dcbfe17836772cedb564ab88 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 16:15:43 +0200 Subject: [PATCH 41/42] feat: make reading position sync default-on in runtime paths - Treat undefined as enabled in ContentPanel (only false disables) - Keeps DEFAULT_SETTINGS at true; ensures consistent behavior even for users without the new setting persisted yet --- src/components/ContentPanel.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/ContentPanel.tsx b/src/components/ContentPanel.tsx index 22a89933..c79bd2f3 100644 --- a/src/components/ContentPanel.tsx +++ b/src/components/ContentPanel.tsx @@ -194,7 +194,7 @@ const ContentPanel: React.FC = ({ const { isReadingComplete, progressPercentage, saveNow } = useReadingPosition({ enabled: isTextContent, - syncEnabled: settings?.syncReadingPosition, + syncEnabled: settings?.syncReadingPosition !== false, onSave: handleSavePosition, onReadingComplete: () => { // Auto-mark as read when reading is complete (if enabled in settings) @@ -209,7 +209,7 @@ const ContentPanel: React.FC = ({ useEffect(() => { console.log('[progress] 📊 ContentPanel reading position sync status:', { enabled: isTextContent, - syncEnabled: settings?.syncReadingPosition, + syncEnabled: settings?.syncReadingPosition !== false, hasAccount: !!activeAccount, hasRelayPool: !!relayPool, hasEventStore: !!eventStore, @@ -230,8 +230,8 @@ const ContentPanel: React.FC = ({ }) return } - if (!settings?.syncReadingPosition) { - console.log('⏭️ [ContentPanel] Sync disabled - not restoring position') + if (settings?.syncReadingPosition === false) { + console.log('⏭️ [ContentPanel] Sync disabled in settings - not restoring position') return } From 31974e7271cc1af3e36d274c73806b9d89e42632 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 16:17:17 +0200 Subject: [PATCH 42/42] feat(reading): 2s completion hold at 100% + reliable auto mark-as-read - Add completionHoldMs (default 2000ms) to useReadingPosition - Start hold timer when position hits 100%; cancel if user scrolls up - Fallback to threshold completion when configured - Clears timers on unmount/disable --- src/hooks/useReadingPosition.ts | 47 ++++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/src/hooks/useReadingPosition.ts b/src/hooks/useReadingPosition.ts index 14a2f606..0d6f7e2b 100644 --- a/src/hooks/useReadingPosition.ts +++ b/src/hooks/useReadingPosition.ts @@ -8,6 +8,7 @@ interface UseReadingPositionOptions { syncEnabled?: boolean // Whether to sync positions to Nostr onSave?: (position: number) => void // Callback for saving position autoSaveInterval?: number // Auto-save interval in ms (default 5000) + completionHoldMs?: number // How long to hold at 100% before firing complete (default 2000) } export const useReadingPosition = ({ @@ -17,7 +18,8 @@ export const useReadingPosition = ({ readingCompleteThreshold = 0.95, // Match filter threshold for consistency syncEnabled = false, onSave, - autoSaveInterval = 5000 + autoSaveInterval = 5000, + completionHoldMs = 2000 }: UseReadingPositionOptions = {}) => { const [position, setPosition] = useState(0) const [isReadingComplete, setIsReadingComplete] = useState(false) @@ -25,6 +27,7 @@ export const useReadingPosition = ({ const lastSavedPosition = useRef(0) const saveTimerRef = useRef | null>(null) const hasSavedOnce = useRef(false) + const completionTimerRef = useRef | null>(null) // Debounced save function const scheduleSave = useCallback((currentPosition: number) => { @@ -119,11 +122,36 @@ export const useReadingPosition = ({ // Schedule auto-save if sync is enabled scheduleSave(clampedProgress) - // Check if reading is complete - if (clampedProgress >= readingCompleteThreshold && !hasTriggeredComplete.current) { - setIsReadingComplete(true) - hasTriggeredComplete.current = true - onReadingComplete?.() + // Completion detection with 2s hold at 100% + if (!hasTriggeredComplete.current) { + // If at exact 100%, start a hold timer; cancel if we scroll up + if (clampedProgress === 1) { + if (!completionTimerRef.current) { + completionTimerRef.current = setTimeout(() => { + if (!hasTriggeredComplete.current && position === 1) { + setIsReadingComplete(true) + hasTriggeredComplete.current = true + console.log('[progress] ✅ Completion hold satisfied (100% for', completionHoldMs, 'ms)') + onReadingComplete?.() + } + completionTimerRef.current = null + }, completionHoldMs) + console.log('[progress] ⏳ Completion hold started (waiting', completionHoldMs, 'ms)') + } + } else { + // If we moved off 100%, cancel any pending completion hold + if (completionTimerRef.current) { + clearTimeout(completionTimerRef.current) + completionTimerRef.current = null + // still allow threshold-based completion for near-bottom if configured + if (clampedProgress >= readingCompleteThreshold) { + setIsReadingComplete(true) + hasTriggeredComplete.current = true + console.log('[progress] ✅ Completion via threshold:', readingCompleteThreshold) + onReadingComplete?.() + } + } + } } } @@ -142,6 +170,9 @@ export const useReadingPosition = ({ if (saveTimerRef.current) { clearTimeout(saveTimerRef.current) } + if (completionTimerRef.current) { + clearTimeout(completionTimerRef.current) + } } // position is intentionally not in deps - it's computed from scroll and would cause infinite re-renders // eslint-disable-next-line react-hooks/exhaustive-deps @@ -154,6 +185,10 @@ export const useReadingPosition = ({ hasTriggeredComplete.current = false hasSavedOnce.current = false lastSavedPosition.current = 0 + if (completionTimerRef.current) { + clearTimeout(completionTimerRef.current) + completionTimerRef.current = null + } } }, [enabled])