Compare commits

..

53 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
Gigi
29746f1042 chore: bump version to 0.10.15 2025-10-23 00:24:49 +02:00
Gigi
829ec4bf6e fix(reading-position): fix infinite loop causing spam saves
The root cause was scheduleSave being in the scroll effect's dependency array.
Even though scheduleSave had an empty dependency array, React still saw it as
a dependency and re-ran the effect constantly, causing unmount/remount loops
and triggering flush-on-unmount repeatedly.

Solution: Store scheduleSave in a ref (scheduleSaveRef) and call it via the ref
in the scroll handler. This removes scheduleSave from the effect dependencies
while still allowing the scroll handler to access the latest version.

This fixes the "Maximum update depth exceeded" error and stops the spam saves.
2025-10-23 00:20:55 +02:00
Gigi
30ae0d9dfb fix(reading-position): prevent spam saves during scroll animation
The issue was that scheduleSave and saveNow had syncEnabled/onSave in their
dependency arrays, causing them to be recreated when those props changed.
This triggered the scroll effect to unmount/remount repeatedly during smooth
scroll animations, flushing saves on each unmount.

Solution: Use refs (syncEnabledRef, onSaveRef) for all callback dependencies,
making scheduleSave and saveNow stable with empty dependency arrays. This
prevents effect re-runs and stops the save spam.

Now the scroll effect only runs once per article load, not on every render.
2025-10-23 00:19:04 +02:00
Gigi
8924f1b307 fix(reading-position): flush pending saves on unmount
Previously, if user navigated away within the 3-second debounce window,
the pending save would be canceled and reading progress would be lost.

Now flushes any pending save on unmount if:
- There's a pending save timer active
- Position has changed by at least 5% since last save
- Not currently in suppression window (e.g., during restore)

This ensures reading progress is always saved even when navigating away
quickly, while still avoiding the 0% save issue from back navigation
(which doesn't trigger scroll events that would set up a pending save).

Uses refs to stabilize cleanup function and avoid effect re-runs.
2025-10-23 00:16:51 +02:00
Gigi
f92fa2cc93 fix(reading-position): prevent 0% saves during back navigation
Removed save-on-unmount behavior that was causing 0% position saves when
using mobile back gesture. The browser scrolls to top during navigation,
triggering a position update to 0% before unmount, which then gets saved.

The auto-save with 3-second debounce already captures position during
normal reading, so saving on unmount is unnecessary and error-prone.

Fixes issue where back gesture on mobile would overwrite reading progress.
2025-10-23 00:15:21 +02:00
Gigi
cc70b533e5 refactor(reading-position): use pre-loaded data from controller
Instead of fetching reading position from scratch using collectReadingPositionsOnce,
now uses the position already loaded by readingProgressController and displayed on cards.

Benefits:
- Faster restore (no network wait)
- Simpler code (no stabilization window needed)
- Data consistency (same data shown on card and used for restore)
- Reduced relay queries
2025-10-23 00:06:35 +02:00
Gigi
003c439658 feat(reading-position): restore smooth animated scroll
Changed scroll behavior from 'auto' to 'smooth' when restoring reading position for better UX.
2025-10-23 00:05:05 +02:00
Gigi
019958073c fix(lint): add missing dependencies to restore effect
Added isTrackingEnabled and restoreKey to dependency array to satisfy react-hooks/exhaustive-deps rule.
2025-10-23 00:04:33 +02:00
Gigi
3d47dddbd2 refactor(reading): simplify back to basics, remove complex timing logic
Removed:
- isTrackingEnabled state and delays
- Complex composite keys
- Verbose debug logging
- isTrackingEnabledRef checks

Back to simple:
- isTextContent = basic check (loading, content exists, not video)
- Restore once per articleIdentifier
- Save on unmount
- Suppression during restore window

Much simpler, closer to original working version.
2025-10-23 00:02:26 +02:00
Gigi
cabf897df8 fix(reading): stabilize tracking enabled state to prevent reset loops
Split tracking enable logic into two effects:
1. Reset to false when article changes (selectedUrl)
2. Enable after 500ms if isTextContent is true AND not already enabled

Prevents isTextContent flipping from resetting the timer repeatedly, which was preventing isTrackingEnabled from ever becoming true.
2025-10-22 23:59:06 +02:00
Gigi
4801c0d621 debug(reading): add detailed logging to restore effect
Add comprehensive debug log showing all dependency states to diagnose why restore never initiates.
2025-10-22 23:57:20 +02:00
Gigi
ae76d6e4ea chore(reading): remove noisy debounce log messages
Removed 'Debouncing save for 3000ms' logs that spam console on every scroll event. Keep only the actual save execution logs.
2025-10-22 23:55:59 +02:00
Gigi
a611e99ff6 fix(reading): only saveNow on unmount if tracking was enabled
Prevents saving 0% position when navigating away before tracking starts. Now checks isTrackingEnabledRef before calling saveNow() in unmount effect.
2025-10-22 23:53:50 +02:00
Gigi
1c039e164f fix(reading): wait for tracking to be enabled before attempting restore
Use composite key (articleIdentifier + isTrackingEnabled) to ensure restore only happens once after:
1. Article loads
2. Content is validated (long enough)
3. 500ms stability delay passes
4. Tracking is enabled

Prevents multiple rapid restore attempts during initial load.
2025-10-22 23:50:50 +02:00
Gigi
ffa4b38106 fix(reading): reset restore attempt tracker when article changes
Previously, if restore was skipped due to missing dependencies (content not loaded), it would never retry even after content loaded. Now resets the attempt tracker whenever articleIdentifier changes, allowing retry when dependencies become available.
2025-10-22 23:49:07 +02:00
Gigi
3b22cb5c5d feat(reading): only track position on loaded, long-enough content
- Check content length before enabling tracking (uses existing 1000 char minimum)
- Wait 500ms after content loads before enabling tracking (ensures stability)
- Prevents tracking on short notes and during page load transitions
- isTextContent now uses useMemo with comprehensive checks
2025-10-22 23:46:19 +02:00
Gigi
7bc4522be4 fix(reading): prevent false 100% detection during page transitions
Add guards to prevent detecting 100% completion when:
- Document height is < 100px (likely during transition)
- scrollTop is < 100px (not actually scrolled)

Prevents accidentally saving 100% when navigating away at 50%.
2025-10-22 23:35:55 +02:00
Gigi
048e0d802b fix(reading): make saveNow respect suppression flag
saveNow() was bypassing suppression, causing 0% to overwrite saved positions during restore. Now checks suppressUntilRef before saving, just like the debounced auto-save.
2025-10-22 23:33:31 +02:00
Gigi
b282bc4972 fix(reading): suppress saves during restore to prevent overwriting
- Suppress saves for 1700ms when restore starts (covers collection + render time)
- If no position found or delta too small, clear suppression immediately
- If restore happens, extend suppression for 1.5s after scroll
- Prevents 0% from overwriting saved 22% position during page load
2025-10-22 23:31:47 +02:00
Gigi
c1a23c1f8f fix(reading): prevent restore effect from restarting during content load
Track whether we've already attempted restore for each article using a ref. Prevents the effect from restarting multiple times as html/markdown/loading state changes during initial page load, which was stopping the stabilization timer before it could complete.
2025-10-22 23:29:29 +02:00
Gigi
8a5aacfe7b feat(reading): allow saving 0% position for open tracking
Removed the check that prevented saving 0% positions. Now tracks when articles are opened, even if not read yet. Useful for engagement metrics and history.
2025-10-22 23:28:28 +02:00
Gigi
9126910de5 fix(reading): stabilize restore effect and prevent 0% saves
- Use ref for suppressSavesFor to prevent restore effect from restarting on every position change
- Skip saving positions at 0% (meaningless start position)
- Restore effect now only restarts when article actually changes, not on every scroll
2025-10-22 23:24:56 +02:00
Gigi
496bbc36f4 fix(reading): prevent saveNow from firing on every position change
The unmount effect had saveNow in its dependency array. Since saveNow is a useCallback that depends on position, it was recreated on every scroll event, triggering the effect cleanup and calling saveNow() repeatedly (every ~14ms).

Now using a ref to store the latest saveNow callback, so the cleanup only runs when selectedUrl changes (i.e., when actually navigating away).
2025-10-22 23:22:16 +02:00
Gigi
90f25420b2 debug(reading): add ISO timestamps to all position logs
Makes it easy to see exact timing of saves and identify if debouncing is working correctly.
2025-10-22 23:21:11 +02:00
Gigi
9167134a89 refactor(reading): increase debounce to 3 seconds 2025-10-22 23:18:40 +02:00
Gigi
b5717f1ebf docs: update CHANGELOG with simplified debouncing 2025-10-22 23:17:53 +02:00
Gigi
0c8eaaf220 refactor(reading): simplify save debouncing to 2s max
Replace complex interval logic with simple 2-second debounce. Every scroll event resets the timer, so saves only happen after 2s of no scrolling (or when reaching 100%). Much less aggressive than the previous 15s minimum interval.
2025-10-22 23:17:34 +02:00
Gigi
80b2720838 docs: update CHANGELOG with debug logging addition 2025-10-22 23:14:47 +02:00
Gigi
ea69740fc8 debug(reading): add comprehensive logging for position restore and save
Add detailed console logs to trace:
- Position collection and stabilization
- Save scheduling, suppression, and execution
- Restore calculations and decisions
- Scroll deltas and thresholds

Logs use [reading-position] prefix with emoji indicators for easy filtering and visual scanning.
2025-10-22 23:14:29 +02:00
Gigi
d650997ff9 docs: update CHANGELOG with reading restore stabilization 2025-10-22 23:06:54 +02:00
Gigi
ba3554b173 feat(reading): one-shot restore with suppression in ContentPanel
Replace continuous restore with stabilized one-shot collector. Suppress saves for 1.5s after restore, skip tiny deltas (<48px or <5%), and use instant scroll (behavior: auto) to eliminate jumpy view behavior from conflicting relay updates.
2025-10-22 23:06:34 +02:00
Gigi
2cc39d0200 feat(reading): add stabilized position collector in readingPositionService
Add collectReadingPositionsOnce() that buffers position updates for ~700ms, then emits the best one (newest timestamp, tie-break by highest progress). Prevents jumpy scrolling from conflicting relay updates.
2025-10-22 23:05:50 +02:00
Gigi
9aa914a704 feat(reading): add save suppression to useReadingPosition
Add suppressSavesFor(ms) API to temporarily block auto-saves after programmatic scrolls, preventing feedback loops where restore triggers save which triggers restore.
2025-10-22 23:05:11 +02:00
Gigi
497b6fa4be docs: update CHANGELOG for v0.10.14 2025-10-22 15:49:23 +02:00
Gigi
4c838b0123 chore: bump version to 0.10.14 2025-10-22 15:48:36 +02:00
Gigi
d551f66ef1 feat: add Relay Setup 101 article link to PWA settings
- Added third relay education article link in PWA settings
- Links to /a/naddr1qvzqqqr4gupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqq9hyetvv9uj6um9w36hq9mgjg8
- Updated punctuation to use commas for better readability (here, here, and here)
2025-10-22 15:47:26 +02:00
Gigi
34514199ee feat: timestamp in cards now opens content in app instead of external search
- Changed timestamp links in CardView and LargeView to use internal routes
- Articles (kind:30023) open in /a/{naddr}
- Notes (kind:1) open in /e/{eventId}
- External URLs open in /r/{encodedUrl}
- Removed unused eventNevent prop and neventEncode import
- Timestamp now uses Link component for client-side navigation
2025-10-22 15:46:25 +02:00
Gigi
228304f68a fix: prevent duplicate video embeds and stray HTML artifacts
- Refactored VideoEmbedProcessor to process HTML and extract URLs in single pass
- Previously processedHtml and videoUrls were computed separately, causing index mismatches
- Now both are computed together ensuring placeholders match collected URLs
- Added check to skip empty HTML parts to prevent rendering stray characters
2025-10-22 15:42:19 +02:00
Gigi
ba263acdff fix: stop highlights loading spinner when article has no highlights 2025-10-22 15:40:18 +02:00
Gigi
5131cbe12c docs: update CHANGELOG for v0.10.13 2025-10-22 15:38:08 +02:00
12 changed files with 508 additions and 217 deletions

View File

@@ -7,6 +7,95 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [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
### Added
- Third relay education article link in PWA settings
- Added "Relay Setup 101" article to relay information section
- Now links to three educational resources about relays
### Changed
- Timestamp links in bookmark cards now navigate within app
- Articles (kind:30023) open in `/a/{naddr}` route
- Notes (kind:1) open in `/e/{eventId}` route
- External URLs open in `/r/{encodedUrl}` route
- Uses React Router Link for client-side navigation instead of external search
- Relay article links punctuation improved for better readability
- Changed from "here and here" to "here, here, and here"
### Fixed
- Duplicate video embeds and stray HTML artifacts eliminated
- VideoEmbedProcessor now processes HTML and extracts URLs in single pass
- Placeholder indices now correctly match collected video URLs
- Empty HTML parts no longer rendered, preventing stray characters like `">`
- Highlights loading spinner no longer spins forever when article has zero highlights
- Loading state properly cleared when no highlights exist
- "No highlights" message displays immediately
## [0.10.13] - 2025-01-27
### Added
- Instant article preview when navigating from blog post cards
- Title, image, summary, and date display immediately via navigation state
- No skeleton loading for metadata already visible on cards
- Article content loads seamlessly in background from eventStore or relays
- Reliable relay fallback for article fetching
- Queries nostr.band, primal, damus, and nos.lol if initial fetch returns no events
- Reduces "Article not found" errors
### Changed
- Article loading now follows local-first controller pattern
- Uses eventStore and queryEvents for streaming results
- Emits content immediately on first event from store or local relays
- Finalizes with newest version after EOSE (no artificial timeouts)
- Background relay query continues to check for updates
- Service Worker now only registers in production builds
- Disabled in development to avoid stale cache issues
- Preserves PWA functionality in production
- Article fetching queries union of naddr relay hints and configured relays
- Prevents failures when naddr contains stale or unreachable relay hints
- Maintains fast local/hinted paths with reliable fallback
### Fixed
- Article loading race conditions eliminated
- Request ID guards prevent stale fetches from overwriting current content
- Stale highlights from previous articles no longer appear
- Content/title mismatch when switching articles resolved
- Markdown preview clears immediately on content change
- Forced re-mount of rendered HTML per article via stable content keys
- Request guards in external URL loader prevent cross-article bleed
- Article re-fetching on settings changes prevented
- Settings memoized via ref to avoid triggering effect dependencies
- Explore writings tab now shows skeletons instead of spinner when loading
- Consistent loading UI across all views
## [0.10.12] - 2025-01-27
### Added

View File

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

View File

@@ -4,7 +4,7 @@ import { faGlobe, faLink } from '@fortawesome/free-solid-svg-icons'
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import { npubEncode, neventEncode } from 'nostr-tools/nip19'
import { npubEncode } from 'nostr-tools/nip19'
import { IndividualBookmark } from '../types/bookmarks'
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
import { classifyUrl } from '../utils/helpers'
@@ -58,8 +58,6 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
// Resolve author profile using applesauce
const authorProfile = useEventModel(Models.ProfileModel, [bookmark.pubkey])
const authorNpub = npubEncode(bookmark.pubkey)
const isHexId = /^[0-9a-f]{64}$/i.test(bookmark.id)
const eventNevent = isHexId ? neventEncode({ id: bookmark.id }) : undefined
// Get display name for author
const getAuthorDisplayName = () => {
@@ -135,7 +133,6 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
extractedUrls,
onSelectUrl,
authorNpub,
eventNevent,
getAuthorDisplayName,
handleReadNow,
articleImage,
@@ -152,7 +149,6 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
extractedUrls,
onSelectUrl,
authorNpub,
eventNevent,
getAuthorDisplayName,
handleReadNow,
articleSummary,

View File

@@ -9,7 +9,7 @@ import RichContent from '../RichContent'
import { classifyUrl } from '../../utils/helpers'
import { useImageCache } from '../../hooks/useImageCache'
import { getPreviewImage, fetchOgImage } from '../../utils/imagePreview'
import { getEventUrl } from '../../config/nostrGateways'
import { naddrEncode } from 'nostr-tools/nip19'
interface CardViewProps {
bookmark: IndividualBookmark
@@ -18,7 +18,6 @@ interface CardViewProps {
extractedUrls: string[]
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
authorNpub: string
eventNevent?: string
getAuthorDisplayName: () => string
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
articleImage?: string
@@ -34,7 +33,6 @@ export const CardView: React.FC<CardViewProps> = ({
extractedUrls,
onSelectUrl,
authorNpub,
eventNevent,
getAuthorDisplayName,
handleReadNow,
articleImage,
@@ -82,6 +80,29 @@ export const CardView: React.FC<CardViewProps> = ({
}
}
// Get internal route for the bookmark
const getInternalRoute = (): string | null => {
if (bookmark.kind === 30023) {
// Nostr-native article - use /a/ route
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
if (dTag) {
const naddr = naddrEncode({
kind: bookmark.kind,
pubkey: bookmark.pubkey,
identifier: dTag
})
return `/a/${naddr}`
}
} else if (bookmark.kind === 1) {
// Note - use /e/ route
return `/e/${bookmark.id}`
} else if (firstUrl) {
// External URL - use /r/ route
return `/r/${encodeURIComponent(firstUrl)}`
}
return null
}
return (
<div
key={`${bookmark.id}-${index}`}
@@ -103,17 +124,15 @@ export const CardView: React.FC<CardViewProps> = ({
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
</span>
{eventNevent ? (
<a
href={getEventUrl(eventNevent)}
target="_blank"
rel="noopener noreferrer"
{getInternalRoute() ? (
<Link
to={getInternalRoute()!}
className="bookmark-date-link"
title="Open event in search"
title="Open in app"
onClick={(e) => e.stopPropagation()}
>
{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}
</a>
</Link>
) : (
<span className="bookmark-date">{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}</span>
)}

View File

@@ -7,7 +7,7 @@ import { formatDate } from '../../utils/bookmarkUtils'
import RichContent from '../RichContent'
import { IconGetter } from './shared'
import { useImageCache } from '../../hooks/useImageCache'
import { getEventUrl } from '../../config/nostrGateways'
import { naddrEncode } from 'nostr-tools/nip19'
interface LargeViewProps {
bookmark: IndividualBookmark
@@ -18,7 +18,6 @@ interface LargeViewProps {
getIconForUrlType: IconGetter
previewImage: string | null
authorNpub: string
eventNevent?: string
getAuthorDisplayName: () => string
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
articleSummary?: string
@@ -35,7 +34,6 @@ export const LargeView: React.FC<LargeViewProps> = ({
getIconForUrlType,
previewImage,
authorNpub,
eventNevent,
getAuthorDisplayName,
handleReadNow,
articleSummary,
@@ -63,6 +61,30 @@ export const LargeView: React.FC<LargeViewProps> = ({
}
}
// Get internal route for the bookmark
const getInternalRoute = (): string | null => {
const firstUrl = hasUrls ? extractedUrls[0] : null
if (bookmark.kind === 30023) {
// Nostr-native article - use /a/ route
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
if (dTag) {
const naddr = naddrEncode({
kind: bookmark.kind,
pubkey: bookmark.pubkey,
identifier: dTag
})
return `/a/${naddr}`
}
} else if (bookmark.kind === 1) {
// Note - use /e/ route
return `/e/${bookmark.id}`
} else if (firstUrl) {
// External URL - use /r/ route
return `/r/${encodeURIComponent(firstUrl)}`
}
return null
}
return (
<div
key={`${bookmark.id}-${index}`}
@@ -136,16 +158,17 @@ export const LargeView: React.FC<LargeViewProps> = ({
</Link>
</span>
{eventNevent && (
<a
href={getEventUrl(eventNevent)}
target="_blank"
rel="noopener noreferrer"
{getInternalRoute() ? (
<Link
to={getInternalRoute()!}
className="bookmark-date-link"
title="Open in app"
onClick={(e) => e.stopPropagation()}
>
{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}
</a>
</Link>
) : (
<span className="bookmark-date">{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}</span>
)}
{/* CTA removed */}

View File

@@ -43,9 +43,9 @@ import { EventFactory } from 'applesauce-factory'
import { Hooks } from 'applesauce-react'
import {
generateArticleIdentifier,
saveReadingPosition,
startReadingPositionStream
saveReadingPosition
} from '../services/readingPositionService'
import { readingProgressController } from '../services/readingProgressController'
import TTSControls from './TTSControls'
interface ContentPanelProps {
@@ -150,8 +150,25 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
// Get event store for reading position service
const eventStore = Hooks.useEventStore()
// Reading position tracking - only for text content, not videos
const isTextContent = !loading && !!(markdown || html) && !selectedUrl?.includes('youtube') && !selectedUrl?.includes('vimeo')
// 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])
// Generate article identifier for saving/loading position
const articleIdentifier = useMemo(() => {
@@ -159,23 +176,35 @@ 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)) {
if (!shouldTrackReadingProgress(htmlRef.current, markdownRef.current)) {
console.log('[reading-position] ⚠️ Save skipped: content too short')
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,
@@ -188,13 +217,43 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
scrollTop
}
)
console.log(`[reading-position] [${new Date().toISOString()}] ✅ Position published successfully`)
} catch (error) {
console.error('[progress] ❌ ContentPanel: Failed to save reading position:', 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])
const { progressPercentage, saveNow } = useReadingPosition({
enabled: isTextContent,
// Delay enabling position tracking to ensure content is stable
const [isTrackingEnabled, setIsTrackingEnabled] = useState(false)
// Reset tracking when article changes
useEffect(() => {
setIsTrackingEnabled(false)
}, [selectedUrl])
// Enable/disable tracking based on content state
useEffect(() => {
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')
setIsTrackingEnabled(true)
}, 500)
return () => clearTimeout(timer)
}
}, [isTextContent, isTrackingEnabled])
const { progressPercentage, suppressSavesFor } = useReadingPosition({
enabled: isTrackingEnabled,
syncEnabled: settings?.syncReadingPosition !== false,
onSave: handleSavePosition,
onReadingComplete: () => {
@@ -214,48 +273,109 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
useEffect(() => {
}, [isTextContent, settings?.syncReadingPosition, activeAccount, relayPool, eventStore, articleIdentifier, progressPercentage])
// Load saved reading position when article loads (non-blocking, EOSE-driven)
// Load saved reading position when article loads (using pre-loaded data from controller)
const suppressSavesForRef = useRef(suppressSavesFor)
useEffect(() => {
if (!isTextContent || !activeAccount || !relayPool || !eventStore || !articleIdentifier) {
suppressSavesForRef.current = suppressSavesFor
}, [suppressSavesFor])
// Track if we've successfully started restore for this article + tracking state
// Use a composite key to ensure we only restore once per article when tracking is enabled
const restoreKey = `${articleIdentifier}-${isTrackingEnabled}`
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 (!isTrackingEnabled) {
console.log('[reading-position] ⏭️ Restore skipped: tracking not yet enabled (waiting for content stability)')
return
}
const stop = startReadingPositionStream(
relayPool,
eventStore,
activeAccount.pubkey,
articleIdentifier,
(savedPosition) => {
if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) {
// Wait for content to be fully rendered before scrolling
setTimeout(() => {
const documentHeight = document.documentElement.scrollHeight
const windowHeight = window.innerHeight
const scrollTop = savedPosition.position * (documentHeight - windowHeight)
window.scrollTo({
top: scrollTop,
behavior: 'smooth'
})
}, 500) // Give content time to render
}
}
)
return () => stop()
}, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
// Save position before unmounting or changing article
useEffect(() => {
return () => {
if (saveNow) {
saveNow()
}
// 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
}
}, [saveNow, selectedUrl])
console.log('[reading-position] 🔄 Initiating restore for article:', articleIdentifier)
// Mark as attempted using composite key
hasAttemptedRestoreRef.current = restoreKey
// Get the saved position from the controller (already loaded and displayed on card)
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 smooth scroll = 1500ms)
if (suppressSavesForRef.current) {
suppressSavesForRef.current(1500)
}
// Wait for content to be fully rendered
setTimeout(() => {
const docH = document.documentElement.scrollHeight
const winH = window.innerHeight
const maxScroll = Math.max(0, docH - winH)
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)
}
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])
// 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
// 3. Position state may not reflect actual reading position during navigation
// Close menu when clicking outside
useEffect(() => {

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

@@ -151,7 +151,7 @@ const PWASettings: React.FC<PWASettingsProps> = ({ settings, onUpdate, onClose }
>
here
</a>
{' and '}
{', '}
<a
onClick={(e) => {
e.preventDefault()
@@ -161,6 +161,16 @@ const PWASettings: React.FC<PWASettingsProps> = ({ settings, onUpdate, onClose }
>
here
</a>
{', and '}
<a
onClick={(e) => {
e.preventDefault()
handleLinkClick('/a/naddr1qvzqqqr4gupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqq9hyetvv9uj6um9w36hq9mgjg8')
}}
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
>
here
</a>
.
</p>
</div>

View File

@@ -21,9 +21,10 @@ const VideoEmbedProcessor = forwardRef<HTMLDivElement, VideoEmbedProcessorProps>
onMouseUp,
onTouchEnd
}, ref) => {
const processedHtml = useMemo(() => {
// Process HTML and extract video URLs in a single pass to keep them in sync
const { processedHtml, videoUrls } = useMemo(() => {
if (!renderVideoLinksAsEmbeds || !html) {
return html
return { processedHtml: html, videoUrls: [] }
}
// Process HTML in stages: <video> blocks, <img> tags with video src, and bare video URLs
@@ -86,71 +87,19 @@ const VideoEmbedProcessor = forwardRef<HTMLDivElement, VideoEmbedProcessorProps>
const remainingUrls = [...fileVideoUrls, ...platformVideoUrls].filter(url => !collectedUrls.includes(url))
let processedHtml = result
let finalHtml = result
remainingUrls.forEach((url) => {
const placeholder = `__VIDEO_EMBED_${placeholderIndex}__`
processedHtml = processedHtml.replace(new RegExp(url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), placeholder)
finalHtml = finalHtml.replace(new RegExp(url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), placeholder)
collectedUrls.push(url)
placeholderIndex++
})
// If nothing collected, return original html
if (collectedUrls.length === 0) {
return html
// Return both processed HTML and collected URLs (in the same order as placeholders)
return {
processedHtml: collectedUrls.length > 0 ? finalHtml : html,
videoUrls: collectedUrls
}
return processedHtml
}, [html, renderVideoLinksAsEmbeds])
const videoUrls = useMemo(() => {
if (!renderVideoLinksAsEmbeds || !html) {
return []
}
const urls: string[] = []
// 1) Extract from <video> blocks first (video src or nested source src)
const videoBlockPattern = /<video[^>]*>[\s\S]*?<\/video>/gi
const videoBlocks = html.match(videoBlockPattern) || []
videoBlocks.forEach((block) => {
let url: string | null = null
const videoSrcMatch = block.match(/<video[^>]*\s+src=["']?(https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)[^\s<>"']*)["']?[^>]*>/i)
if (videoSrcMatch && videoSrcMatch[1]) {
url = videoSrcMatch[1]
} else {
const sourceSrcMatch = block.match(/<source[^>]*\s+src=["']?(https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)[^\s<>"']*)["']?[^>]*>/i)
if (sourceSrcMatch && sourceSrcMatch[1]) {
url = sourceSrcMatch[1]
}
}
if (url && !urls.includes(url)) urls.push(url)
})
// 2) Extract from <img> tags with video src
const imgTagPattern = /<img[^>]*>/gi
const allImgTags = html.match(imgTagPattern) || []
allImgTags.forEach((imgTag) => {
const srcMatch = imgTag.match(/src=["']?(https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)[^\s<>"']*)["']?/i)
if (srcMatch && srcMatch[1] && !urls.includes(srcMatch[1])) {
urls.push(srcMatch[1])
}
})
// 3) Extract remaining direct file URLs and platform-classified video URLs
const fileVideoPattern = /https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)(?:\?[^\s<>"']*)?/gi
const fileVideoUrls: string[] = html.match(fileVideoPattern) || []
fileVideoUrls.forEach(u => { if (!urls.includes(u)) urls.push(u) })
const allUrlPattern = /https?:\/\/[^\s<>"']+(?=\s|>|"|'|$)/gi
const allUrls: string[] = html.match(allUrlPattern) || []
allUrls.forEach(u => {
const classification = classifyUrl(u)
if (classification.type === 'video' && !urls.includes(u)) {
urls.push(u)
}
})
return urls
}, [html, renderVideoLinksAsEmbeds])
// If no video embedding is enabled, just render the HTML normally
@@ -195,13 +144,16 @@ const VideoEmbedProcessor = forwardRef<HTMLDivElement, VideoEmbedProcessorProps>
}
}
// Regular HTML content
return (
<div
key={index}
dangerouslySetInnerHTML={{ __html: part }}
/>
)
// Regular HTML content - only render if not empty
if (part.trim()) {
return (
<div
key={index}
dangerouslySetInnerHTML={{ __html: part }}
/>
)
}
return null
})}
</div>
)

View File

@@ -229,13 +229,15 @@ export function useArticleLoader({
// Fetch highlights after content is shown
try {
if (!mountedRef.current) return
setHighlightsLoading(true)
setHighlights([])
const le = latestEvent as NostrEvent | null
const dTag = le ? (le.tags.find((t: string[]) => t[0] === 'd')?.[1] || '') : ''
const coord = le && dTag ? `${le.kind}:${le.pubkey}:${dTag}` : undefined
const eventId = le ? le.id : undefined
if (coord && eventId) {
setHighlightsLoading(true)
setHighlights([])
await fetchHighlightsForArticle(
relayPool,
coord,
@@ -251,6 +253,10 @@ export function useArticleLoader({
},
settingsRef.current
)
} else {
// No article event to fetch highlights for - clear and don't show loading
setHighlights([])
setHighlightsLoading(false)
}
} catch (err) {
console.error('Failed to fetch highlights:', err)

View File

@@ -7,7 +7,6 @@ interface UseReadingPositionOptions {
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
autoSaveInterval?: number // Auto-save interval in ms (default 5000)
completionHoldMs?: number // How long to hold at 100% before firing complete (default 2000)
}
@@ -18,98 +17,66 @@ export const useReadingPosition = ({
readingCompleteThreshold = 0.95, // Match filter threshold for consistency
syncEnabled = false,
onSave,
autoSaveInterval = 5000,
completionHoldMs = 2000
}: UseReadingPositionOptions = {}) => {
const [position, setPosition] = useState(0)
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 pendingPositionRef = useRef<number>(0) // Track latest position for throttled save
const lastSaved100Ref = useRef(false) // Track if we've saved 100% to avoid duplicate saves
// Debounced save function
// 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()}`)
}, [])
// Throttled save function - saves at 3s intervals during scrolling
const scheduleSave = useCallback((currentPosition: number) => {
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
}
lastSavedPosition.current = 1
hasSavedOnce.current = true
lastSavedAtRef.current = Date.now()
console.log(`[reading-position] [${new Date().toISOString()}] 💾 Instant save at 100% completion`)
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
// Enforce a minimum interval between saves (15s) to avoid spamming
const MIN_INTERVAL_MS = 15000
const nowMs = Date.now()
const enoughTimeElapsed = nowMs - lastSavedAtRef.current >= MIN_INTERVAL_MS
// Allow the very first meaningful save (when crossing 5%) regardless of interval
const isFirstMeaningful = !hasSavedOnce.current && currentPosition >= MIN_DELTA
if (!hasSignificantChange && !isFirstMeaningful) {
return
}
// If interval hasn't elapsed yet, delay until autoSaveInterval but still cap frequency
if (!enoughTimeElapsed && !isFirstMeaningful) {
// Clear and reschedule within the remaining window, but not sooner than MIN_INTERVAL_MS
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current)
}
const remaining = Math.max(0, MIN_INTERVAL_MS - (nowMs - lastSavedAtRef.current))
const delay = Math.max(autoSaveInterval, remaining)
saveTimerRef.current = setTimeout(() => {
lastSavedPosition.current = currentPosition
hasSavedOnce.current = true
lastSavedAtRef.current = Date.now()
onSave(currentPosition)
}, delay)
return
}
// Clear existing timer
// 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
}
// Schedule new save using the larger of autoSaveInterval and MIN_INTERVAL_MS
const delay = Math.max(autoSaveInterval, MIN_INTERVAL_MS)
const THROTTLE_MS = 3000
console.log(`[reading-position] [${new Date().toISOString()}] ⏰ Scheduling save in ${THROTTLE_MS}ms`)
saveTimerRef.current = setTimeout(() => {
lastSavedPosition.current = currentPosition
hasSavedOnce.current = true
lastSavedAtRef.current = Date.now()
onSave(currentPosition)
}, delay)
}, [syncEnabled, onSave, autoSaveInterval])
// Immediate save function
const saveNow = useCallback(() => {
if (!syncEnabled || !onSave) return
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current)
// 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
}
lastSavedPosition.current = position
hasSavedOnce.current = true
lastSavedAtRef.current = Date.now()
onSave(position)
}, [syncEnabled, onSave, position])
}, THROTTLE_MS)
}, [syncEnabled, onSave])
useEffect(() => {
if (!enabled) return
@@ -123,21 +90,27 @@ export const useReadingPosition = ({
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
// Add a small threshold (5px) to account for rounding and make it easier to reach 100%
const maxScroll = documentHeight - windowHeight
const scrollProgress = maxScroll > 0 ? scrollTop / maxScroll : 0
// If we're within 5px of the bottom, consider it 100%
const isAtBottom = scrollTop + windowHeight >= documentHeight - 5
// 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
onPositionChange?.(clampedProgress)
// Schedule auto-save if sync is enabled
scheduleSave(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) {
@@ -180,10 +153,8 @@ export const useReadingPosition = ({
window.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', handleScroll)
// Clear save timer on unmount
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.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)
}
@@ -195,8 +166,7 @@ export const useReadingPosition = ({
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
@@ -208,6 +178,6 @@ export const useReadingPosition = ({
position,
isReadingComplete,
progressPercentage: Math.round(position * 100),
saveNow
suppressSavesFor
}
}

View File

@@ -178,6 +178,101 @@ export function startReadingPositionStream(
}
}
/**
* Stabilized reading position collector
* Collects position updates for a brief window, then emits the best one (newest, then highest progress)
* @returns Object with stop() to cancel and onStable(cb) to register callback
*/
export function collectReadingPositionsOnce(params: {
relayPool: RelayPool
eventStore: IEventStore
pubkey: string
articleIdentifier: string
windowMs?: number
}): { stop: () => void; onStable: (cb: (pos: ReadingPosition | null) => void) => void } {
const { relayPool, eventStore, pubkey, articleIdentifier, windowMs = 700 } = params
const candidates: ReadingPosition[] = []
let stableCallback: ((pos: ReadingPosition | null) => void) | null = null
let timer: ReturnType<typeof setTimeout> | null = null
let streamStop: (() => void) | null = null
let hasEmitted = false
const emitStable = () => {
if (hasEmitted || !stableCallback) return
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
if (timeDiff !== 0) return timeDiff
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,
pubkey,
articleIdentifier,
(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()
}, windowMs)
}
}
)
return {
stop: () => {
if (timer) {
clearTimeout(timer)
timer = null
}
if (streamStop) {
streamStop()
streamStop = null
}
},
onStable: (cb) => {
stableCallback = cb
}
}
}
/**
* Load reading position from Nostr (kind 39802)
* @deprecated Use startReadingPositionStream for non-blocking behavior