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 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", diff --git a/public/md/NIP-85.md b/public/md/NIP-85.md new file mode 100644 index 00000000..84573816 --- /dev/null +++ b/public/md/NIP-85.md @@ -0,0 +1,75 @@ +# NIP-85 + +## Reading Progress + +`draft` `optional` + +This NIP defines kind `39802`, a parameterized replaceable event for tracking reading progress across articles and web content. + +## Table of Contents + +* [Format](#format) + * [Tags](#tags) + * [Content](#content) +* [Examples](#examples) + +## 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 + +### 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 +- `ver` (optional): Schema version string + +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, only save significant changes). + +## Examples + +### Nostr Article + +```json +{ + "kind": 39802, + "pubkey": "", + "created_at": 1734635012, + "content": "{\"progress\":0.66,\"loc\":1432,\"ts\":1734635012,\"ver\":\"1\"}", + "tags": [ + ["d", "30023::"], + ["a", "30023::"] + ] +} +``` + +### External URL + +```json +{ + "kind": 39802, + "pubkey": "", + "created_at": 1734635999, + "content": "{\"progress\":1,\"ts\":1734635999,\"ver\":\"1\"}", + "tags": [ + ["d", "url:aHR0cHM6Ly9leGFtcGxlLmNvbS9wb3N0"], + ["r", "https://example.com/post"] + ] +} +``` diff --git a/src/App.tsx b/src/App.tsx index 57083fde..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' @@ -54,18 +55,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 +95,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 +135,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]) @@ -152,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') } 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 ? : null ) : undefined} profile={showProfile && profilePubkey ? ( - relayPool ? : null + relayPool ? : null ) : undefined} support={showSupport ? ( relayPool ? : null diff --git a/src/components/ContentPanel.tsx b/src/components/ContentPanel.tsx index 28e08ad2..c79bd2f3 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,25 +183,40 @@ 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]) const { isReadingComplete, progressPercentage, saveNow } = useReadingPosition({ enabled: isTextContent, - syncEnabled: settings?.syncReadingPosition, + syncEnabled: settings?.syncReadingPosition !== false, 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('[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 !== false, + 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(() => { @@ -208,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 } diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index a6abf245..65ac6c19 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -30,6 +30,7 @@ import { useStoreTimeline } from '../hooks/useStoreTimeline' import { dedupeHighlightsById, dedupeWritingsByReplaceable } from '../utils/dedupe' import { writingsController } from '../services/writingsController' import { nostrverseWritingsController } from '../services/nostrverseWritingsController' +import { readingProgressController } from '../services/readingProgressController' const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers @@ -59,6 +60,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 +173,38 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti return () => unsub() }, []) + + // Subscribe to reading progress controller + useEffect(() => { + // Get initial state immediately + const initialMap = readingProgressController.getProgressMap() + console.log('[progress] 🎯 Explore: Initial progress map size:', initialMap.size) + setReadingProgressMap(initialMap) + + // Subscribe to updates + const unsubProgress = readingProgressController.onProgress((newMap) => { + console.log('[progress] 🎯 Explore: Received progress update, size:', newMap.size) + setReadingProgressMap(newMap) + }) + + return () => { + unsubProgress() + } + }, []) + + // Load reading progress data when logged in + useEffect(() => { + if (!activeAccount?.pubkey) { + return + } + + readingProgressController.start({ + relayPool, + eventStore, + pubkey: activeAccount.pubkey, + force: refreshTrigger > 0 + }) + }, [activeAccount?.pubkey, relayPool, eventStore, refreshTrigger]) // Update visibility when settings/login state changes useEffect(() => { @@ -571,6 +607,39 @@ 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) { + console.log('[progress] ⚠️ No d-tag for post:', post.title) + return undefined + } + + try { + const naddr = nip19.naddrEncode({ + kind: 30023, + pubkey: post.author, + identifier: dTag + }) + const progress = readingProgressMap.get(naddr) + + // 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) + return undefined + } + }, [readingProgressMap]) const renderTabContent = () => { switch (activeTab) { @@ -596,6 +665,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/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 && ( diff --git a/src/components/Me.tsx b/src/components/Me.tsx index c1af4ab6..9d709b89 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -1,27 +1,24 @@ -import React, { useState, useEffect, useMemo } from 'react' +import React, { useState, useEffect } 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 { 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' 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' @@ -34,17 +31,12 @@ 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 +import { readingProgressController } from '../services/readingProgressController' 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) } @@ -57,8 +49,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() @@ -66,9 +57,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()) @@ -86,30 +76,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 [viewMode, setViewMode] = useState('cards') const [refreshTrigger, setRefreshTrigger] = useState(0) const [bookmarkFilter, setBookmarkFilter] = useState('all') const [groupingMode, setGroupingMode] = useState<'grouped' | 'flat'>(() => { @@ -128,6 +94,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(() => { @@ -183,80 +152,64 @@ 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) { + return + } + + readingProgressController.start({ + relayPool, + eventStore, + pubkey: viewingPubkey, + force: refreshTrigger > 0 + }) + }, [viewingPubkey, relayPool, eventStore, refreshTrigger]) // Tab-specific loading functions const loadHighlightsTab = async () => { if (!viewingPubkey) return - // Only show loading skeleton if tab hasn't been loaded yet - const hasBeenLoaded = loadedTabs.has('highlights') - - 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) - } - - setLoadedTabs(prev => new Set(prev).add('highlights')) - } catch (err) { - console.error('Failed to load highlights:', err) - } finally { - if (!hasBeenLoaded) 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') - try { - if (!hasBeenLoaded) setLoading(true) - - // 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')) - return - } - - // For other profiles, seed with cached writings first - if (cachedWritings.length > 0) { - setWritings(cachedWritings.sort((a, b) => { - const timeA = a.published || a.event.created_at - const timeB = b.published || b.event.created_at - return timeB - timeA - })) - } - - // Fetch fresh writings for other profiles - const userWritings = await fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS) - setWritings(userWritings) + // 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) - } finally { - if (!hasBeenLoaded) setLoading(false) + setLoading(false) } } const loadReadingListTab = async () => { - if (!viewingPubkey || !isOwnProfile || !activeAccount) return + if (!viewingPubkey || !activeAccount) return const hasBeenLoaded = loadedTabs.has('reading-list') @@ -272,7 +225,7 @@ const Me: React.FC = ({ } const loadReadsTab = async () => { - if (!viewingPubkey || !isOwnProfile || !activeAccount) return + if (!viewingPubkey || !activeAccount) return const hasBeenLoaded = loadedTabs.has('reads') @@ -322,7 +275,7 @@ const Me: React.FC = ({ } const loadLinksTab = async () => { - if (!viewingPubkey || !isOwnProfile || !activeAccount) return + if (!viewingPubkey || !activeAccount) return const hasBeenLoaded = loadedTabs.has('links') @@ -368,14 +321,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) @@ -399,19 +350,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]) + setWritings(myWritings) + }, [myWritings]) // Pull-to-refresh - reload active tab without clearing state const { isRefreshing, pullPosition } = usePullToRefresh({ @@ -427,8 +374,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 @@ -506,6 +453,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 || []) @@ -532,7 +496,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) { @@ -546,7 +510,7 @@ const Me: React.FC = ({ ) } - return highlights.length === 0 && !loading && !(isOwnProfile && myHighlightsLoading) ? ( + return highlights.length === 0 && !loading && !myHighlightsLoading ? (
No highlights yet.
@@ -567,9 +531,9 @@ const Me: React.FC = ({ if (showSkeletons) { return (
-
+
{Array.from({ length: 6 }).map((_, i) => ( - + ))}
@@ -595,13 +559,13 @@ const Me: React.FC = ({ sections.filter(s => s.items.length > 0).map(section => (

{section.title}

-
+
{section.items.map((individualBookmark, index) => ( ))} @@ -623,27 +587,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'} - />
) @@ -752,7 +695,7 @@ const Me: React.FC = ({
) } - return writings.length === 0 && !loading && !(isOwnProfile && myWritingsLoading) ? ( + return writings.length === 0 && !loading && !myWritingsLoading ? (
No articles written yet.
@@ -763,6 +706,7 @@ const Me: React.FC = ({ key={post.event.id} post={post} href={getPostUrl(post)} + readingProgress={getWritingReadingProgress(post)} /> ))}
@@ -786,43 +730,39 @@ const Me: React.FC = ({ - {isOwnProfile && ( - <> - - - - - )} + + + + + + + +
+ {renderTabContent()} +
+ + ) +} + +export default Profile + diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index f3001a06..45039dde 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -39,7 +39,8 @@ const DEFAULT_SETTINGS: UserSettings = { useLocalRelayAsCache: true, rebroadcastToAllRelays: false, paragraphAlignment: 'justify', - syncReadingPosition: false, + syncReadingPosition: true, + autoMarkAsReadOnCompletion: false, } interface SettingsProps { diff --git a/src/components/Settings/LayoutBehaviorSettings.tsx b/src/components/Settings/LayoutBehaviorSettings.tsx index efc17384..86cf70ae 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 + +
+ +
) } diff --git a/src/config/kinds.ts b/src/config/kinds.ts index 4221a07d..d8e6e9d8 100644 --- a/src/config/kinds.ts +++ b/src/config/kinds.ts @@ -1,8 +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 (reading positions) + AppData: 30078, // NIP-78 application data + 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/hooks/useReadingPosition.ts b/src/hooks/useReadingPosition.ts index 5530d020..0d6f7e2b 100644 --- a/src/hooks/useReadingPosition.ts +++ b/src/hooks/useReadingPosition.ts @@ -4,40 +4,53 @@ 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) + completionHoldMs?: number // How long to hold at 100% before firing complete (default 2000) } export const useReadingPosition = ({ enabled = true, onPositionChange, onReadingComplete, - readingCompleteThreshold = 0.9, + 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) const hasTriggeredComplete = useRef(false) 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) => { - if (!syncEnabled || !onSave) return - - // Don't save if position is too low (< 5%) - if (currentPosition < 0.05) return - + if (!syncEnabled || !onSave) { + console.log('[progress] ⏭️ scheduleSave skipped:', { syncEnabled, hasOnSave: !!onSave, position: 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) return + 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), + isInitialSave + }) + return + } // Clear existing timer if (saveTimerRef.current) { @@ -45,8 +58,11 @@ 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 + hasSavedOnce.current = true onSave(currentPosition) }, autoSaveInterval) }, [syncEnabled, onSave, autoSaveInterval]) @@ -61,11 +77,11 @@ export const useReadingPosition = ({ saveTimerRef.current = null } - // Save if position is meaningful (>= 5%) - if (position >= 0.05) { - lastSavedPosition.current = position - onSave(position) - } + // 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(() => { @@ -89,17 +105,53 @@ 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) // 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?.() + } + } + } } } @@ -118,7 +170,12 @@ 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 }, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, scheduleSave]) // Reset reading complete state when enabled changes @@ -126,6 +183,12 @@ export const useReadingPosition = ({ if (!enabled) { setIsReadingComplete(false) hasTriggeredComplete.current = false + hasSavedOnce.current = false + lastSavedPosition.current = 0 + if (completionTimerRef.current) { + clearTimeout(completionTimerRef.current) + completionTimerRef.current = null + } } }, [enabled]) 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) 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) => { diff --git a/src/services/linksService.ts b/src/services/linksService.ts index 401cec12..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 { 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:30078) + * - URLs with reading progress (kind:39802) * - Manually marked as read URLs (kind:7, kind:17) */ export async function fetchLinks( @@ -32,18 +32,18 @@ export async function fetchLinks( try { // Fetch all data sources in parallel - const [readingPositionEvents, markedAsReadArticles] = await Promise.all([ - queryEvents(relayPool, { kinds: [KINDS.AppData], authors: [userPubkey] }, { relayUrls: RELAYS }), + const [progressEvents, markedAsReadArticles] = await Promise.all([ + queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [userPubkey] }, { relayUrls: RELAYS }), fetchReadArticles(relayPool, userPubkey) ]) console.log('📊 [Links] Data fetched:', { - readingPositions: readingPositionEvents.length, + readingProgress: progressEvents.length, markedAsRead: markedAsReadArticles.length }) - // Process reading positions and emit external items - processReadingPositions(readingPositionEvents, linksMap) + // Process reading progress events (kind 39802) + processReadingProgress(progressEvents, linksMap) if (onItem) { linksMap.forEach(item => { if (item.type === 'external') { diff --git a/src/services/readingDataProcessor.ts b/src/services/readingDataProcessor.ts index a61c54ef..7d1e3c8d 100644 --- a/src/services/readingDataProcessor.ts +++ b/src/services/readingDataProcessor.ts @@ -1,8 +1,9 @@ -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 - NIP-85 interface ReadArticle { id: string @@ -13,44 +14,95 @@ interface ReadArticle { } /** - * Processes reading position events into ReadItems + * 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 processReadingPositions( +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) { + console.log('[progress] ⏭️ Skipping event with wrong kind:', event.kind) + continue + } + 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, '') + 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 positionData = JSON.parse(event.content) - const position = positionData.position - const timestamp = positionData.timestamp + 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('[progress] ❌ Invalid progress value (must be 0-1):', position, 'event:', event.id.slice(0, 8)) + continue + } + + // Use event.created_at as authoritative timestamp (NIP-85 spec) + const 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) + // 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' + console.log('[progress] ✅ Converted coordinate to naddr:', naddr.slice(0, 50)) + } catch (e) { + 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:')) { + // It's a URL with base64url encoding + const encoded = dTag.replace('url:', '') + try { + itemUrl = atob(encoded.replace(/-/g, '+').replace(/_/g, '/')) + itemId = itemUrl + itemType = 'external' + console.log('[progress] ✅ Decoded URL:', itemUrl.slice(0, 50)) + } catch (e) { + console.warn('[progress] ❌ Failed to decode URL from d tag:', dTag) + continue + } + } else { + console.warn('[progress] ⚠️ Unknown d-tag format:', dTag) + continue } - // Add or update the item + // Add or update the item, preferring newer timestamps const existing = readsMap.get(itemId) if (!existing || !existing.readingTimestamp || timestamp > existing.readingTimestamp) { readsMap.set(itemId, { @@ -62,11 +114,16 @@ export function processReadingPositions( 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 position:', 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/readingPositionService.ts b/src/services/readingPositionService.ts index 1d645c13..f1774a54 100644 --- a/src/services/readingPositionService.ts +++ b/src/services/readingPositionService.ts @@ -1,13 +1,13 @@ 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 READING_PROGRESS_KIND = KINDS.ReadingProgress // 39802 - NIP-85 Reading Progress export interface ReadingPosition { position: number // 0-1 scroll progress @@ -15,16 +15,83 @@ export interface ReadingPosition { scrollTop?: number // Optional: pixel position } -// Helper to extract and parse reading position from an event -function getReadingPositionContent(event: NostrEvent): ReadingPosition | undefined { +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 progress from event (kind 39802) +function getReadingProgressContent(event: NostrEvent): ReadingPosition | undefined { if (!event.content || event.content.length === 0) return undefined try { - return JSON.parse(event.content) as ReadingPosition + 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 +// 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')) { + try { + const decoded = nip19.decode(naddrOrUrl) + if (decoded.type === 'naddr') { + 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) + } + } + + // 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]] + + // 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,7 +110,7 @@ export function generateArticleIdentifier(naddrOrUrl: string): string { } /** - * Save reading position to Nostr (Kind 30078) + * Save reading position to Nostr (kind 39802) */ export async function saveReadingPosition( relayPool: RelayPool, @@ -52,36 +119,57 @@ export async function saveReadingPosition( articleIdentifier: string, position: ReadingPosition ): Promise { - console.log('💾 [ReadingPosition] 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, scrollTop: position.scrollTop }) - const dTag = `${READING_POSITION_PREFIX}${articleIdentifier}` + const now = Math.floor(Date.now() / 1000) + const progressContent: ReadingProgressContent = { + progress: position.position, + ts: position.timestamp, + loc: position.scrollTop, + ver: '1' + } + + 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: APP_DATA_KIND, - content: JSON.stringify(position), - tags: [ - ['d', dTag], - ['client', 'boris'] - ], - created_at: Math.floor(Date.now() / 1000) + kind: READING_PROGRESS_KIND, + content: JSON.stringify(progressContent), + tags, + created_at: now })) + console.log('[progress] ✍️ Signing event...') const signed = await factory.sign(draft) - - // Use unified write service + + 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('✅ [ReadingPosition] Position saved successfully, event ID:', signed.id.slice(0, 8)) + + console.log('[progress] ✅ Event published successfully, ID:', signed.id.slice(0, 16)) } /** - * Load reading position from Nostr + * Load reading position from Nostr (kind 39802) */ export async function loadReadingPosition( relayPool: RelayPool, @@ -89,32 +177,32 @@ export async function loadReadingPosition( pubkey: string, articleIdentifier: string ): Promise { - const dTag = `${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) + '...', dTag: dTag.slice(0, 50) + '...' }) - // First, check if we already have the position in the local event store + // Check local event store first try { const localEvent = await firstValueFrom( - eventStore.replaceable(APP_DATA_KIND, pubkey, dTag) + eventStore.replaceable(READING_PROGRESS_KIND, pubkey, dTag) ) if (localEvent) { - const content = getReadingPositionContent(localEvent) + const content = getReadingProgressContent(localEvent) if (content) { - console.log('✅ [ReadingPosition] Loaded from local store:', { + console.log('✅ [ReadingProgress] Loaded 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 to get any updates relayPool .subscription(RELAYS, { - kinds: [APP_DATA_KIND], + kinds: [READING_PROGRESS_KIND], authors: [pubkey], '#d': [dTag] }) @@ -125,23 +213,49 @@ export async function loadReadingPosition( } } } catch (err) { - console.log('📭 No cached reading position found, fetching from relays...') + console.log('📭 No cached reading progress found, fetching from relays...') } - // If not in local store, fetch from relays + // Fetch from relays + const result = await fetchFromRelays( + relayPool, + eventStore, + pubkey, + READING_PROGRESS_KIND, + dTag, + getReadingProgressContent + ) + + if (result) { + console.log('✅ [ReadingProgress] Loaded from relays') + return result + } + + console.log('📭 No reading progress 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 +267,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/readingProgressController.ts b/src/services/readingProgressController.ts new file mode 100644 index 00000000..2527b841 --- /dev/null +++ b/src/services/readingProgressController.ts @@ -0,0 +1,299 @@ +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' +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' +const PROGRESS_CACHE_KEY = 'reading_progress_cache_v1' + +/** + * 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 + private timelineSubscription: { unsubscribe: () => void } | null = null + + 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 { + console.log('[progress] 📡 Emitting to', this.progressListeners.length, 'listeners with', progressMap.size, 'items') + this.progressListeners.forEach(cb => cb(new Map(progressMap))) + } + + /** + * Get current reading progress map without triggering a reload + */ + getProgressMap(): Map { + 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 + */ + 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++ + // Unsubscribe from any active timeline subscription + if (this.timelineSubscription) { + try { + this.timelineSubscription.unsubscribe() + } catch (err) { + console.warn('[progress] ⚠️ Failed to unsubscribe timeline on reset:', err) + } + this.timelineSubscription = null + } + 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 { + // 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) { + try { + this.timelineSubscription.unsubscribe() + } catch (err) { + console.warn('[progress] ⚠️ Failed to unsubscribe previous timeline:', err) + } + 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 + const lastSynced = needsFullSync ? null : this.getLastSyncedAt(pubkey) + + const filter: Filter = { + kinds: [KINDS.ReadingProgress], + authors: [pubkey] + } + + if (lastSynced && !needsFullSync) { + filter.since = lastSynced + console.log('📊 [ReadingProgress] Incremental sync since', new Date(lastSynced * 1000).toISOString()) + } else { + console.log('📊 [ReadingProgress] Full sync (map size:', this.currentProgressMap.size + ')') + } + + const relayEvents = await queryEvents(relayPool, filter, { relayUrls: RELAYS }) + + if (startGeneration !== this.generation) { + console.log('📊 [ReadingProgress] Cancelled (generation changed)') + return + } + + if (relayEvents.length > 0) { + // Add to event store + relayEvents.forEach(e => eventStore.add(e)) + + // 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 new events from relays') + } + } 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: NostrEvent[]): void { + console.log('[progress] 🔄 Processing', events.length, 'events') + + 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 + }) + } + + 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) + + // Persist for current user so it survives refresh/flight mode + if (this.lastLoadedPubkey) { + this.persistProgress(this.lastLoadedPubkey, this.currentProgressMap) + } + } +} + +export const readingProgressController = new ReadingProgressController() + diff --git a/src/services/readsService.ts b/src/services/readsService.ts index f54adc9b..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 { 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,19 +61,19 @@ export async function fetchAllReads( try { // Fetch all data sources in parallel - const [readingPositionEvents, markedAsReadArticles] = await Promise.all([ - queryEvents(relayPool, { kinds: [KINDS.AppData], authors: [userPubkey] }, { relayUrls: RELAYS }), + const [progressEvents, markedAsReadArticles] = await Promise.all([ + queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [userPubkey] }, { relayUrls: RELAYS }), fetchReadArticles(relayPool, userPubkey) ]) console.log('📊 [Reads] Data fetched:', { - readingPositions: readingPositionEvents.length, + readingProgress: progressEvents.length, markedAsRead: markedAsReadArticles.length, bookmarks: bookmarks.length }) - // Process reading positions and emit items - processReadingPositions(readingPositionEvents, readsMap) + // Process reading progress events (kind 39802) + processReadingProgress(progressEvents, 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..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( 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')) { 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 +}) +