feat: add reading position sync across devices using Nostr Kind 30078

- Create readingPositionService.ts for save/load operations
- Add syncReadingPosition setting (opt-in via Settings > Layout & Behavior)
- Enhance useReadingPosition hook with auto-save (debounced 5s) and immediate save on navigation
- Integrate position restore in ContentPanel with smooth scroll to saved position
- Support both Nostr articles (naddr) and external URLs
- Reading positions stored privately to user's relays
- Auto-save excludes first 5% and last 5% of content to avoid noise
- Position automatically restored when returning to article
This commit is contained in:
Gigi
2025-10-15 22:08:12 +02:00
parent 2c9e6cc54e
commit cf2d227f61
6 changed files with 351 additions and 6 deletions

View File

@@ -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 ReactPlayer from 'react-player'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
@@ -36,6 +36,14 @@ import { classifyUrl } from '../utils/helpers'
import { buildNativeVideoUrl } from '../utils/videoHelpers' import { buildNativeVideoUrl } from '../utils/videoHelpers'
import { useReadingPosition } from '../hooks/useReadingPosition' import { useReadingPosition } from '../hooks/useReadingPosition'
import { ReadingProgressIndicator } from './ReadingProgressIndicator' 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 { interface ContentPanelProps {
loading: boolean loading: boolean
@@ -129,10 +137,45 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
onClearSelection onClearSelection
}) })
// Get event store for reading position service
const eventStore = Hooks.useEventStore()
// Reading position tracking - only for text content, not videos // Reading position tracking - only for text content, not videos
const isTextContent = !loading && !!(markdown || html) && !selectedUrl?.includes('youtube') && !selectedUrl?.includes('vimeo') 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, enabled: isTextContent,
syncEnabled: settings?.syncReadingPosition,
onSave: handleSavePosition,
onReadingComplete: () => { onReadingComplete: () => {
// Optional: Auto-mark as read when reading is complete // Optional: Auto-mark as read when reading is complete
if (activeAccount && !isMarkedAsRead) { if (activeAccount && !isMarkedAsRead) {
@@ -141,6 +184,52 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
} }
}) })
// 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 // Close menu when clicking outside
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {

View File

@@ -34,6 +34,7 @@ const DEFAULT_SETTINGS: UserSettings = {
useLocalRelayAsCache: true, useLocalRelayAsCache: true,
rebroadcastToAllRelays: false, rebroadcastToAllRelays: false,
paragraphAlignment: 'justify', paragraphAlignment: 'justify',
syncReadingPosition: false,
} }
interface SettingsProps { interface SettingsProps {

View File

@@ -104,6 +104,19 @@ const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ setting
<span>Auto-collapse sidebar on small screens</span> <span>Auto-collapse sidebar on small screens</span>
</label> </label>
</div> </div>
<div className="setting-group">
<label htmlFor="syncReadingPosition" className="checkbox-label">
<input
id="syncReadingPosition"
type="checkbox"
checked={settings.syncReadingPosition ?? false}
onChange={(e) => onUpdate({ syncReadingPosition: e.target.checked })}
className="setting-checkbox"
/>
<span>Sync reading position across devices</span>
</label>
</div>
</div> </div>
) )
} }

View File

@@ -1,21 +1,68 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState, useCallback } from 'react'
interface UseReadingPositionOptions { interface UseReadingPositionOptions {
enabled?: boolean enabled?: boolean
onPositionChange?: (position: number) => void onPositionChange?: (position: number) => void
onReadingComplete?: () => void onReadingComplete?: () => void
readingCompleteThreshold?: number // Default 0.9 (90%) 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 = ({ export const useReadingPosition = ({
enabled = true, enabled = true,
onPositionChange, onPositionChange,
onReadingComplete, onReadingComplete,
readingCompleteThreshold = 0.9 readingCompleteThreshold = 0.9,
syncEnabled = false,
onSave,
autoSaveInterval = 5000
}: UseReadingPositionOptions = {}) => { }: UseReadingPositionOptions = {}) => {
const [position, setPosition] = useState(0) const [position, setPosition] = useState(0)
const [isReadingComplete, setIsReadingComplete] = useState(false) const [isReadingComplete, setIsReadingComplete] = useState(false)
const hasTriggeredComplete = useRef(false) const hasTriggeredComplete = useRef(false)
const lastSavedPosition = useRef(0)
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | 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(() => { useEffect(() => {
if (!enabled) return if (!enabled) return
@@ -36,6 +83,9 @@ export const useReadingPosition = ({
setPosition(clampedProgress) setPosition(clampedProgress)
onPositionChange?.(clampedProgress) onPositionChange?.(clampedProgress)
// Schedule auto-save if sync is enabled
scheduleSave(clampedProgress)
// Check if reading is complete // Check if reading is complete
if (clampedProgress >= readingCompleteThreshold && !hasTriggeredComplete.current) { if (clampedProgress >= readingCompleteThreshold && !hasTriggeredComplete.current) {
setIsReadingComplete(true) setIsReadingComplete(true)
@@ -54,8 +104,13 @@ export const useReadingPosition = ({
return () => { return () => {
window.removeEventListener('scroll', handleScroll) window.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', 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 // Reset reading complete state when enabled changes
useEffect(() => { useEffect(() => {
@@ -68,6 +123,7 @@ export const useReadingPosition = ({
return { return {
position, position,
isReadingComplete, isReadingComplete,
progressPercentage: Math.round(position * 100) progressPercentage: Math.round(position * 100),
saveNow
} }
} }

View File

@@ -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<void> {
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<ReadingPosition | null> {
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)
})
}

View File

@@ -54,6 +54,8 @@ export interface UserSettings {
lightColorTheme?: 'paper-white' | 'sepia' | 'ivory' // default: sepia lightColorTheme?: 'paper-white' | 'sepia' | 'ivory' // default: sepia
// Reading settings // Reading settings
paragraphAlignment?: 'left' | 'justify' // default: justify paragraphAlignment?: 'left' | 'justify' // default: justify
// Reading position sync
syncReadingPosition?: boolean // default: false (opt-in)
} }
export async function loadSettings( export async function loadSettings(