Compare commits

..

26 Commits

Author SHA1 Message Date
Gigi
15a7129b6d chore: bump version to 0.10.17 2025-10-23 15:59:45 +02:00
Gigi
b9e17e0982 fix: remove unused variable in highlight timestamp handler 2025-10-23 15:59:11 +02:00
Gigi
1be8c62c94 fix: restore edge-to-edge hero image on mobile with adjusted negative margins 2025-10-23 15:56:34 +02:00
Gigi
e2bf243b01 style: increase mobile reader padding to 1rem for better title/body alignment 2025-10-23 15:55:00 +02:00
Gigi
85d816b2a7 style: increase horizontal padding in reader on mobile for better readability 2025-10-23 15:52:36 +02:00
Gigi
623bee4632 fix: timestamp in highlight cards now opens content in app instead of external search 2025-10-23 09:44:20 +02:00
Gigi
e68b97bde8 fix: add equal right padding to blockquotes for better mobile layout 2025-10-23 09:41:25 +02:00
Gigi
ca32dfca51 perf: reduce reading position throttle from 3s to 1s
Reading position now saves every 1 second during continuous scrolling
instead of every 3 seconds, providing more frequent position updates.
2025-10-23 01:04:54 +02:00
Gigi
9de8b00d5d chore: remove remaining console.log statements from reading position code
Removed all debug logs from readingPositionService.ts that were left
from the stabilization collector implementation.
2025-10-23 00:56:53 +02:00
Gigi
033ef5e995 fix: relay article link now opens via /a/ path instead of /r/
Updated handleLinkClick in PWASettings to check if URL is an internal
route (starts with /) and navigate directly, otherwise wrap external
URLs with /r/ path. This fixes the third relay education link to open
the nostr article correctly.
2025-10-23 00:54:29 +02:00
Gigi
c986b0d517 feat: add setting to control auto-scroll to reading position
- Added autoScrollToReadingPosition setting (enabled by default)
- Users can now disable auto-scroll while keeping position sync enabled
- Setting appears in Layout & Behavior section of settings
- Auto-scroll only happens when both syncReadingPosition and
  autoScrollToReadingPosition are enabled
2025-10-23 00:52:51 +02:00
Gigi
1729a5b066 chore: remove debug logs from reading position code 2025-10-23 00:51:01 +02:00
Gigi
c6186ea84e docs: update CHANGELOG for v0.10.16 2025-10-23 00:48:24 +02:00
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
11 changed files with 166 additions and 173 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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

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
}
})
}
}
@@ -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)}

View File

@@ -44,6 +44,7 @@ const DEFAULT_SETTINGS: UserSettings = {
fullWidthImages: true,
renderVideoLinksAsEmbeds: true,
syncReadingPosition: true,
autoScrollToReadingPosition: true,
autoMarkAsReadOnCompletion: false,
hideBookmarksWithoutCreationDate: true,
ttsUseSystemLanguage: false,

View File

@@ -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

View File

@@ -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 () => {

View File

@@ -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
}
}

View File

@@ -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()

View File

@@ -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

View File

@@ -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; }