import { useEffect, useRef, useState, useCallback } from 'react' interface UseReadingPositionOptions { enabled?: boolean onPositionChange?: (position: number) => void onReadingComplete?: () => void readingCompleteThreshold?: number // Default 0.95 (95%) - matches filter threshold syncEnabled?: boolean // Whether to sync positions to Nostr onSave?: (position: number) => void // Callback for saving position completionHoldMs?: number // How long to hold at 100% before firing complete (default 2000) } export const useReadingPosition = ({ enabled = true, onPositionChange, onReadingComplete, readingCompleteThreshold = 0.95, // Match filter threshold for consistency syncEnabled = false, onSave, completionHoldMs = 2000 }: UseReadingPositionOptions = {}) => { const [position, setPosition] = useState(0) const positionRef = useRef(0) const [isReadingComplete, setIsReadingComplete] = useState(false) const hasTriggeredComplete = useRef(false) const saveTimerRef = useRef | null>(null) const completionTimerRef = useRef | null>(null) const suppressUntilRef = useRef(0) const pendingPositionRef = useRef(0) // Track latest position for throttled save const lastSaved100Ref = useRef(false) // Track if we've saved 100% to avoid duplicate saves // Store callbacks in refs to avoid them being dependencies const onPositionChangeRef = useRef(onPositionChange) const onReadingCompleteRef = useRef(onReadingComplete) const onSaveRef = useRef(onSave) useEffect(() => { onPositionChangeRef.current = onPositionChange onReadingCompleteRef.current = onReadingComplete onSaveRef.current = onSave }, [onPositionChange, onReadingComplete, onSave]) // Suppress auto-saves for a given duration (used after programmatic restore) const suppressSavesFor = useCallback((ms: number) => { const until = Date.now() + ms suppressUntilRef.current = until }, []) // Throttled save function - saves at 1s intervals during scrolling const scheduleSave = useCallback((currentPosition: number) => { if (!syncEnabled || !onSaveRef.current) { return } // Always save instantly when we reach completion (1.0) if (currentPosition === 1 && !lastSaved100Ref.current) { if (saveTimerRef.current) { clearTimeout(saveTimerRef.current) saveTimerRef.current = null } lastSaved100Ref.current = true onSaveRef.current(1) return } // Always update the pending position (latest position to save) pendingPositionRef.current = currentPosition // 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) { return // Already have a save scheduled, don't reset the timer } const THROTTLE_MS = 1000 saveTimerRef.current = setTimeout(() => { // Save the latest position, not the one from when timer was scheduled const positionToSave = pendingPositionRef.current onSaveRef.current?.(positionToSave) saveTimerRef.current = null }, THROTTLE_MS) }, [syncEnabled]) useEffect(() => { if (!enabled) return const handleScroll = () => { // Get the main content area (reader content) const readerContent = document.querySelector('.reader-html, .reader-markdown') if (!readerContent) return const scrollTop = window.pageYOffset || document.documentElement.scrollTop const windowHeight = window.innerHeight const documentHeight = document.documentElement.scrollHeight // Ignore if document is too small (likely during page transition) if (documentHeight < 100) return // Calculate position based on how much of the content has been scrolled through const maxScroll = documentHeight - windowHeight const scrollProgress = maxScroll > 0 ? scrollTop / maxScroll : 0 // Only consider it 100% if we're truly at the bottom AND have scrolled significantly // This prevents false 100% during page transitions const isAtBottom = scrollTop + windowHeight >= documentHeight - 5 && scrollTop > 100 const clampedProgress = isAtBottom ? 1 : Math.max(0, Math.min(1, scrollProgress)) setPosition(clampedProgress) positionRef.current = clampedProgress onPositionChangeRef.current?.(clampedProgress) // Schedule auto-save if sync is enabled (unless suppressed) if (Date.now() >= suppressUntilRef.current) { scheduleSave(clampedProgress) } // Note: Suppression is silent to avoid log spam during scrolling // Completion detection with 2s hold at 100% if (!hasTriggeredComplete.current) { // If at exact 100%, start a hold timer; cancel if we scroll up if (clampedProgress === 1) { if (!completionTimerRef.current) { completionTimerRef.current = setTimeout(() => { if (!hasTriggeredComplete.current && positionRef.current === 1) { setIsReadingComplete(true) hasTriggeredComplete.current = true onReadingCompleteRef.current?.() } completionTimerRef.current = null }, completionHoldMs) } } else { // If we moved off 100%, cancel any pending completion hold if (completionTimerRef.current) { clearTimeout(completionTimerRef.current) completionTimerRef.current = null // still allow threshold-based completion for near-bottom if configured if (clampedProgress >= readingCompleteThreshold) { setIsReadingComplete(true) hasTriggeredComplete.current = true onReadingCompleteRef.current?.() } } } } } // Initial calculation handleScroll() // Add scroll listener window.addEventListener('scroll', handleScroll, { passive: true }) window.addEventListener('resize', handleScroll, { passive: true }) return () => { window.removeEventListener('scroll', handleScroll) window.removeEventListener('resize', handleScroll) // 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, readingCompleteThreshold, scheduleSave, completionHoldMs]) // Reset reading complete state when enabled changes useEffect(() => { if (!enabled) { setIsReadingComplete(false) hasTriggeredComplete.current = false lastSaved100Ref.current = false if (completionTimerRef.current) { clearTimeout(completionTimerRef.current) completionTimerRef.current = null } } }, [enabled]) return { position, isReadingComplete, progressPercentage: Math.round(position * 100), suppressSavesFor } }