mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15a7129b6d | ||
|
|
b9e17e0982 | ||
|
|
1be8c62c94 | ||
|
|
e2bf243b01 | ||
|
|
85d816b2a7 | ||
|
|
623bee4632 | ||
|
|
e68b97bde8 | ||
|
|
ca32dfca51 | ||
|
|
9de8b00d5d | ||
|
|
033ef5e995 | ||
|
|
c986b0d517 | ||
|
|
1729a5b066 | ||
|
|
c6186ea84e | ||
|
|
c798376411 | ||
|
|
e83c301e6a | ||
|
|
2c0aee3fe4 | ||
|
|
d0f043fb5a | ||
|
|
039b988869 | ||
|
|
d285003e1d | ||
|
|
530abeeb33 | ||
|
|
3ac6954cb7 | ||
|
|
1c0f619a47 | ||
|
|
0fcfd200a4 | ||
|
|
e01c8d33fc | ||
|
|
51c0f7d923 | ||
|
|
8c79b5fd75 |
67
CHANGELOG.md
67
CHANGELOG.md
@@ -7,28 +7,57 @@ 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
|
||||
|
||||
### 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
|
||||
## [0.10.16] - 2025-10-22
|
||||
|
||||
### 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 auto-save now works correctly during continuous scrolling
|
||||
- Fixed critical bug where save timer was cleared when tracking toggled
|
||||
- Timer now persists across tracking state changes
|
||||
- Saves fire reliably every 3 seconds during active reading
|
||||
- Throttle mechanism now works as intended
|
||||
- Reading position tracking stability improved
|
||||
- Tracking state no longer toggles erratically
|
||||
- Content stability checks refined to prevent false negatives
|
||||
- Infinite loop fixed in position save handler
|
||||
|
||||
### Changed
|
||||
|
||||
- Reading position save mechanism changed from debounce to throttle
|
||||
- Ensures saves happen at regular 3-second intervals during continuous scrolling
|
||||
- Previous debounce approach could skip saves during slow continuous scrolling
|
||||
- More predictable save behavior for users
|
||||
- Simplified reading position logic by removing unused complexity
|
||||
- Removed 5% delta requirement for scheduling saves
|
||||
- Removed unnecessary state tracking (lastSavedPosition, hasSavedOnce, lastSavedAtRef)
|
||||
- Cleaner, more maintainable code
|
||||
|
||||
### Fixed
|
||||
|
||||
- Highlights now scroll into view when clicked from `/me/highlights` page
|
||||
- Navigation state properly passes highlight ID and openHighlights flag
|
||||
- Works for both article links and external URL links
|
||||
|
||||
## [0.10.15] - 2025-01-22
|
||||
|
||||
### Changed
|
||||
|
||||
- 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 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.17",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
|
||||
@@ -157,6 +157,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
if (!markdown && !html) return false
|
||||
if (selectedUrl?.includes('youtube') || selectedUrl?.includes('vimeo')) return false
|
||||
if (!shouldTrackReadingProgress(html, markdown)) return false
|
||||
|
||||
return true
|
||||
}, [loading, markdown, html, selectedUrl])
|
||||
|
||||
@@ -166,27 +167,31 @@ 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) {
|
||||
console.log('[reading-position] ❌ Cannot save: missing dependencies')
|
||||
return
|
||||
}
|
||||
if (!settings?.syncReadingPosition) {
|
||||
console.log('[reading-position] ⚠️ Save skipped: sync disabled in settings')
|
||||
return
|
||||
}
|
||||
|
||||
// Check if content is long enough to track reading progress
|
||||
if (!shouldTrackReadingProgress(html, markdown)) {
|
||||
console.log('[reading-position] ⚠️ Save skipped: content too short')
|
||||
if (!shouldTrackReadingProgress(htmlRef.current, markdownRef.current)) {
|
||||
return
|
||||
}
|
||||
|
||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
|
||||
|
||||
try {
|
||||
console.log(`[reading-position] [${new Date().toISOString()}] 🚀 Publishing position ${Math.round(position * 100)}% to relays...`)
|
||||
const factory = new EventFactory({ signer: activeAccount })
|
||||
await saveReadingPosition(
|
||||
relayPool,
|
||||
@@ -199,11 +204,10 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
scrollTop
|
||||
}
|
||||
)
|
||||
console.log(`[reading-position] [${new Date().toISOString()}] ✅ Position published successfully`)
|
||||
} catch (error) {
|
||||
console.error(`[reading-position] [${new Date().toISOString()}] ❌ Failed to save reading position:`, error)
|
||||
console.error('[reading-position] 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,12 +217,19 @@ 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) {
|
||||
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')
|
||||
setIsTrackingEnabled(true)
|
||||
}, 500)
|
||||
return () => clearTimeout(timer)
|
||||
@@ -258,35 +269,24 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
const hasAttemptedRestoreRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[reading-position] 🔍 Restore effect running:', {
|
||||
isTextContent,
|
||||
isTrackingEnabled,
|
||||
hasAccount: !!activeAccount,
|
||||
articleIdentifier,
|
||||
restoreKey,
|
||||
hasAttempted: hasAttemptedRestoreRef.current
|
||||
})
|
||||
|
||||
if (!isTextContent || !activeAccount || !articleIdentifier) {
|
||||
console.log('[reading-position] ⏭️ Restore skipped: missing dependencies or not text content')
|
||||
return
|
||||
}
|
||||
if (settings?.syncReadingPosition === false) {
|
||||
console.log('[reading-position] ⏭️ Restore skipped: sync disabled in settings')
|
||||
return
|
||||
}
|
||||
if (settings?.autoScrollToReadingPosition === false) {
|
||||
return
|
||||
}
|
||||
if (!isTrackingEnabled) {
|
||||
console.log('[reading-position] ⏭️ Restore skipped: tracking not yet enabled (waiting for content stability)')
|
||||
return
|
||||
}
|
||||
|
||||
// Only attempt restore once per article (after tracking is enabled)
|
||||
if (hasAttemptedRestoreRef.current === restoreKey) {
|
||||
console.log('[reading-position] ⏭️ Restore skipped: already attempted for this article')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[reading-position] 🔄 Initiating restore for article:', articleIdentifier)
|
||||
// Mark as attempted using composite key
|
||||
hasAttemptedRestoreRef.current = restoreKey
|
||||
|
||||
@@ -294,15 +294,12 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
const savedProgress = readingProgressController.getProgress(articleIdentifier)
|
||||
|
||||
if (!savedProgress || savedProgress <= 0.05 || savedProgress >= 1) {
|
||||
console.log('[reading-position] ℹ️ No position to restore (progress:', savedProgress, ')')
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
@@ -313,20 +310,10 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
const currentTop = window.pageYOffset || document.documentElement.scrollTop
|
||||
const targetTop = savedProgress * maxScroll
|
||||
|
||||
console.log('[reading-position] 📐 Restore calculation:', {
|
||||
docHeight: docH,
|
||||
winHeight: winH,
|
||||
maxScroll,
|
||||
currentTop,
|
||||
targetTop,
|
||||
targetPercent: Math.round(savedProgress * 100) + '%'
|
||||
})
|
||||
|
||||
// Skip if delta is too small (< 48px or < 5%)
|
||||
const deltaPx = Math.abs(targetTop - currentTop)
|
||||
const deltaPct = maxScroll > 0 ? Math.abs((targetTop - currentTop) / maxScroll) : 0
|
||||
if (deltaPx < 48 || deltaPct < 0.05) {
|
||||
console.log('[reading-position] ⏭️ Restore skipped: delta too small (', deltaPx, 'px,', Math.round(deltaPct * 100) + '%)')
|
||||
// Allow saves immediately since no scroll happened
|
||||
if (suppressSavesForRef.current) {
|
||||
suppressSavesForRef.current(0)
|
||||
@@ -334,20 +321,17 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[reading-position] 📜 Restoring scroll position (delta:', deltaPx, 'px,', Math.round(deltaPct * 100) + '%)')
|
||||
|
||||
// Perform smooth animated restore
|
||||
window.scrollTo({
|
||||
top: targetTop,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
console.log('[reading-position] ✅ Scroll restored to', Math.round(savedProgress * 100) + '%')
|
||||
}, 500) // Give content time to render
|
||||
}, [isTextContent, activeAccount, articleIdentifier, settings?.syncReadingPosition, selectedUrl, isTrackingEnabled, restoreKey])
|
||||
}, [isTextContent, activeAccount, articleIdentifier, settings?.syncReadingPosition, settings?.autoScrollToReadingPosition, selectedUrl, isTrackingEnabled, restoreKey])
|
||||
|
||||
// Note: We intentionally do NOT save on unmount because:
|
||||
// 1. Browser may scroll to top during back navigation, causing 0% saves
|
||||
// 2. The auto-save with 3s debounce already captures position during reading
|
||||
// 2. The auto-save with 1s throttle already captures position during reading
|
||||
// 3. Position state may not reflect actual reading position during navigation
|
||||
|
||||
// Close menu when clicking outside
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,7 +433,31 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
title={new Date(highlight.created_at * 1000).toLocaleString()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
window.location.href = highlightLinks.native
|
||||
// Navigate within app using same logic as handleItemClick
|
||||
if (highlight.eventReference) {
|
||||
const parts = highlight.eventReference.split(':')
|
||||
if (parts.length === 3 && parts[0] === '30023') {
|
||||
const [, pubkey, identifier] = parts
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
pubkey,
|
||||
identifier
|
||||
})
|
||||
navigate(`/a/${naddr}`, {
|
||||
state: {
|
||||
highlightId: highlight.id,
|
||||
openHighlights: true
|
||||
}
|
||||
})
|
||||
}
|
||||
} else if (highlight.urlReference) {
|
||||
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, {
|
||||
state: {
|
||||
highlightId: highlight.id,
|
||||
openHighlights: true
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
{formatDateCompact(highlight.created_at)}
|
||||
|
||||
@@ -44,6 +44,7 @@ const DEFAULT_SETTINGS: UserSettings = {
|
||||
fullWidthImages: true,
|
||||
renderVideoLinksAsEmbeds: true,
|
||||
syncReadingPosition: true,
|
||||
autoScrollToReadingPosition: true,
|
||||
autoMarkAsReadOnCompletion: false,
|
||||
hideBookmarksWithoutCreationDate: true,
|
||||
ttsUseSystemLanguage: false,
|
||||
|
||||
@@ -118,6 +118,19 @@ const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ setting
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="autoScrollToReadingPosition" className="checkbox-label">
|
||||
<input
|
||||
id="autoScrollToReadingPosition"
|
||||
type="checkbox"
|
||||
checked={settings.autoScrollToReadingPosition !== false}
|
||||
onChange={(e) => onUpdate({ autoScrollToReadingPosition: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Auto-scroll to saved reading position</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="autoMarkAsReadOnCompletion" className="checkbox-label">
|
||||
<input
|
||||
|
||||
@@ -33,7 +33,13 @@ const PWASettings: React.FC<PWASettingsProps> = ({ settings, onUpdate, onClose }
|
||||
|
||||
const handleLinkClick = (url: string) => {
|
||||
if (onClose) onClose()
|
||||
navigate(`/r/${encodeURIComponent(url)}`)
|
||||
// If it's an internal route (starts with /), navigate directly
|
||||
if (url.startsWith('/')) {
|
||||
navigate(url)
|
||||
} else {
|
||||
// External URL: wrap with /r/ path
|
||||
navigate(`/r/${encodeURIComponent(url)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearCache = async () => {
|
||||
|
||||
@@ -23,101 +23,52 @@ 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) => {
|
||||
const until = Date.now() + ms
|
||||
suppressUntilRef.current = until
|
||||
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 1s intervals during scrolling
|
||||
const scheduleSave = useCallback((currentPosition: number) => {
|
||||
if (!syncEnabledRef.current || !onSaveRef.current) {
|
||||
if (!syncEnabled || !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 1s intervals during continuous scrolling
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current)
|
||||
return // Already have a save scheduled, don't reset the timer
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 3000 // Save max every 3 seconds
|
||||
const THROTTLE_MS = 1000
|
||||
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
|
||||
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 +100,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 +145,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 +170,6 @@ export const useReadingPosition = ({
|
||||
position,
|
||||
isReadingComplete,
|
||||
progressPercentage: Math.round(position * 100),
|
||||
saveNow,
|
||||
suppressSavesFor
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,14 +203,10 @@ export function collectReadingPositionsOnce(params: {
|
||||
hasEmitted = true
|
||||
|
||||
if (candidates.length === 0) {
|
||||
console.log('[reading-position] 📊 No candidates collected during stabilization window')
|
||||
stableCallback(null)
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[reading-position] 📊 Collected', candidates.length, 'position candidates:',
|
||||
candidates.map(c => `${Math.round(c.position * 100)}% @${new Date(c.timestamp * 1000).toLocaleTimeString()}`).join(', '))
|
||||
|
||||
// Sort: newest first, then highest progress
|
||||
candidates.sort((a, b) => {
|
||||
const timeDiff = b.timestamp - a.timestamp
|
||||
@@ -218,13 +214,10 @@ export function collectReadingPositionsOnce(params: {
|
||||
return b.position - a.position
|
||||
})
|
||||
|
||||
console.log('[reading-position] ✅ Best position selected:', Math.round(candidates[0].position * 100) + '%',
|
||||
'from', new Date(candidates[0].timestamp * 1000).toLocaleTimeString())
|
||||
stableCallback(candidates[0])
|
||||
}
|
||||
|
||||
// Start streaming and collecting
|
||||
console.log('[reading-position] 🎯 Starting stabilized position collector (window:', windowMs, 'ms)')
|
||||
streamStop = startReadingPositionStream(
|
||||
relayPool,
|
||||
eventStore,
|
||||
@@ -233,21 +226,16 @@ export function collectReadingPositionsOnce(params: {
|
||||
(pos) => {
|
||||
if (hasEmitted) return
|
||||
if (!pos) {
|
||||
console.log('[reading-position] 📥 Received null position')
|
||||
return
|
||||
}
|
||||
if (pos.position <= 0.05 || pos.position >= 1) {
|
||||
console.log('[reading-position] 🚫 Ignoring position', Math.round(pos.position * 100) + '% (outside 5%-100% range)')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[reading-position] 📥 Received position candidate:', Math.round(pos.position * 100) + '%',
|
||||
'from', new Date(pos.timestamp * 1000).toLocaleTimeString())
|
||||
candidates.push(pos)
|
||||
|
||||
// Schedule one-shot emission if not already scheduled
|
||||
if (!timer) {
|
||||
console.log('[reading-position] ⏰ Starting', windowMs, 'ms stabilization timer')
|
||||
timer = setTimeout(() => {
|
||||
emitStable()
|
||||
if (streamStop) streamStop()
|
||||
|
||||
@@ -62,6 +62,7 @@ export interface UserSettings {
|
||||
renderVideoLinksAsEmbeds?: boolean // default: false
|
||||
// Reading position sync
|
||||
syncReadingPosition?: boolean // default: false (opt-in)
|
||||
autoScrollToReadingPosition?: boolean // default: true - automatically scroll to saved position when opening article
|
||||
autoMarkAsReadOnCompletion?: boolean // default: false (opt-in)
|
||||
// Bookmark filtering
|
||||
hideBookmarksWithoutCreationDate?: boolean // default: false
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
}
|
||||
.reader-markdown blockquote, .reader-html blockquote {
|
||||
margin: 1.5rem 0;
|
||||
padding: 1rem 0 1rem 2rem;
|
||||
padding: 1rem 2rem;
|
||||
font-style: italic;
|
||||
}
|
||||
.reader-markdown blockquote p, .reader-html blockquote p { margin: 0.5rem 0; }
|
||||
@@ -232,7 +232,7 @@
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
@@ -261,7 +261,7 @@
|
||||
.reader-header-overlay .reader-summary.hide-on-mobile { display: none; }
|
||||
.reader-summary-below-image { display: block; padding: 0 0 1.5rem 0; margin-top: -1rem; }
|
||||
.reader-summary-below-image .reader-summary { color: var(--color-text-secondary); font-size: 1rem; line-height: 1.6; margin: 0; }
|
||||
.reader-hero-image { min-height: 280px; max-height: 400px; height: 50vh; }
|
||||
.reader-hero-image { width: calc(100% + 2rem); margin: -0.5rem -1rem 2rem -1rem; min-height: 280px; max-height: 400px; height: 50vh; }
|
||||
.reader-hero-image img { height: 100%; width: 100%; object-fit: cover; object-position: center; }
|
||||
.reader-header-overlay { padding: 1.5rem 1rem 1rem; }
|
||||
.reader-header-overlay .reader-title { font-size: 2rem; line-height: 1.3; }
|
||||
|
||||
Reference in New Issue
Block a user