mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c798376411 | ||
|
|
e83c301e6a | ||
|
|
2c0aee3fe4 | ||
|
|
d0f043fb5a | ||
|
|
039b988869 | ||
|
|
d285003e1d | ||
|
|
530abeeb33 | ||
|
|
3ac6954cb7 | ||
|
|
1c0f619a47 | ||
|
|
0fcfd200a4 | ||
|
|
e01c8d33fc | ||
|
|
51c0f7d923 | ||
|
|
8c79b5fd75 |
30
CHANGELOG.md
30
CHANGELOG.md
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user