diff --git a/src/components/ContentPanel.tsx b/src/components/ContentPanel.tsx index cda830ea..b18155d7 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,14 @@ 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 { IEventStore } from 'applesauce-core' +import { Hooks } from 'applesauce-react' +import { + generateArticleIdentifier, + loadReadingPosition, + saveReadingPosition +} from '../services/readingPositionService' interface ContentPanelProps { loading: boolean @@ -129,10 +137,45 @@ 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) return + if (!settings?.syncReadingPosition) return + + 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('Failed to save reading position:', error) + } + }, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition]) + + 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 +184,52 @@ const ContentPanel: React.FC = ({ } }) + // Load saved reading position when article loads + useEffect(() => { + if (!isTextContent || !activeAccount || !relayPool || !eventStore || !articleIdentifier) return + if (!settings?.syncReadingPosition) return + + const loadPosition = async () => { + try { + const savedPosition = await loadReadingPosition( + relayPool, + eventStore, + activeAccount.pubkey, + articleIdentifier + ) + + if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 0.95) { + // 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('📖 Restored reading position:', Math.round(savedPosition.position * 100) + '%') + }, 500) // Give content time to render + } + } catch (error) { + console.error('Failed to load reading position:', error) + } + } + + loadPosition() + }, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition]) + + // 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/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..b9301b85 100644 --- a/src/hooks/useReadingPosition.ts +++ b/src/hooks/useReadingPosition.ts @@ -1,21 +1,68 @@ -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%) or too high (> 95%) + if (currentPosition < 0.05 || currentPosition > 0.95) return + + // Don't save if position hasn't changed significantly (less than 1%) + if (Math.abs(currentPosition - lastSavedPosition.current) < 0.01) 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 + if (position >= 0.05 && position <= 0.95) { + lastSavedPosition.current = position + onSave(position) + } + }, [syncEnabled, onSave, position]) useEffect(() => { if (!enabled) return @@ -36,6 +83,9 @@ export const useReadingPosition = ({ 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 +104,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 +123,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..95154a35 --- /dev/null +++ b/src/services/readingPositionService.ts @@ -0,0 +1,184 @@ +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('💾 Saving reading position:', { + identifier: articleIdentifier.slice(0, 32) + '...', + position: position.position, + timestamp: position.timestamp + }) + + 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('✅ Reading position saved successfully') +} + +/** + * 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('📖 Loading reading position:', { + pubkey: pubkey.slice(0, 8) + '...', + identifier: articleIdentifier.slice(0, 32) + '...' + }) + + // 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('✅ Reading position loaded from local store:', content.position) + + // 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('✅ Reading position loaded from relays:', content.position) + resolve(content) + } else { + resolve(null) + } + } else { + console.log('📭 No reading position found') + 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(