mirror of
https://github.com/dergigi/boris.git
synced 2025-12-22 17:14:20 +01:00
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:
@@ -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<ContentPanelProps> = ({
|
||||
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<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
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
|
||||
@@ -34,6 +34,7 @@ const DEFAULT_SETTINGS: UserSettings = {
|
||||
useLocalRelayAsCache: true,
|
||||
rebroadcastToAllRelays: false,
|
||||
paragraphAlignment: 'justify',
|
||||
syncReadingPosition: false,
|
||||
}
|
||||
|
||||
interface SettingsProps {
|
||||
|
||||
@@ -104,6 +104,19 @@ const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ setting
|
||||
<span>Auto-collapse sidebar on small screens</span>
|
||||
</label>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<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(() => {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
184
src/services/readingPositionService.ts
Normal file
184
src/services/readingPositionService.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user