diff --git a/src/components/ArchiveFilters.tsx b/src/components/ArchiveFilters.tsx new file mode 100644 index 00000000..1d4c9cac --- /dev/null +++ b/src/components/ArchiveFilters.tsx @@ -0,0 +1,40 @@ +import React from 'react' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faBookOpen, faBookmark, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons' +import { faBooks } from '../icons/customIcons' + +export type ArchiveFilterType = 'all' | 'to-read' | 'reading' | 'completed' | 'marked' + +interface ArchiveFiltersProps { + selectedFilter: ArchiveFilterType + onFilterChange: (filter: ArchiveFilterType) => void +} + +const ArchiveFilters: React.FC = ({ selectedFilter, onFilterChange }) => { + const filters = [ + { type: 'all' as const, icon: faAsterisk, label: 'All' }, + { type: 'to-read' as const, icon: faBookmark, label: 'To Read' }, + { type: 'reading' as const, icon: faBookOpen, label: 'Reading' }, + { type: 'completed' as const, icon: faCheckCircle, label: 'Completed' }, + { type: 'marked' as const, icon: faBooks, label: 'Marked as Read' } + ] + + return ( +
+ {filters.map(filter => ( + + ))} +
+ ) +} + +export default ArchiveFilters + diff --git a/src/components/BlogPostCard.tsx b/src/components/BlogPostCard.tsx index 549dddbe..7c338a1b 100644 --- a/src/components/BlogPostCard.tsx +++ b/src/components/BlogPostCard.tsx @@ -11,9 +11,10 @@ interface BlogPostCardProps { post: BlogPostPreview href: string level?: 'mine' | 'friends' | 'nostrverse' + readingProgress?: number // 0-1 reading progress (optional) } -const BlogPostCard: React.FC = ({ post, href, level }) => { +const BlogPostCard: React.FC = ({ post, href, level, readingProgress }) => { const profile = useEventModel(Models.ProfileModel, [post.author]) const displayName = profile?.name || profile?.display_name || `${post.author.slice(0, 8)}...${post.author.slice(-4)}` @@ -23,6 +24,10 @@ const BlogPostCard: React.FC = ({ post, href, level }) => { addSuffix: true }) + // Calculate progress percentage and determine color + const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0 + const progressColor = progressPercent >= 95 ? '#10b981' : '#6366f1' // green if >=95%, blue otherwise + return ( = ({ post, href, level }) => { {post.summary && (

{post.summary}

)} -
+ + {/* Reading progress indicator - replaces the dividing line */} + {readingProgress !== undefined && readingProgress > 0 ? ( +
+
+
+ ) : ( +
+ )} + +
{displayName} diff --git a/src/components/ContentPanel.tsx b/src/components/ContentPanel.tsx index cda830ea..28e08ad2 100644 --- a/src/components/ContentPanel.tsx +++ b/src/components/ContentPanel.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState, useEffect, useRef } from 'react' +import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react' import ReactPlayer from 'react-player' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' @@ -36,6 +36,13 @@ import { classifyUrl } from '../utils/helpers' import { buildNativeVideoUrl } from '../utils/videoHelpers' import { useReadingPosition } from '../hooks/useReadingPosition' import { ReadingProgressIndicator } from './ReadingProgressIndicator' +import { EventFactory } from 'applesauce-factory' +import { Hooks } from 'applesauce-react' +import { + generateArticleIdentifier, + loadReadingPosition, + saveReadingPosition +} from '../services/readingPositionService' interface ContentPanelProps { loading: boolean @@ -129,10 +136,58 @@ const ContentPanel: React.FC = ({ onClearSelection }) + // Get event store for reading position service + const eventStore = Hooks.useEventStore() + // Reading position tracking - only for text content, not videos const isTextContent = !loading && !!(markdown || html) && !selectedUrl?.includes('youtube') && !selectedUrl?.includes('vimeo') - const { isReadingComplete, progressPercentage } = useReadingPosition({ + + // Generate article identifier for saving/loading position + const articleIdentifier = useMemo(() => { + if (!selectedUrl) return null + return generateArticleIdentifier(selectedUrl) + }, [selectedUrl]) + + // Callback to save reading position + const handleSavePosition = useCallback(async (position: number) => { + if (!activeAccount || !relayPool || !eventStore || !articleIdentifier) { + console.log('⏭️ [ContentPanel] Skipping save - missing requirements:', { + hasAccount: !!activeAccount, + hasRelayPool: !!relayPool, + hasEventStore: !!eventStore, + hasIdentifier: !!articleIdentifier + }) + return + } + if (!settings?.syncReadingPosition) { + console.log('⏭️ [ContentPanel] Sync disabled in settings') + return + } + + console.log('💾 [ContentPanel] Saving position:', Math.round(position * 100) + '%', 'for article:', selectedUrl?.slice(0, 50)) + + try { + const factory = new EventFactory({ signer: activeAccount }) + await saveReadingPosition( + relayPool, + eventStore, + factory, + articleIdentifier, + { + position, + timestamp: Math.floor(Date.now() / 1000), + scrollTop: window.pageYOffset || document.documentElement.scrollTop + } + ) + } catch (error) { + console.error('❌ [ContentPanel] Failed to save reading position:', error) + } + }, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl]) + + const { isReadingComplete, progressPercentage, saveNow } = useReadingPosition({ enabled: isTextContent, + syncEnabled: settings?.syncReadingPosition, + onSave: handleSavePosition, onReadingComplete: () => { // Optional: Auto-mark as read when reading is complete if (activeAccount && !isMarkedAsRead) { @@ -141,6 +196,73 @@ const ContentPanel: React.FC = ({ } }) + // Load saved reading position when article loads + useEffect(() => { + if (!isTextContent || !activeAccount || !relayPool || !eventStore || !articleIdentifier) { + console.log('⏭️ [ContentPanel] Skipping position restore - missing requirements:', { + isTextContent, + hasAccount: !!activeAccount, + hasRelayPool: !!relayPool, + hasEventStore: !!eventStore, + hasIdentifier: !!articleIdentifier + }) + return + } + if (!settings?.syncReadingPosition) { + console.log('⏭️ [ContentPanel] Sync disabled - not restoring position') + return + } + + console.log('📖 [ContentPanel] Loading position for article:', selectedUrl?.slice(0, 50)) + + const loadPosition = async () => { + try { + const savedPosition = await loadReadingPosition( + relayPool, + eventStore, + activeAccount.pubkey, + articleIdentifier + ) + + if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) { + console.log('🎯 [ContentPanel] Restoring position:', Math.round(savedPosition.position * 100) + '%') + // Wait for content to be fully rendered before scrolling + setTimeout(() => { + const documentHeight = document.documentElement.scrollHeight + const windowHeight = window.innerHeight + const scrollTop = savedPosition.position * (documentHeight - windowHeight) + + window.scrollTo({ + top: scrollTop, + behavior: 'smooth' + }) + + console.log('✅ [ContentPanel] Restored to position:', Math.round(savedPosition.position * 100) + '%', 'scrollTop:', scrollTop) + }, 500) // Give content time to render + } else if (savedPosition) { + if (savedPosition.position === 1) { + console.log('✅ [ContentPanel] Article completed (100%), starting from top') + } else { + console.log('⏭️ [ContentPanel] Position too early (<5%):', Math.round(savedPosition.position * 100) + '%') + } + } + } catch (error) { + console.error('❌ [ContentPanel] Failed to load reading position:', error) + } + } + + loadPosition() + }, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl]) + + // Save position before unmounting or changing article + useEffect(() => { + return () => { + if (saveNow) { + saveNow() + } + } + }, [saveNow, selectedUrl]) + // Close menu when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index e8fe4a2f..fae4c2ad 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -237,35 +237,6 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti return `/a/${naddr}` } - const handleHighlightClick = (highlightId: string) => { - const highlight = highlights.find(h => h.id === highlightId) - if (!highlight) return - - // For nostr-native articles - if (highlight.eventReference) { - // Convert eventReference to naddr - if (highlight.eventReference.includes(':')) { - const parts = highlight.eventReference.split(':') - const kind = parseInt(parts[0]) - const pubkey = parts[1] - const identifier = parts[2] || '' - - const naddr = nip19.naddrEncode({ - kind, - pubkey, - identifier - }) - navigate(`/a/${naddr}`, { state: { highlightId, openHighlights: true } }) - } else { - // Already an naddr - navigate(`/a/${highlight.eventReference}`, { state: { highlightId, openHighlights: true } }) - } - } - // For web URLs - else if (highlight.urlReference) { - navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, { state: { highlightId, openHighlights: true } }) - } - } // Classify highlights with levels based on user context and apply visibility filters const classifiedHighlights = useMemo(() => { @@ -357,7 +328,6 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti key={highlight.id} highlight={highlight} relayPool={relayPool} - onHighlightClick={handleHighlightClick} /> ))}
diff --git a/src/components/HighlightItem.tsx b/src/components/HighlightItem.tsx index d15ee963..9ca8c073 100644 --- a/src/components/HighlightItem.tsx +++ b/src/components/HighlightItem.tsx @@ -16,6 +16,7 @@ import { createDeletionRequest } from '../services/deletionService' import { getNostrUrl } from '../config/nostrGateways' import CompactButton from './CompactButton' import { HighlightCitation } from './HighlightCitation' +import { useNavigate } from 'react-router-dom' // Helper to detect if a URL is an image const isImageUrl = (url: string): boolean => { @@ -206,6 +207,7 @@ export const HighlightItem: React.FC = ({ const [showMenu, setShowMenu] = useState(false) const activeAccount = Hooks.useActiveAccount() + const navigate = useNavigate() // Resolve the profile of the user who made the highlight const profile = useEventModel(Models.ProfileModel, [highlight.pubkey]) @@ -274,8 +276,34 @@ export const HighlightItem: React.FC = ({ }, [showMenu, showDeleteConfirm]) const handleItemClick = () => { + // If onHighlightClick is provided, use it (legacy behavior) if (onHighlightClick) { onHighlightClick(highlight.id) + return + } + + // Otherwise, navigate to the article that this highlight references + if (highlight.eventReference) { + // Parse the event reference - it can be an event ID or article coordinate (kind:pubkey:identifier) + const parts = highlight.eventReference.split(':') + + // If it's an article coordinate (3 parts) and kind is 30023, navigate to it + if (parts.length === 3) { + const [kind, pubkey, identifier] = parts + + if (kind === '30023') { + // Encode as naddr and navigate + const naddr = nip19.naddrEncode({ + kind: 30023, + pubkey, + identifier + }) + navigate(`/a/${naddr}`) + } + } + } else if (highlight.urlReference) { + // Navigate to external URL + navigate(`/r/${encodeURIComponent(highlight.urlReference)}`) } } @@ -473,7 +501,7 @@ export const HighlightItem: React.FC = ({ className={`highlight-item ${isSelected ? 'selected' : ''} ${highlight.level ? `level-${highlight.level}` : ''}`} data-highlight-id={highlight.id} onClick={handleItemClick} - style={{ cursor: onHighlightClick ? 'pointer' : 'default' }} + style={{ cursor: (onHighlightClick || highlight.eventReference || highlight.urlReference) ? 'pointer' : 'default' }} >
= ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => { const activeAccount = Hooks.useActiveAccount() + const eventStore = Hooks.useEventStore() const navigate = useNavigate() const [activeTab, setActiveTab] = useState(propActiveTab || 'highlights') @@ -51,6 +54,8 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr const [viewMode, setViewMode] = useState('cards') const [refreshTrigger, setRefreshTrigger] = useState(0) const [bookmarkFilter, setBookmarkFilter] = useState('all') + const [archiveFilter, setArchiveFilter] = useState('all') + const [readingPositions, setReadingPositions] = useState>(new Map()) // Update local state when prop changes useEffect(() => { @@ -122,6 +127,65 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr loadData() }, [relayPool, viewingPubkey, isOwnProfile, activeAccount, refreshTrigger]) + // Load reading positions for read articles (only for own profile) + useEffect(() => { + const loadPositions = async () => { + if (!isOwnProfile || !activeAccount || !relayPool || !eventStore || readArticles.length === 0) { + console.log('🔍 [Archive] Skipping position load:', { + isOwnProfile, + hasAccount: !!activeAccount, + hasRelayPool: !!relayPool, + hasEventStore: !!eventStore, + articlesCount: readArticles.length + }) + return + } + + console.log('📊 [Archive] Loading reading positions for', readArticles.length, 'articles') + + const positions = new Map() + + // Load positions for all read articles + await Promise.all( + readArticles.map(async (post) => { + try { + const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || '' + const naddr = nip19.naddrEncode({ + kind: 30023, + pubkey: post.author, + identifier: dTag + }) + const articleUrl = `nostr:${naddr}` + const identifier = generateArticleIdentifier(articleUrl) + + console.log('🔍 [Archive] Loading position for:', post.title?.slice(0, 50), 'identifier:', identifier.slice(0, 32)) + + const savedPosition = await loadReadingPosition( + relayPool, + eventStore, + activeAccount.pubkey, + identifier + ) + + if (savedPosition && savedPosition.position > 0) { + console.log('✅ [Archive] Found position:', Math.round(savedPosition.position * 100) + '%', 'for', post.title?.slice(0, 50)) + positions.set(post.event.id, savedPosition.position) + } else { + console.log('❌ [Archive] No position found for:', post.title?.slice(0, 50)) + } + } catch (error) { + console.warn('⚠️ [Archive] Failed to load reading position for article:', error) + } + }) + ) + + console.log('📊 [Archive] Loaded positions for', positions.size, '/', readArticles.length, 'articles') + setReadingPositions(positions) + } + + loadPositions() + }, [readArticles, isOwnProfile, activeAccount, relayPool, eventStore]) + // Pull-to-refresh const { isRefreshing, pullPosition } = usePullToRefresh({ onRefresh: () => { @@ -176,10 +240,34 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || []) .filter(hasContent) - // Apply filter + // Apply bookmark filter const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, bookmarkFilter) const groups = groupIndividualBookmarks(filteredBookmarks) + + // Apply archive filter + const filteredReadArticles = readArticles.filter(post => { + const position = readingPositions.get(post.event.id) + + switch (archiveFilter) { + case 'to-read': + // No position or 0% progress + return !position || position === 0 + case 'reading': + // Has some progress but not completed (0 < position < 1) + return position !== undefined && position > 0 && position < 0.95 + case 'completed': + // 95% or more read (we consider 95%+ as completed) + return position !== undefined && position >= 0.95 + case 'marked': + // Manually marked as read (in archive but no reading position data) + // These are articles that were marked via the emoji reaction + return !position || position === 0 + case 'all': + default: + return true + } + }) const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [ { key: 'private', title: 'Private Bookmarks', items: groups.privateItems }, { key: 'public', title: 'Public Bookmarks', items: groups.publicItems }, @@ -313,15 +401,30 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr
) : ( -
- {readArticles.map((post) => ( - + {readArticles.length > 0 && ( + - ))} -
+ )} + {filteredReadArticles.length === 0 ? ( +
+ No articles match this filter. +
+ ) : ( +
+ {filteredReadArticles.map((post) => ( + + ))} +
+ )} + ) case 'writings': diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index b179b5c2..f5c7af97 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -34,6 +34,7 @@ const DEFAULT_SETTINGS: UserSettings = { useLocalRelayAsCache: true, rebroadcastToAllRelays: false, paragraphAlignment: 'justify', + syncReadingPosition: false, } interface SettingsProps { diff --git a/src/components/Settings/LayoutBehaviorSettings.tsx b/src/components/Settings/LayoutBehaviorSettings.tsx index ff51264b..efc17384 100644 --- a/src/components/Settings/LayoutBehaviorSettings.tsx +++ b/src/components/Settings/LayoutBehaviorSettings.tsx @@ -104,6 +104,19 @@ const LayoutBehaviorSettings: React.FC = ({ setting Auto-collapse sidebar on small screens
+ +
+ +
) } diff --git a/src/hooks/useReadingPosition.ts b/src/hooks/useReadingPosition.ts index 775c3a08..5530d020 100644 --- a/src/hooks/useReadingPosition.ts +++ b/src/hooks/useReadingPosition.ts @@ -1,21 +1,72 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState, useCallback } from 'react' interface UseReadingPositionOptions { enabled?: boolean onPositionChange?: (position: number) => void onReadingComplete?: () => void readingCompleteThreshold?: number // Default 0.9 (90%) + 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) } export const useReadingPosition = ({ enabled = true, onPositionChange, onReadingComplete, - readingCompleteThreshold = 0.9 + readingCompleteThreshold = 0.9, + syncEnabled = false, + onSave, + autoSaveInterval = 5000 }: UseReadingPositionOptions = {}) => { const [position, setPosition] = useState(0) const [isReadingComplete, setIsReadingComplete] = useState(false) const hasTriggeredComplete = useRef(false) + const lastSavedPosition = useRef(0) + const saveTimerRef = 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 + + // 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 + + // Clear existing timer + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current) + } + + // Schedule new save + saveTimerRef.current = setTimeout(() => { + lastSavedPosition.current = currentPosition + onSave(currentPosition) + }, autoSaveInterval) + }, [syncEnabled, onSave, autoSaveInterval]) + + // Immediate save function + const saveNow = useCallback(() => { + if (!syncEnabled || !onSave) return + + // Cancel any pending saves + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current) + saveTimerRef.current = null + } + + // Save if position is meaningful (>= 5%) + if (position >= 0.05) { + lastSavedPosition.current = position + onSave(position) + } + }, [syncEnabled, onSave, position]) useEffect(() => { if (!enabled) return @@ -30,12 +81,20 @@ export const useReadingPosition = ({ const documentHeight = document.documentElement.scrollHeight // Calculate position based on how much of the content has been scrolled through - const scrollProgress = Math.min(scrollTop / (documentHeight - windowHeight), 1) - const clampedProgress = Math.max(0, Math.min(1, scrollProgress)) + // Add a small threshold (5px) to account for rounding and make it easier to reach 100% + const maxScroll = documentHeight - windowHeight + const scrollProgress = maxScroll > 0 ? scrollTop / maxScroll : 0 + + // If we're within 5px of the bottom, consider it 100% + const isAtBottom = scrollTop + windowHeight >= documentHeight - 5 + const clampedProgress = isAtBottom ? 1 : Math.max(0, Math.min(1, scrollProgress)) 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) @@ -54,8 +113,13 @@ export const useReadingPosition = ({ return () => { window.removeEventListener('scroll', handleScroll) window.removeEventListener('resize', handleScroll) + + // Clear save timer on unmount + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current) + } } - }, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold]) + }, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, scheduleSave]) // Reset reading complete state when enabled changes useEffect(() => { @@ -68,6 +132,7 @@ export const useReadingPosition = ({ return { position, isReadingComplete, - progressPercentage: Math.round(position * 100) + progressPercentage: Math.round(position * 100), + saveNow } } diff --git a/src/services/readingPositionService.ts b/src/services/readingPositionService.ts new file mode 100644 index 00000000..1d645c13 --- /dev/null +++ b/src/services/readingPositionService.ts @@ -0,0 +1,196 @@ +import { IEventStore, mapEventsToStore } from 'applesauce-core' +import { EventFactory } from 'applesauce-factory' +import { RelayPool, onlyEvents } from 'applesauce-relay' +import { NostrEvent } from 'nostr-tools' +import { firstValueFrom } from 'rxjs' +import { publishEvent } from './writeService' +import { RELAYS } from '../config/relays' + +const APP_DATA_KIND = 30078 // NIP-78 Application Data +const READING_POSITION_PREFIX = 'boris:reading-position:' + +export interface ReadingPosition { + position: number // 0-1 scroll progress + timestamp: number // Unix timestamp + scrollTop?: number // Optional: pixel position +} + +// Helper to extract and parse reading position from an event +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 + } +} + +/** + * Generate a unique identifier for an article + * For Nostr articles: use the naddr directly + * For external URLs: use base64url encoding of the URL + */ +export function generateArticleIdentifier(naddrOrUrl: string): string { + // If it starts with "nostr:", extract the naddr + if (naddrOrUrl.startsWith('nostr:')) { + return naddrOrUrl.replace('nostr:', '') + } + // For URLs, use base64url encoding (URL-safe) + return btoa(naddrOrUrl) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') +} + +/** + * Save reading position to Nostr (Kind 30078) + */ +export async function saveReadingPosition( + relayPool: RelayPool, + eventStore: IEventStore, + factory: EventFactory, + articleIdentifier: string, + position: ReadingPosition +): Promise { + 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 + }) + + const dTag = `${READING_POSITION_PREFIX}${articleIdentifier}` + + 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) + })) + + const signed = await factory.sign(draft) + + // Use unified write service + await publishEvent(relayPool, eventStore, signed) + + console.log('✅ [ReadingPosition] Position saved successfully, event ID:', signed.id.slice(0, 8)) +} + +/** + * Load reading position from Nostr + */ +export async function loadReadingPosition( + relayPool: RelayPool, + eventStore: IEventStore, + pubkey: string, + articleIdentifier: string +): Promise { + const dTag = `${READING_POSITION_PREFIX}${articleIdentifier}` + + console.log('📖 [ReadingPosition] 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 + try { + const localEvent = await firstValueFrom( + eventStore.replaceable(APP_DATA_KIND, pubkey, dTag) + ) + if (localEvent) { + const content = getReadingPositionContent(localEvent) + if (content) { + console.log('✅ [ReadingPosition] 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 + relayPool + .subscription(RELAYS, { + kinds: [APP_DATA_KIND], + authors: [pubkey], + '#d': [dTag] + }) + .pipe(onlyEvents(), mapEventsToStore(eventStore)) + .subscribe() + + return content + } + } + } catch (err) { + console.log('📭 No cached reading position found, fetching from relays...') + } + + // If not in local store, fetch from relays + 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 + + const sub = relayPool + .subscription(RELAYS, { + kinds: [APP_DATA_KIND], + authors: [pubkey], + '#d': [dTag] + }) + .pipe(onlyEvents(), mapEventsToStore(eventStore)) + .subscribe({ + complete: async () => { + clearTimeout(timeout) + if (!hasResolved) { + hasResolved = true + try { + const event = await firstValueFrom( + eventStore.replaceable(APP_DATA_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) + } + } 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) + clearTimeout(timeout) + if (!hasResolved) { + hasResolved = true + resolve(null) + } + } + }) + + setTimeout(() => { + sub.unsubscribe() + }, 3000) + }) +} + diff --git a/src/services/settingsService.ts b/src/services/settingsService.ts index 58e78735..a36c7879 100644 --- a/src/services/settingsService.ts +++ b/src/services/settingsService.ts @@ -54,6 +54,8 @@ export interface UserSettings { lightColorTheme?: 'paper-white' | 'sepia' | 'ivory' // default: sepia // Reading settings paragraphAlignment?: 'left' | 'justify' // default: justify + // Reading position sync + syncReadingPosition?: boolean // default: false (opt-in) } export async function loadSettings(