Compare commits

..

13 Commits

Author SHA1 Message Date
Gigi
c798376411 chore: bump version to 0.10.16 2025-10-23 00:47:38 +02:00
Gigi
e83c301e6a fix(reading-position): don't clear save timer when tracking toggles
The save timer was being cleared every time the effect unmounted (when
tracking toggled on/off), preventing saves from ever completing.

Now the save timer persists across tracking toggles and will fire even
if tracking is temporarily disabled. This fixes the core issue where
saves were scheduled but never executed.
2025-10-23 00:45:05 +02:00
Gigi
2c0aee3fe4 debug(reading-position): add comprehensive logging to scheduleSave
Adding detailed logs to trace exactly what's happening when saves
are attempted. This will help identify why saves aren't working.
2025-10-23 00:43:42 +02:00
Gigi
d0f043fb5a debug(reading-position): add logging to track isTextContent changes
Added detailed logging to understand why isTextContent is changing
and causing tracking to toggle on/off.
2025-10-23 00:42:29 +02:00
Gigi
039b988869 fix(reading-position): prevent tracking from toggling on/off
Added logic to properly disable tracking when isTextContent becomes false.
This prevents the tracking state from flipping and ensures saves work
consistently.

Now tracking is only enabled once content is stable and stays enabled
until the article changes or content becomes unsuitable.
2025-10-23 00:42:08 +02:00
Gigi
d285003e1d fix(reading-position): fix infinite loop and enable saves
Fixed maximum update depth error by using refs for html/markdown content
instead of including them in useCallback dependencies. This prevents
handleSavePosition from being recreated on every content change, which
was causing scheduleSave to recreate, triggering infinite effect loops.

Now:
- handleSavePosition is stable across renders
- scheduleSave is stable
- Effect doesn't re-run infinitely
- Saves work properly with 3s throttle
2025-10-23 00:40:36 +02:00
Gigi
530abeeb33 fix(reading-position): remove noisy suppression logs and reduce suppression time
Changes:
- Removed log spam during suppression (was logging on every scroll event)
- Reduced suppression time from 2000ms to 1500ms for smooth scroll
  (500ms render delay + 1000ms smooth scroll animation)

The suppression still works but is now silent to avoid console spam.
After smooth scroll completes, saves will resume normally.
2025-10-23 00:38:30 +02:00
Gigi
3ac6954cb7 refactor(reading-position): remove unused complexity
Removed unnecessary refs and logic that are no longer needed with
the simple 3s throttle:

- Removed lastSavedPosition (not used for any logic)
- Removed hasSavedOnce (not used)
- Removed lastSavedAtRef (not used)
- Removed saveNow() function (no longer needed after removing save-on-unmount)
- Simplified to just lastSaved100Ref to prevent duplicate 100% saves

The hook is now much simpler and easier to understand.
2025-10-23 00:36:20 +02:00
Gigi
1c0f619a47 refactor(reading-position): remove 5% delta requirement
Simplified throttle logic to just save every 3 seconds during scrolling,
regardless of how much the position changed. This ensures all position
updates are captured reliably.

The 5% check was causing issues and unnecessary complexity. Now:
- First scroll schedules a save in 3s
- Continued scrolling updates pending position
- Timer fires and saves latest position
- Next scroll schedules another save in 3s

Simple and reliable.
2025-10-23 00:34:47 +02:00
Gigi
0fcfd200a4 fix(reading-position): fix throttle logic to work with slow scrolling
Previous fix didn't work because after a save, the 5% check would
prevent scheduling a new timer during slow scrolling.

Changes:
- Always update pendingPositionRef (line 62)
- Schedule timer if significant change OR 3s has passed since last save
- Check 5% delta again when timer fires before actually saving

This ensures continuous slow scrolling triggers saves every 3s.
2025-10-23 00:33:55 +02:00
Gigi
e01c8d33fc fix(reading-position): use throttle instead of debounce for saves
Changed from debounce (which resets timer on every scroll) to throttle
(which saves at regular 3s intervals). This ensures position is saved
during continuous slow scrolling.

Key changes:
- Don't reset timer if one is already pending
- Track latest position in pendingPositionRef
- Save the latest position when timer fires, not the position from when scheduled

This prevents the issue where slow continuous scrolling would never
trigger a save because the debounce timer kept resetting.
2025-10-23 00:31:29 +02:00
Gigi
51c0f7d923 fix(highlights): scroll to highlight when clicked from /me/highlights
Pass highlightId and openHighlights in navigation state when clicking
highlights from the highlights list. This triggers the scroll behavior
in Bookmarks.tsx that was already implemented but not being used.

The useHighlightInteractions hook automatically scrolls to the selected
highlight once the article loads and the highlight mark is found in the DOM.
2025-10-23 00:27:35 +02:00
Gigi
8c79b5fd75 docs: update CHANGELOG for v0.10.15 2025-10-23 00:26:13 +02:00
5 changed files with 92 additions and 112 deletions

View File

@@ -7,28 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Comprehensive debug logging for reading position system
- All position restore, save, and suppression events logged with `[reading-position]` prefix
- Emoji indicators for easy visual scanning (🎯 restore, 💾 save, 🛡️ suppression, etc.)
- Detailed metrics for troubleshooting scroll behavior
## [0.10.15] - 2025-01-22
### Changed
- Reading position auto-save now uses simple 3-second debounce
- Saves only after 3s of no scrolling (was 15s minimum interval)
- Much less aggressive, reduces relay traffic
- Still saves instantly at 100% completion
- Reading position restore now uses pre-loaded data from controller
- No longer fetches position from scratch when opening articles
- Uses position already loaded and displayed on bookmark cards
- Faster restore with no network wait
- Simpler code without stabilization window complexity
- Reading position scroll animation restored to smooth behavior
- Changed from instant jump back to smooth animated scroll
- Better user experience when restoring position
### Fixed
- Reading position restore no longer causes jumpy scrolling
- Stabilized position collector buffers updates for ~700ms, then applies best one (newest timestamp, tie-break by highest progress)
- Auto-saves suppressed for 1.5s after programmatic restore to prevent feedback loops
- Tiny scroll deltas (<48px or <5%) ignored to avoid unnecessary movement
- Instant scroll (behavior: auto) instead of smooth animation reduces perceived oscillation
- Fixes jumpy behavior from conflicting relay updates and save-restore loops
- Reading position no longer saves 0% during back navigation on mobile
- Removed save-on-unmount behavior that was error-prone
- Browser scroll-to-top during back gesture no longer overwrites progress
- Auto-save with 3-second debounce is sufficient for normal reading
- Prevents accidental reset of reading progress when navigating away
## [0.10.14] - 2025-01-27

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.10.15",
"version": "0.10.16",
"description": "A minimal nostr client for bookmark management",
"homepage": "https://read.withboris.com/",
"type": "module",

View File

@@ -153,10 +153,20 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
// Reading position tracking - only for text content that's loaded and long enough
// Wait for content to load, check it's not a video, and verify it's long enough to track
const isTextContent = useMemo(() => {
const result = {
loading,
hasMarkdown: !!markdown,
hasHtml: !!html,
isVideo: selectedUrl?.includes('youtube') || selectedUrl?.includes('vimeo'),
longEnough: shouldTrackReadingProgress(html, markdown)
}
if (loading) return false
if (!markdown && !html) return false
if (selectedUrl?.includes('youtube') || selectedUrl?.includes('vimeo')) return false
if (!shouldTrackReadingProgress(html, markdown)) return false
console.log('[reading-position] 📊 isTextContent check:', result, '→', true)
return true
}, [loading, markdown, html, selectedUrl])
@@ -166,6 +176,14 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
return generateArticleIdentifier(selectedUrl)
}, [selectedUrl])
// Use refs for content to avoid recreating callback on every content change
const htmlRef = useRef(html)
const markdownRef = useRef(markdown)
useEffect(() => {
htmlRef.current = html
markdownRef.current = markdown
}, [html, markdown])
// Callback to save reading position
const handleSavePosition = useCallback(async (position: number) => {
if (!activeAccount || !relayPool || !eventStore || !articleIdentifier) {
@@ -178,7 +196,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
}
// Check if content is long enough to track reading progress
if (!shouldTrackReadingProgress(html, markdown)) {
if (!shouldTrackReadingProgress(htmlRef.current, markdownRef.current)) {
console.log('[reading-position] ⚠️ Save skipped: content too short')
return
}
@@ -203,7 +221,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
} catch (error) {
console.error(`[reading-position] [${new Date().toISOString()}] ❌ Failed to save reading position:`, error)
}
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, html, markdown])
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition])
// Delay enabling position tracking to ensure content is stable
const [isTrackingEnabled, setIsTrackingEnabled] = useState(false)
@@ -213,9 +231,18 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
setIsTrackingEnabled(false)
}, [selectedUrl])
// Enable tracking after content is stable
// Enable/disable tracking based on content state
useEffect(() => {
if (isTextContent && !isTrackingEnabled) {
if (!isTextContent) {
// Disable tracking if content is not suitable
if (isTrackingEnabled) {
console.log('[reading-position] ⏸️ Disabling tracking (not text content)')
setIsTrackingEnabled(false)
}
return
}
if (!isTrackingEnabled) {
// Wait 500ms after content loads before enabling tracking
const timer = setTimeout(() => {
console.log('[reading-position] ✅ Enabling tracking after stability delay')
@@ -300,9 +327,9 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
console.log('[reading-position] 🎯 Found saved position:', Math.round(savedProgress * 100) + '%')
// Suppress saves during restore (500ms render + 1000ms animation + 500ms buffer = 2000ms)
// Suppress saves during restore (500ms render + 1000ms smooth scroll = 1500ms)
if (suppressSavesForRef.current) {
suppressSavesForRef.current(2000)
suppressSavesForRef.current(1500)
}
// Wait for content to be fully rendered

View File

@@ -212,12 +212,23 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
pubkey,
identifier
})
navigate(`/a/${naddr}`)
// Pass highlight ID in navigation state to trigger scroll
navigate(`/a/${naddr}`, {
state: {
highlightId: highlight.id,
openHighlights: true
}
})
}
}
} else if (highlight.urlReference) {
// Navigate to external URL
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`)
// Navigate to external URL with highlight ID to trigger scroll
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, {
state: {
highlightId: highlight.id,
openHighlights: true
}
})
}
}

View File

@@ -23,21 +23,11 @@ export const useReadingPosition = ({
const positionRef = useRef(0)
const [isReadingComplete, setIsReadingComplete] = useState(false)
const hasTriggeredComplete = useRef(false)
const lastSavedPosition = useRef(0)
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const hasSavedOnce = useRef(false)
const completionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const lastSavedAtRef = useRef<number>(0)
const suppressUntilRef = useRef<number>(0)
const syncEnabledRef = useRef(syncEnabled)
const onSaveRef = useRef(onSave)
const scheduleSaveRef = useRef<((pos: number) => void) | null>(null)
// Keep refs in sync with props
useEffect(() => {
syncEnabledRef.current = syncEnabled
onSaveRef.current = onSave
}, [syncEnabled, onSave])
const pendingPositionRef = useRef<number>(0) // Track latest position for throttled save
const lastSaved100Ref = useRef(false) // Track if we've saved 100% to avoid duplicate saves
// Suppress auto-saves for a given duration (used after programmatic restore)
const suppressSavesFor = useCallback((ms: number) => {
@@ -46,78 +36,47 @@ export const useReadingPosition = ({
console.log(`[reading-position] [${new Date().toISOString()}] 🛡️ Suppressing saves for ${ms}ms until ${new Date(until).toISOString()}`)
}, [])
// Debounced save function - simple 2s debounce
// Throttled save function - saves at 3s intervals during scrolling
const scheduleSave = useCallback((currentPosition: number) => {
if (!syncEnabledRef.current || !onSaveRef.current) {
console.log(`[reading-position] [${new Date().toISOString()}] 📞 scheduleSave called at ${Math.round(currentPosition * 100)}%, syncEnabled=${syncEnabled}, hasOnSave=${!!onSave}`)
if (!syncEnabled || !onSave) {
console.log(`[reading-position] [${new Date().toISOString()}] ⏭️ Save skipped: syncEnabled=${syncEnabled}, hasOnSave=${!!onSave}`)
return
}
// Always save instantly when we reach completion (1.0)
if (currentPosition === 1 && lastSavedPosition.current < 1) {
if (currentPosition === 1 && !lastSaved100Ref.current) {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current)
saveTimerRef.current = null
}
console.log(`[reading-position] [${new Date().toISOString()}] 💾 Instant save at 100% completion`)
lastSavedPosition.current = 1
hasSavedOnce.current = true
lastSavedAtRef.current = Date.now()
onSaveRef.current(1)
lastSaved100Ref.current = true
onSave(1)
return
}
// Require at least 5% progress change to consider saving
const MIN_DELTA = 0.05
const hasSignificantChange = Math.abs(currentPosition - lastSavedPosition.current) >= MIN_DELTA
// Always update the pending position (latest position to save)
pendingPositionRef.current = currentPosition
if (!hasSignificantChange) {
return
}
// Clear any existing timer and schedule new save
// Throttle: only schedule a save if one isn't already pending
// This ensures saves happen at regular 3s intervals during continuous scrolling
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current)
console.log(`[reading-position] [${new Date().toISOString()}] ⏳ Timer already pending, updated pending position to ${Math.round(currentPosition * 100)}%`)
return // Already have a save scheduled, don't reset the timer
}
const DEBOUNCE_MS = 3000 // Save max every 3 seconds
const THROTTLE_MS = 3000
console.log(`[reading-position] [${new Date().toISOString()}] ⏰ Scheduling save in ${THROTTLE_MS}ms`)
saveTimerRef.current = setTimeout(() => {
console.log(`[reading-position] [${new Date().toISOString()}] 💾 Auto-save at ${Math.round(currentPosition * 100)}%`)
lastSavedPosition.current = currentPosition
hasSavedOnce.current = true
lastSavedAtRef.current = Date.now()
if (onSaveRef.current) {
onSaveRef.current(currentPosition)
}
// Save the latest position, not the one from when timer was scheduled
const positionToSave = pendingPositionRef.current
console.log(`[reading-position] [${new Date().toISOString()}] 💾 Auto-save at ${Math.round(positionToSave * 100)}%`)
onSave(positionToSave)
saveTimerRef.current = null
}, DEBOUNCE_MS)
}, [])
// Store scheduleSave in ref for use in scroll handler
useEffect(() => {
scheduleSaveRef.current = scheduleSave
}, [scheduleSave])
// Immediate save function
const saveNow = useCallback(() => {
if (!syncEnabledRef.current || !onSaveRef.current) return
// Check suppression even for saveNow (e.g., during restore)
if (Date.now() < suppressUntilRef.current) {
const remainingMs = suppressUntilRef.current - Date.now()
console.log(`[reading-position] [${new Date().toISOString()}] ⏭️ saveNow() suppressed (${remainingMs}ms remaining) at ${Math.round(positionRef.current * 100)}%`)
return
}
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current)
saveTimerRef.current = null
}
console.log(`[reading-position] [${new Date().toISOString()}] 💾 saveNow() called at ${Math.round(positionRef.current * 100)}%`)
lastSavedPosition.current = positionRef.current
hasSavedOnce.current = true
lastSavedAtRef.current = Date.now()
onSaveRef.current(positionRef.current)
}, [])
}, THROTTLE_MS)
}, [syncEnabled, onSave])
useEffect(() => {
if (!enabled) return
@@ -149,11 +108,9 @@ export const useReadingPosition = ({
// Schedule auto-save if sync is enabled (unless suppressed)
if (Date.now() >= suppressUntilRef.current) {
scheduleSaveRef.current?.(clampedProgress)
} else {
const remainingMs = suppressUntilRef.current - Date.now()
console.log(`[reading-position] [${new Date().toISOString()}] 🛡️ Save suppressed (${remainingMs}ms remaining) at ${Math.round(clampedProgress * 100)}%`)
scheduleSave(clampedProgress)
}
// Note: Suppression is silent to avoid log spam during scrolling
// Completion detection with 2s hold at 100%
if (!hasTriggeredComplete.current) {
@@ -196,32 +153,20 @@ export const useReadingPosition = ({
window.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', handleScroll)
// Flush pending save before unmount (don't lose progress if navigating away during debounce window)
if (saveTimerRef.current && syncEnabledRef.current && onSaveRef.current) {
clearTimeout(saveTimerRef.current)
saveTimerRef.current = null
// Only flush if we have unsaved progress (position differs from last saved)
const hasUnsavedProgress = Math.abs(positionRef.current - lastSavedPosition.current) >= 0.05
if (hasUnsavedProgress && Date.now() >= suppressUntilRef.current) {
console.log(`[reading-position] [${new Date().toISOString()}] 💾 Flushing pending save on unmount at ${Math.round(positionRef.current * 100)}%`)
onSaveRef.current(positionRef.current)
}
}
// DON'T clear save timer - let it complete even if tracking is temporarily disabled
// Only clear completion timer since that's tied to the current scroll session
if (completionTimerRef.current) {
clearTimeout(completionTimerRef.current)
}
}
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, completionHoldMs])
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, scheduleSave, completionHoldMs])
// Reset reading complete state when enabled changes
useEffect(() => {
if (!enabled) {
setIsReadingComplete(false)
hasTriggeredComplete.current = false
hasSavedOnce.current = false
lastSavedPosition.current = 0
lastSaved100Ref.current = false
if (completionTimerRef.current) {
clearTimeout(completionTimerRef.current)
completionTimerRef.current = null
@@ -233,7 +178,6 @@ export const useReadingPosition = ({
position,
isReadingComplete,
progressPercentage: Math.round(position * 100),
saveNow,
suppressSavesFor
}
}