Compare commits

..

86 Commits

Author SHA1 Message Date
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
Gigi
fa8eed4f4e chore: bump version to 0.10.13 2025-10-22 15:36:42 +02:00
Gigi
3ff57c4b67 fix(lint): add previewData to useArticleLoader effect dependencies 2025-10-22 15:36:03 +02:00
Gigi
51c364ea53 feat(article): instant preview from blog cards - show title, image, summary, date immediately via navigation state while content loads 2025-10-22 15:33:37 +02:00
Gigi
4d032372dc fix(explore): show blog post skeletons instead of spinner when loading writings tab 2025-10-22 15:31:19 +02:00
Gigi
48b5aa3a30 feat(article): instant load from eventStore when clicking bookmark cards - check store by coordinate before relay query 2025-10-22 15:29:08 +02:00
Gigi
d4483a2f91 fix(lint): resolve eslint warnings in useArticleLoader - add comment for empty catch, use settingsRef consistently 2025-10-22 15:26:16 +02:00
Gigi
c62cb21962 fix(article): wire eventStore to useArticleLoader for instant local-first loads; keep SW enabled in prod for PWA 2025-10-22 15:24:24 +02:00
Gigi
3f7d726ae6 feat(article): local-first streaming loader using eventStore + queryEvents in useArticleLoader; emit immediately on first store/relay hit; finalize on EOSE 2025-10-22 15:22:39 +02:00
Gigi
ac0e5eb585 fix(article): add reliable-relay fallback (nostr.band, primal, damus, nos.lol) when first parallel query returns no events 2025-10-22 14:03:47 +02:00
Gigi
5a0dd49e4e fix(sw): disable Service Worker in dev and register non-module SW only in production to avoid stale cached HTML causing mismatched content 2025-10-22 14:01:53 +02:00
Gigi
d067193f21 fix(reader): force re-mount of markdown preview and rendered HTML per-content to eliminate stale display when switching articles 2025-10-22 13:46:57 +02:00
Gigi
774e2ba67c fix(reader): clear markdown render on change and add request guards to external URL loader to prevent stale content 2025-10-22 13:45:41 +02:00
Gigi
6f1c31058f fix(reader): guard against stale article fetches overwriting current content/highlights via requestId in useArticleLoader 2025-10-22 13:41:46 +02:00
Gigi
7551a05aee fix(article): prevent re-fetch on settings change by memoizing via ref in useArticleLoader 2025-10-22 13:38:24 +02:00
Gigi
df485b883d fix(article): query union of naddr relay hints and configured relays to prevent post-load ‘Article not found’ refresh 2025-10-22 13:35:59 +02:00
Gigi
6f428af1bc docs: update CHANGELOG for v0.10.12 2025-10-22 13:32:05 +02:00
Gigi
e821aaf058 chore: bump version to 0.10.12 2025-10-22 13:30:37 +02:00
Gigi
a84d439489 fix: properly deduplicate web bookmarks by d-tag
- Web bookmarks (kind:39701) are replaceable events and should be deduplicated by d-tag
- Update dedupeNip51Events to include kind:39701 in d-tag deduplication logic
- Use coordinate format (kind:pubkey:d-tag) for web bookmark IDs instead of event IDs
- Ensures same URL bookmarked multiple times only appears once
- Keeps newest version when duplicates exist
2025-10-22 13:28:36 +02:00
Gigi
67bf7e017d fix: make profile avatar button same size as other icon buttons on mobile
- Add mobile media query to profile-avatar-button for consistent sizing
- Use --min-touch-target (44px) on mobile to match IconButton components
- Ensures consistent touch target size across all sidebar buttons
2025-10-22 13:26:20 +02:00
Gigi
e47419a0b8 feat: update explore icon to fa-person-hiking and reorder sidebar buttons
- Change explore icon from fa-newspaper to fa-person-hiking in SidebarHeader and Explore components
- Switch positions of settings and explore buttons in sidebar navigation
- Remove all console.log statements from bookmarkController and bookmarkProcessing
- Update CHANGELOG.md with v0.10.11 changes
2025-10-22 13:25:34 +02:00
Gigi
2dda52c30f chore: bump version to 0.10.11 2025-10-22 13:19:36 +02:00
Gigi
2e0a493243 fix(bookmarks): sort by display time (created_at || listUpdatedAt) desc; nulls last 2025-10-22 13:07:37 +02:00
Gigi
2e955e9bed refactor(bookmarks): never default timestamps to now; allow nulls and sort nulls last; render empty when missing 2025-10-22 13:04:24 +02:00
Gigi
538cbd2296 fix(bookmarks): show sane dates using created_at fallback to listUpdatedAt; guard formatters 2025-10-22 12:54:26 +02:00
Gigi
c17eab5a47 fix(router): route /me/reading-list -> /me/bookmarks to render Bookmarks view 2025-10-22 12:49:23 +02:00
Gigi
b3c61ba635 fix: update Me.tsx bookmarks tab to use dynamic filter titles and chronological sorting 2025-10-22 12:46:16 +02:00
Gigi
3bfa750a0c fix: update Me.tsx to use faClock icon instead of faBars 2025-10-22 12:42:42 +02:00
Gigi
d1f7e549c2 fix: change bookmark URL from /me/reading-list to /me/bookmarks 2025-10-22 12:33:05 +02:00
Gigi
0fec120410 debug: add targeted logging to diagnose listUpdatedAt timestamp issue 2025-10-22 12:31:53 +02:00
Gigi
9b21075a9b refactor: remove excessive debug logging 2025-10-22 12:29:09 +02:00
Gigi
4f78ee4794 fix: preserve content created_at, add listUpdatedAt for sorting by when bookmarked 2025-10-22 12:26:01 +02:00
Gigi
8bb871913b refactor: remove synthetic added_at field, use created_at from bookmark list event 2025-10-22 12:18:43 +02:00
Gigi
49eb6855ca debug: add console logging for bookmark timestamp and sorting analysis 2025-10-22 12:14:36 +02:00
Gigi
748b2e1631 fix: correct added_at timestamp to use bookmark list creation time, not content creation time 2025-10-22 12:12:44 +02:00
Gigi
9fa83a2a1c fix: ensure robust sorting of merged bookmarks with fallback timestamps 2025-10-22 12:07:32 +02:00
Gigi
d45705e8e4 feat: use clock icon (regular style) for chronological bookmark view 2025-10-22 12:05:57 +02:00
Gigi
83c170b4e2 fix: ensure bookmarks are consistently sorted chronologically with useMemo 2025-10-22 12:04:41 +02:00
Gigi
8459853c43 refactor: remove bookmark count from section headings 2025-10-22 12:02:24 +02:00
Gigi
f7eeb080e1 feat: update bookmark heading based on selected filter 2025-10-22 12:01:09 +02:00
Gigi
2769b2dba7 fix: remove unused faTimes import 2025-10-22 11:51:17 +02:00
Gigi
46636b8e6a feat: move profile picture to first position (left-aligned) with consistent sizing 2025-10-22 11:50:16 +02:00
Gigi
92a85761ef feat: make highlight count clickable to open highlights sidebar 2025-10-22 11:48:41 +02:00
Gigi
f6a325f7e9 feat: hide close/collapse sidebar buttons on mobile 2025-10-22 11:45:51 +02:00
Gigi
a501fa816f feat: sort bookmarks chronologically by displayed date (newest first) 2025-10-22 11:43:30 +02:00
Gigi
5ece80b8e9 feat: change default bookmark view to flat chronological list 2025-10-22 11:42:12 +02:00
Gigi
87c017b2c2 docs: update CHANGELOG for v0.10.10 2025-10-22 11:38:09 +02:00
38 changed files with 1105 additions and 422 deletions

View File

@@ -7,6 +7,160 @@ 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
### 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
## [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
- Person hiking icon (fa-person-hiking) for explore navigation
### Changed
- Explore icon changed from newspaper to person hiking for better semantic meaning
- Settings button moved before explore button in sidebar navigation
- Profile avatar button now uses 44px touch target on mobile (matches other icon buttons)
### Fixed
- Web bookmarks (kind:39701) now properly deduplicate by d-tag
- Same URL bookmarked multiple times now only appears once
- Web bookmark IDs use coordinate format (kind:pubkey:d-tag) for consistent deduplication
- Profile avatar button sizing on mobile now matches other IconButton components
- Removed all console.log statements from bookmarkController and bookmarkProcessing
## [0.10.11] - 2025-01-27
### Added
- Clock icon for chronological bookmark view
- Clickable highlight count to open highlights sidebar
- Dynamic bookmark filter titles based on selected filter
- Profile picture moved to first position (left-aligned) with consistent sizing
### Changed
- Default bookmark view changed to flat chronological list (newest first)
- Bookmark URL changed from `/me/reading-list` to `/me/bookmarks`
- Router updated to handle `/me/reading-list` `/me/bookmarks` redirect
- Me.tsx bookmarks tab now uses dynamic filter titles and chronological sorting
- Me.tsx updated to use faClock icon instead of faBars
- Removed bookmark count from section headings for cleaner display
- Hide close/collapse sidebar buttons on mobile for better UX
### Fixed
- Bookmark sorting now uses proper display time (created_at || listUpdatedAt) with nulls last
- Robust sorting of merged bookmarks with fallback timestamps
- Corrected bookmark timestamp to use bookmark list creation time, not content creation time
- Preserved content created_at while adding listUpdatedAt for proper sorting
- Removed synthetic added_at field, now uses created_at from bookmark list event
- Consistent chronological sorting with useMemo optimization
- Removed unused faTimes import
- Bookmark timestamps now show sane dates using created_at fallback to listUpdatedAt
- Guarded formatters to prevent timestamp display errors
### Refactored
- Removed excessive debug logging for cleaner console output
- Bookmark timestamp handling never defaults to "now", allows nulls and sorts nulls last
- Renders empty when timestamp is missing instead of showing invalid dates
## [0.10.10] - 2025-10-22
### Changed
- Version bump for consistency (no user-facing changes)
## [0.10.9] - 2025-10-21
### Fixed
@@ -2436,7 +2590,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Optimize relay usage following applesauce-relay best practices
- Use applesauce-react event models for better profile handling
[Unreleased]: https://github.com/dergigi/boris/compare/v0.10.4...HEAD
[Unreleased]: https://github.com/dergigi/boris/compare/v0.10.12...HEAD
[0.10.12]: https://github.com/dergigi/boris/compare/v0.10.11...v0.10.12
[0.10.11]: https://github.com/dergigi/boris/compare/v0.10.10...v0.10.11
[0.10.10]: https://github.com/dergigi/boris/compare/v0.10.9...v0.10.10
[0.10.9]: https://github.com/dergigi/boris/compare/v0.10.8...v0.10.9
[0.10.8]: https://github.com/dergigi/boris/compare/v0.10.7...v0.10.8
[0.10.7]: https://github.com/dergigi/boris/compare/v0.10.6...v0.10.7
[0.10.6]: https://github.com/dergigi/boris/compare/v0.10.5...v0.10.6
[0.10.5]: https://github.com/dergigi/boris/compare/v0.10.4...v0.10.5
[0.10.4]: https://github.com/dergigi/boris/compare/v0.10.3...v0.10.4
[0.10.3]: https://github.com/dergigi/boris/compare/v0.10.2...v0.10.3
[0.10.2]: https://github.com/dergigi/boris/compare/v0.10.1...v0.10.2

View File

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

View File

@@ -253,7 +253,7 @@ function AppRoutes({
}
/>
<Route
path="/me/reading-list"
path="/me/bookmarks"
element={
<Bookmarks
relayPool={relayPool}

View File

@@ -50,6 +50,14 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
return (
<Link
to={href}
state={{
previewData: {
title: post.title,
image: post.image,
summary: post.summary,
published: post.published
}
}}
className={`blog-post-card ${level ? `level-${level}` : ''}`}
style={{ textDecoration: 'none', color: 'inherit' }}
>

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

@@ -1,7 +1,8 @@
import React, { useRef, useState } from 'react'
import React, { useRef, useState, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate, faHeart, faPlus, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons'
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate, faHeart, faPlus, faLayerGroup } from '@fortawesome/free-solid-svg-icons'
import { faClock } from '@fortawesome/free-regular-svg-icons'
import { formatDistanceToNow } from 'date-fns'
import { RelayPool } from 'applesauce-relay'
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
@@ -13,7 +14,7 @@ import { ViewMode } from './Bookmarks'
import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator'
import { BookmarkSkeleton } from './Skeletons'
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet, hasCreationDate } from '../utils/bookmarkUtils'
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet, hasCreationDate, sortIndividualBookmarks } from '../utils/bookmarkUtils'
import { UserSettings } from '../services/settingsService'
import AddBookmarkModal from './AddBookmarkModal'
import { createWebBookmark } from '../services/webBookmarkService'
@@ -71,7 +72,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
const [selectedFilter, setSelectedFilter] = useState<BookmarkFilterType>('all')
const [groupingMode, setGroupingMode] = useState<'grouped' | 'flat'>(() => {
const saved = localStorage.getItem('bookmarkGroupingMode')
return saved === 'flat' ? 'flat' : 'grouped'
return saved === 'grouped' ? 'grouped' : 'flat'
})
const activeAccount = Hooks.useActiveAccount()
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
@@ -120,6 +121,18 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
localStorage.setItem('bookmarkGroupingMode', newMode)
}
const getFilterTitle = (filter: BookmarkFilterType): string => {
const titles: Record<BookmarkFilterType, string> = {
'all': 'All Bookmarks',
'article': 'Bookmarked Reads',
'external': 'Bookmarked Links',
'video': 'Bookmarked Videos',
'note': 'Bookmarked Notes',
'web': 'Web Bookmarks'
}
return titles[filter]
}
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
if (!activeAccount || !relayPool) {
throw new Error('Please login to create bookmarks')
@@ -140,39 +153,58 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
isDisabled: !onRefresh
})
// Merge and flatten all individual bookmarks from all lists
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
.filter(hasContent)
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b))
// Merge and flatten all individual bookmarks from all lists - memoized to ensure consistent sorting
const sections = useMemo(() => {
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
.filter(hasContent)
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b))
// Apply filter
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
// Separate bookmarks with setName (kind 30003) from regular bookmarks
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
const bookmarkSets = getBookmarkSets(filteredBookmarks)
// Group non-set bookmarks by source or flatten based on mode
const groups = groupIndividualBookmarks(bookmarksWithoutSet)
const sectionsArray: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
groupingMode === 'flat'
? [{ key: 'all', title: getFilterTitle(selectedFilter), items: sortIndividualBookmarks(filteredBookmarks) }]
: [
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
{ key: 'amethyst-private', title: 'Private Lists', items: groups.amethystPrivate },
{ key: 'amethyst-public', title: 'My Lists', items: groups.amethystPublic },
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb }
]
// Add bookmark sets as additional sections (only in grouped mode)
if (groupingMode === 'grouped') {
bookmarkSets.forEach(set => {
sectionsArray.push({
key: `set-${set.name}`,
title: set.title || set.name,
items: set.bookmarks
})
})
}
return sectionsArray
}, [bookmarks, selectedFilter, groupingMode, settings?.hideBookmarksWithoutCreationDate])
// Apply filter
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
// Get all filtered bookmarks for empty state checks
const allIndividualBookmarks = useMemo(() =>
bookmarks.flatMap(b => b.individualBookmarks || [])
.filter(hasContent)
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b)),
[bookmarks, settings?.hideBookmarksWithoutCreationDate]
)
// Separate bookmarks with setName (kind 30003) from regular bookmarks
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
const bookmarkSets = getBookmarkSets(filteredBookmarks)
// Group non-set bookmarks by source or flatten based on mode
const groups = groupIndividualBookmarks(bookmarksWithoutSet)
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
groupingMode === 'flat'
? [{ key: 'all', title: `All Bookmarks (${bookmarksWithoutSet.length})`, items: bookmarksWithoutSet }]
: [
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
{ key: 'amethyst-private', title: 'Private Lists', items: groups.amethystPrivate },
{ key: 'amethyst-public', title: 'My Lists', items: groups.amethystPublic },
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb }
]
// Add bookmark sets as additional sections
bookmarkSets.forEach(set => {
sections.push({
key: `set-${set.name}`,
title: set.title || set.name,
items: set.bookmarks
})
})
const filteredBookmarks = useMemo(() =>
filterBookmarksByType(allIndividualBookmarks, selectedFilter),
[allIndividualBookmarks, selectedFilter]
)
if (isCollapsed) {
// Check if the selected URL is in bookmarks
@@ -286,7 +318,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
{activeAccount && (
<div className="view-mode-right">
<IconButton
icon={groupingMode === 'grouped' ? faLayerGroup : faBars}
icon={groupingMode === 'grouped' ? faLayerGroup : faClock}
onClick={toggleGroupingMode}
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}

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,19 +124,17 @@ 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)}
</a>
{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}
</Link>
) : (
<span className="bookmark-date">{formatDate(bookmark.created_at)}</span>
<span className="bookmark-date">{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}</span>
)}
</div>

View File

@@ -73,7 +73,7 @@ export const CompactView: React.FC<CompactViewProps> = ({
<code>{bookmark.id.slice(0, 12)}...</code>
</div>
)}
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at)}</span>
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at ?? bookmark.listUpdatedAt)}</span>
{/* CTA removed */}
</div>

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)}
</a>
{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}
</Link>
) : (
<span className="bookmark-date">{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}</span>
)}
{/* CTA removed */}

View File

@@ -64,7 +64,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
// Extract tab from me routes
const meTab = location.pathname === '/me' ? 'highlights' :
location.pathname === '/me/highlights' ? 'highlights' :
location.pathname === '/me/reading-list' ? 'reading-list' :
location.pathname === '/me/bookmarks' ? 'bookmarks' :
location.pathname.startsWith('/me/reads') ? 'reads' :
location.pathname.startsWith('/me/links') ? 'links' :
location.pathname === '/me/writings' ? 'writings' : 'highlights'
@@ -230,6 +230,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
useArticleLoader({
naddr,
relayPool,
eventStore,
setSelectedUrl,
setReaderContent,
setReaderLoading,

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 {
@@ -76,6 +76,7 @@ interface ContentPanelProps {
// For reading progress indicator positioning
isSidebarCollapsed?: boolean
isHighlightsCollapsed?: boolean
onOpenHighlights?: () => void
}
const ContentPanel: React.FC<ContentPanelProps> = ({
@@ -103,7 +104,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
onTextSelection,
onClearSelection,
isSidebarCollapsed = false,
isHighlightsCollapsed = false
isHighlightsCollapsed = false,
onOpenHighlights
}) => {
const [isMarkedAsRead, setIsMarkedAsRead] = useState(false)
const [isCheckingReadStatus, setIsCheckingReadStatus] = useState(false)
@@ -132,6 +134,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
currentUserPubkey,
followedPubkeys
})
// Key used to force re-mount of markdown preview/render when content changes
const contentKey = useMemo(() => {
// Prefer selectedUrl as a stable per-article key; fallback to title+length
return selectedUrl || `${title || ''}:${(markdown || html || '').length}`
}, [selectedUrl, title, markdown, html])
const { contentRef, handleSelectionEnd } = useHighlightInteractions({
onHighlightClick,
@@ -143,8 +150,15 @@ 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(() => {
if (loading) return false
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])
// Generate article identifier for saving/loading position
const articleIdentifier = useMemo(() => {
@@ -155,20 +169,24 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
// 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')
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,
@@ -181,13 +199,34 @@ 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])
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 tracking after content is stable
useEffect(() => {
if (isTextContent && !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: () => {
@@ -207,48 +246,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 animation + 500ms buffer = 2000ms)
if (suppressSavesForRef.current) {
suppressSavesForRef.current(2000)
}
// 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(() => {
@@ -749,7 +849,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
<div className="reader" style={{ '--highlight-rgb': highlightRgb } as React.CSSProperties}>
{/* Hidden markdown preview to convert markdown to HTML */}
{markdown && (
<div ref={markdownPreviewRef} style={{ display: 'none' }}>
<div ref={markdownPreviewRef} key={`preview:${contentKey}`} style={{ display: 'none' }}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypePrism]}
@@ -778,6 +878,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
settings={settings}
highlights={relevantHighlights}
highlightVisibility={highlightVisibility}
onHighlightCountClick={onOpenHighlights}
/>
{isTextContent && articleText && (
<div style={{ padding: '0 0.75rem 0.5rem 0.75rem' }}>
@@ -869,6 +970,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
{markdown ? (
renderedMarkdownHtml && finalHtml ? (
<VideoEmbedProcessor
key={`content:${contentKey}`}
ref={contentRef}
html={finalHtml}
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo}
@@ -885,6 +987,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
)
) : (
<VideoEmbedProcessor
key={`content:${contentKey}`}
ref={contentRef}
html={finalHtml || html || ''}
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo}

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate, faSpinner } from '@fortawesome/free-solid-svg-icons'
import { faPersonHiking, faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate } from '@fortawesome/free-solid-svg-icons'
import IconButton from './IconButton'
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
import { Hooks } from 'applesauce-react'
@@ -523,8 +523,10 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
)
}
return filteredBlogPosts.length === 0 ? (
<div className="explore-loading" style={{ gridColumn: '1/-1', display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
<div className="explore-grid">
{Array.from({ length: 6 }).map((_, i) => (
<BlogPostSkeleton key={i} />
))}
</div>
) : (
<div className="explore-grid">
@@ -584,7 +586,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
/>
<div className="explore-header">
<h1>
<FontAwesomeIcon icon={faNewspaper} />
<FontAwesomeIcon icon={faPersonHiking} />
Explore
</h1>

View File

@@ -37,6 +37,7 @@ interface HighlightsPanelProps {
relayPool?: RelayPool | null
eventStore?: IEventStore | null
settings?: UserSettings
isMobile?: boolean
}
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
@@ -56,7 +57,8 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
followedPubkeys = new Set(),
relayPool,
eventStore,
settings
settings,
isMobile = false
}) => {
const [showHighlights, setShowHighlights] = useState(true)
const [localHighlights, setLocalHighlights] = useState(highlights)
@@ -125,6 +127,7 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
onRefresh={onRefresh}
onToggleCollapse={onToggleCollapse}
onHighlightVisibilityChange={onHighlightVisibilityChange}
isMobile={isMobile}
/>
{loading && filteredHighlights.length === 0 ? (

View File

@@ -13,6 +13,7 @@ interface HighlightsPanelHeaderProps {
onRefresh?: () => void
onToggleCollapse: () => void
onHighlightVisibilityChange?: (visibility: HighlightVisibility) => void
isMobile?: boolean
}
const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
@@ -24,7 +25,8 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
onToggleHighlights,
onRefresh,
onToggleCollapse,
onHighlightVisibilityChange
onHighlightVisibilityChange,
isMobile = false
}) => {
return (
<div className="highlights-header">
@@ -101,14 +103,16 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
/>
)}
</div>
<IconButton
icon={faChevronRight}
onClick={onToggleCollapse}
title="Collapse highlights panel"
ariaLabel="Collapse highlights panel"
variant="ghost"
style={{ transform: 'rotate(180deg)' }}
/>
{!isMobile && (
<IconButton
icon={faChevronRight}
onClick={onToggleCollapse}
title="Collapse highlights panel"
ariaLabel="Collapse highlights panel"
variant="ghost"
style={{ transform: 'rotate(180deg)' }}
/>
)}
</div>
</div>
)

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faBookmark, faPenToSquare, faLink, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons'
import { faHighlighter, faBookmark, faPenToSquare, faLink, faLayerGroup } from '@fortawesome/free-solid-svg-icons'
import { faClock } from '@fortawesome/free-regular-svg-icons'
import { Hooks } from 'applesauce-react'
import { IEventStore } from 'applesauce-core'
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
@@ -23,7 +24,7 @@ import { getCachedMeData, updateCachedHighlights } from '../services/meCache'
import { faBooks } from '../icons/customIcons'
import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator'
import { groupIndividualBookmarks, hasContent, hasCreationDate } from '../utils/bookmarkUtils'
import { groupIndividualBookmarks, hasContent, hasCreationDate, sortIndividualBookmarks } from '../utils/bookmarkUtils'
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters'
@@ -42,7 +43,7 @@ interface MeProps {
settings: UserSettings
}
type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
type TabType = 'highlights' | 'bookmarks' | 'reads' | 'links' | 'writings'
// Valid reading progress filters
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed', 'highlighted', 'archive']
@@ -229,9 +230,9 @@ const Me: React.FC<MeProps> = ({
if (!viewingPubkey || !activeAccount) return
setLoadedTabs(prev => {
const hasBeenLoaded = prev.has('reading-list')
const hasBeenLoaded = prev.has('bookmarks')
if (!hasBeenLoaded) setLoading(true)
return new Set(prev).add('reading-list')
return new Set(prev).add('bookmarks')
})
// Always turn off loading after a tick
@@ -334,7 +335,7 @@ const Me: React.FC<MeProps> = ({
case 'writings':
loadWritingsTab()
break
case 'reading-list':
case 'bookmarks':
loadReadingListTab()
break
case 'reads':
@@ -418,7 +419,7 @@ const Me: React.FC<MeProps> = ({
const mockEvent = {
id: item.id,
pubkey: item.author || '',
created_at: item.readingTimestamp || Math.floor(Date.now() / 1000),
created_at: item.readingTimestamp || 0,
kind: 1,
tags: [] as string[][],
content: item.title || item.url || 'Untitled',
@@ -565,9 +566,21 @@ const Me: React.FC<MeProps> = ({
? buildArchiveOnly(linksWithProgress, { kind: 'external' })
: []
const getFilterTitle = (filter: BookmarkFilterType): string => {
const titles: Record<BookmarkFilterType, string> = {
'all': 'All Bookmarks',
'article': 'Bookmarked Reads',
'external': 'Bookmarked Links',
'video': 'Bookmarked Videos',
'note': 'Bookmarked Notes',
'web': 'Web Bookmarks'
}
return titles[filter]
}
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
groupingMode === 'flat'
? [{ key: 'all', title: `All Bookmarks (${filteredBookmarks.length})`, items: filteredBookmarks }]
? [{ key: 'all', title: getFilterTitle(bookmarkFilter), items: sortIndividualBookmarks(filteredBookmarks) }]
: [
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
@@ -609,7 +622,7 @@ const Me: React.FC<MeProps> = ({
</div>
)
case 'reading-list':
case 'bookmarks':
if (showSkeletons) {
return (
<div className="bookmarks-list">
@@ -664,7 +677,7 @@ const Me: React.FC<MeProps> = ({
borderTop: '1px solid var(--border-color)'
}}>
<IconButton
icon={groupingMode === 'grouped' ? faLayerGroup : faBars}
icon={groupingMode === 'grouped' ? faLayerGroup : faClock}
onClick={toggleGroupingMode}
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
@@ -860,9 +873,9 @@ const Me: React.FC<MeProps> = ({
<span className="tab-label">Highlights</span>
</button>
<button
className={`me-tab ${activeTab === 'reading-list' ? 'active' : ''}`}
data-tab="reading-list"
onClick={() => navigate('/me/reading-list')}
className={`me-tab ${activeTab === 'bookmarks' ? 'active' : ''}`}
data-tab="bookmarks"
onClick={() => navigate('/me/bookmarks')}
>
<FontAwesomeIcon icon={faBookmark} />
<span className="tab-label">Bookmarks</span>

View File

@@ -20,6 +20,7 @@ interface ReaderHeaderProps {
settings?: UserSettings
highlights?: Highlight[]
highlightVisibility?: HighlightVisibility
onHighlightCountClick?: () => void
}
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
@@ -32,7 +33,8 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
highlightCount,
settings,
highlights = [],
highlightVisibility = { nostrverse: true, friends: true, mine: true }
highlightVisibility = { nostrverse: true, friends: true, mine: true },
onHighlightCountClick
}) => {
const cachedImage = useImageCache(image)
const { textColor } = useAdaptiveTextColor(cachedImage)
@@ -107,8 +109,10 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
)}
{hasHighlights && (
<div
className="highlight-indicator"
className="highlight-indicator clickable"
style={getHighlightIndicatorStyles(true)}
onClick={onHighlightCountClick}
title="Open highlights sidebar"
>
<FontAwesomeIcon icon={faHighlighter} />
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
@@ -152,8 +156,10 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
)}
{hasHighlights && (
<div
className="highlight-indicator"
className="highlight-indicator clickable"
style={getHighlightIndicatorStyles(false)}
onClick={onHighlightCountClick}
title="Open highlights sidebar"
>
<FontAwesomeIcon icon={faHighlighter} />
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>

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

@@ -1,7 +1,7 @@
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight, faRightFromBracket, faUserCircle, faGear, faHome, faNewspaper, faTimes } from '@fortawesome/free-solid-svg-icons'
import { faChevronRight, faRightFromBracket, faUserCircle, faGear, faHome, faPersonHiking } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
@@ -36,70 +36,61 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
return (
<>
<div className="sidebar-header-bar">
{isMobile ? (
<IconButton
icon={faTimes}
onClick={onToggleCollapse}
title="Close sidebar"
ariaLabel="Close sidebar"
variant="ghost"
className="mobile-close-btn"
/>
) : (
<button
onClick={onToggleCollapse}
className="toggle-sidebar-btn"
title="Collapse bookmarks sidebar"
aria-label="Collapse bookmarks sidebar"
>
<FontAwesomeIcon icon={faChevronRight} />
</button>
)}
<div className="sidebar-header-right">
{activeAccount && (
<div
className="profile-avatar"
<button
className="profile-avatar-button"
title={getUserDisplayName()}
onClick={() => navigate('/me')}
style={{ cursor: 'pointer' }}
aria-label={`Profile: ${getUserDisplayName()}`}
>
{profileImage ? (
<img src={profileImage} alt={getUserDisplayName()} />
) : (
<FontAwesomeIcon icon={faUserCircle} />
)}
</div>
</button>
)}
<IconButton
icon={faHome}
onClick={() => navigate('/')}
title="Home"
ariaLabel="Home"
variant="ghost"
/>
<IconButton
icon={faNewspaper}
onClick={() => navigate('/explore')}
title="Explore"
ariaLabel="Explore"
variant="ghost"
/>
<IconButton
icon={faGear}
onClick={onOpenSettings}
title="Settings"
ariaLabel="Settings"
variant="ghost"
/>
{activeAccount && (
<div className="sidebar-header-right">
<IconButton
icon={faRightFromBracket}
onClick={onLogout}
title="Logout"
ariaLabel="Logout"
icon={faHome}
onClick={() => navigate('/')}
title="Home"
ariaLabel="Home"
variant="ghost"
/>
)}
<IconButton
icon={faGear}
onClick={onOpenSettings}
title="Settings"
ariaLabel="Settings"
variant="ghost"
/>
<IconButton
icon={faPersonHiking}
onClick={() => navigate('/explore')}
title="Explore"
ariaLabel="Explore"
variant="ghost"
/>
{activeAccount && (
<IconButton
icon={faRightFromBracket}
onClick={onLogout}
title="Logout"
ariaLabel="Logout"
variant="ghost"
/>
)}
{!isMobile && (
<button
onClick={onToggleCollapse}
className="toggle-sidebar-btn"
title="Collapse bookmarks sidebar"
aria-label="Collapse bookmarks sidebar"
>
<FontAwesomeIcon icon={faChevronRight} />
</button>
)}
</div>
</div>
</>

View File

@@ -387,6 +387,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
currentArticle={props.currentArticle}
isSidebarCollapsed={props.isCollapsed}
isHighlightsCollapsed={props.isHighlightsCollapsed}
onOpenHighlights={() => {
if (props.isHighlightsCollapsed) {
props.onToggleHighlightsPanel()
}
}}
/>
)}
</div>
@@ -413,6 +418,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
relayPool={props.relayPool}
eventStore={props.eventStore}
settings={props.settings}
isMobile={isMobile}
/>
</div>
</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

@@ -1,5 +1,11 @@
import { useEffect, useRef, Dispatch, SetStateAction } from 'react'
import { useLocation } from 'react-router-dom'
import { RelayPool } from 'applesauce-relay'
import type { IEventStore } from 'applesauce-core'
import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19'
import { Helpers } from 'applesauce-core'
import { queryEvents } from '../services/dataFetch'
import { fetchArticleByNaddr } from '../services/articleService'
import { fetchHighlightsForArticle } from '../services/highlightService'
import { ReadableContent } from '../services/readerService'
@@ -7,9 +13,17 @@ import { Highlight } from '../types/highlights'
import { NostrEvent } from 'nostr-tools'
import { UserSettings } from '../services/settingsService'
interface PreviewData {
title: string
image?: string
summary?: string
published?: number
}
interface UseArticleLoaderProps {
naddr: string | undefined
relayPool: RelayPool | null
eventStore?: IEventStore | null
setSelectedUrl: (url: string) => void
setReaderContent: (content: ReadableContent | undefined) => void
setReaderLoading: (loading: boolean) => void
@@ -25,6 +39,7 @@ interface UseArticleLoaderProps {
export function useArticleLoader({
naddr,
relayPool,
eventStore,
setSelectedUrl,
setReaderContent,
setReaderLoading,
@@ -36,7 +51,18 @@ export function useArticleLoader({
setCurrentArticle,
settings
}: UseArticleLoaderProps) {
const location = useLocation()
const mountedRef = useRef(true)
// Hold latest settings without retriggering effect
const settingsRef = useRef<UserSettings | undefined>(settings)
useEffect(() => {
settingsRef.current = settings
}, [settings])
// Track in-flight request to prevent stale updates from previous naddr
const currentRequestIdRef = useRef(0)
// Extract preview data from navigation state (from blog post cards)
const previewData = (location.state as { previewData?: PreviewData })?.previewData
useEffect(() => {
mountedRef.current = true
@@ -44,67 +70,204 @@ export function useArticleLoader({
if (!relayPool || !naddr) return
const loadArticle = async () => {
const requestId = ++currentRequestIdRef.current
if (!mountedRef.current) return
setReaderLoading(true)
setReaderContent(undefined)
setSelectedUrl(`nostr:${naddr}`)
setIsCollapsed(true)
try {
const article = await fetchArticleByNaddr(relayPool, naddr, false, settings)
if (!mountedRef.current) return
// If we have preview data from navigation, show it immediately (no skeleton!)
if (previewData) {
setReaderContent({
title: article.title,
markdown: article.markdown,
image: article.image,
summary: article.summary,
published: article.published,
title: previewData.title,
markdown: '', // Will be loaded from store or relay
image: previewData.image,
summary: previewData.summary,
published: previewData.published,
url: `nostr:${naddr}`
})
const dTag = article.event.tags.find(t => t[0] === 'd')?.[1] || ''
const articleCoordinate = `${article.event.kind}:${article.author}:${dTag}`
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(article.event.id)
setCurrentArticle?.(article.event)
setReaderLoading(false)
// Fetch highlights asynchronously without blocking article display
setReaderLoading(false) // Turn off loading immediately - we have the preview!
} else {
setReaderLoading(true)
setReaderContent(undefined)
}
try {
// Decode naddr to filter
const decoded = nip19.decode(naddr)
if (decoded.type !== 'naddr') {
throw new Error('Invalid naddr format')
}
const pointer = decoded.data as AddressPointer
const filter = {
kinds: [pointer.kind],
authors: [pointer.pubkey],
'#d': [pointer.identifier]
}
let firstEmitted = false
let latestEvent: NostrEvent | null = null
// Check eventStore first for instant load (from bookmark cards, explore, etc.)
if (eventStore) {
try {
const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`
const storedEvent = eventStore.getEvent?.(coordinate)
if (storedEvent) {
latestEvent = storedEvent as NostrEvent
firstEmitted = true
const title = Helpers.getArticleTitle(storedEvent) || 'Untitled Article'
const image = Helpers.getArticleImage(storedEvent)
const summary = Helpers.getArticleSummary(storedEvent)
const published = Helpers.getArticlePublished(storedEvent)
setReaderContent({
title,
markdown: storedEvent.content,
image,
summary,
published,
url: `nostr:${naddr}`
})
const dTag = storedEvent.tags.find(t => t[0] === 'd')?.[1] || ''
const articleCoordinate = `${storedEvent.kind}:${storedEvent.pubkey}:${dTag}`
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(storedEvent.id)
setCurrentArticle?.(storedEvent)
setReaderLoading(false)
}
} catch (err) {
// Ignore store errors, fall through to relay query
}
}
// Stream local-first via queryEvents; rely on EOSE (no timeouts)
const events = await queryEvents(relayPool, filter, {
onEvent: (evt) => {
if (!mountedRef.current) return
if (currentRequestIdRef.current !== requestId) return
// Store in event store for future local reads
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
eventStore?.add?.(evt as unknown as any)
} catch {
// Silently ignore store errors
}
// Keep latest by created_at
if (!latestEvent || evt.created_at > latestEvent.created_at) {
latestEvent = evt
}
// Emit immediately on first event
if (!firstEmitted) {
firstEmitted = true
const title = Helpers.getArticleTitle(evt) || 'Untitled Article'
const image = Helpers.getArticleImage(evt)
const summary = Helpers.getArticleSummary(evt)
const published = Helpers.getArticlePublished(evt)
setReaderContent({
title,
markdown: evt.content,
image,
summary,
published,
url: `nostr:${naddr}`
})
const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || ''
const articleCoordinate = `${evt.kind}:${evt.pubkey}:${dTag}`
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(evt.id)
setCurrentArticle?.(evt)
setReaderLoading(false)
}
}
})
if (!mountedRef.current || currentRequestIdRef.current !== requestId) return
// Finalize with newest version if it's newer than what we first rendered
const finalEvent = (events.sort((a, b) => b.created_at - a.created_at)[0]) || latestEvent
if (finalEvent) {
const title = Helpers.getArticleTitle(finalEvent) || 'Untitled Article'
const image = Helpers.getArticleImage(finalEvent)
const summary = Helpers.getArticleSummary(finalEvent)
const published = Helpers.getArticlePublished(finalEvent)
setReaderContent({
title,
markdown: finalEvent.content,
image,
summary,
published,
url: `nostr:${naddr}`
})
const dTag = finalEvent.tags.find(t => t[0] === 'd')?.[1] || ''
const articleCoordinate = `${finalEvent.kind}:${finalEvent.pubkey}:${dTag}`
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(finalEvent.id)
setCurrentArticle?.(finalEvent)
} else {
// As a last resort, fall back to the legacy helper (which includes cache)
const article = await fetchArticleByNaddr(relayPool, naddr, false, settingsRef.current)
if (!mountedRef.current || currentRequestIdRef.current !== requestId) return
setReaderContent({
title: article.title,
markdown: article.markdown,
image: article.image,
summary: article.summary,
published: article.published,
url: `nostr:${naddr}`
})
const dTag = article.event.tags.find(t => t[0] === 'd')?.[1] || ''
const articleCoordinate = `${article.event.kind}:${article.author}:${dTag}`
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(article.event.id)
setCurrentArticle?.(article.event)
}
// 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
await fetchHighlightsForArticle(
relayPool,
articleCoordinate,
article.event.id,
(highlight) => {
if (!mountedRef.current) return
setHighlights((prev: Highlight[]) => {
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
const next = [highlight, ...prev]
return next.sort((a, b) => b.created_at - a.created_at)
})
},
settings
)
if (coord && eventId) {
setHighlightsLoading(true)
setHighlights([])
await fetchHighlightsForArticle(
relayPool,
coord,
eventId,
(highlight) => {
if (!mountedRef.current) return
if (currentRequestIdRef.current !== requestId) return
setHighlights((prev: Highlight[]) => {
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
const next = [highlight, ...prev]
return next.sort((a, b) => b.created_at - a.created_at)
})
},
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)
} finally {
if (mountedRef.current) {
if (mountedRef.current && currentRequestIdRef.current === requestId) {
setHighlightsLoading(false)
}
}
} catch (err) {
console.error('Failed to load article:', err)
if (mountedRef.current) {
if (mountedRef.current && currentRequestIdRef.current === requestId) {
setReaderContent({
title: 'Error Loading Article',
html: `<p>Failed to load article: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
@@ -123,7 +286,8 @@ export function useArticleLoader({
}, [
naddr,
relayPool,
settings,
eventStore,
previewData,
setSelectedUrl,
setReaderContent,
setReaderLoading,

View File

@@ -49,6 +49,8 @@ export function useExternalUrlLoader({
setCurrentArticleEventId
}: UseExternalUrlLoaderProps) {
const mountedRef = useRef(true)
// Track in-flight request to prevent stale updates when switching quickly
const currentRequestIdRef = useRef(0)
// Load cached URL-specific highlights from event store
const urlFilter = useMemo(() => {
@@ -70,6 +72,7 @@ export function useExternalUrlLoader({
if (!relayPool || !url) return
const loadExternalUrl = async () => {
const requestId = ++currentRequestIdRef.current
if (!mountedRef.current) return
setReaderLoading(true)
@@ -83,6 +86,7 @@ export function useExternalUrlLoader({
const content = await fetchReadableContent(url)
if (!mountedRef.current) return
if (currentRequestIdRef.current !== requestId) return
setReaderContent(content)
setReaderLoading(false)
@@ -114,6 +118,7 @@ export function useExternalUrlLoader({
url,
(highlight) => {
if (!mountedRef.current) return
if (currentRequestIdRef.current !== requestId) return
if (seen.has(highlight.id)) return
seen.add(highlight.id)
@@ -131,13 +136,13 @@ export function useExternalUrlLoader({
} catch (err) {
console.error('Failed to fetch highlights:', err)
} finally {
if (mountedRef.current) {
if (mountedRef.current && currentRequestIdRef.current === requestId) {
setHighlightsLoading(false)
}
}
} catch (err) {
console.error('Failed to load external URL:', err)
if (mountedRef.current) {
if (mountedRef.current && currentRequestIdRef.current === requestId) {
const filename = getFilenameFromUrl(url)
setReaderContent({
title: filename,

View File

@@ -20,9 +20,11 @@ export const useMarkdownToHTML = (
const [processedMarkdown, setProcessedMarkdown] = useState<string>('')
useEffect(() => {
// Always clear previous render immediately to avoid showing stale content while processing
setRenderedHtml('')
setProcessedMarkdown('')
if (!markdown) {
setRenderedHtml('')
setProcessedMarkdown('')
return
}

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,7 +17,6 @@ export const useReadingPosition = ({
readingCompleteThreshold = 0.95, // Match filter threshold for consistency
syncEnabled = false,
onSave,
autoSaveInterval = 5000,
completionHoldMs = 2000
}: UseReadingPositionOptions = {}) => {
const [position, setPosition] = useState(0)
@@ -30,10 +28,27 @@ export const useReadingPosition = ({
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)
// Debounced save function
// Keep refs in sync with props
useEffect(() => {
syncEnabledRef.current = syncEnabled
onSaveRef.current = onSave
}, [syncEnabled, 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
console.log(`[reading-position] [${new Date().toISOString()}] 🛡️ Suppressing saves for ${ms}ms until ${new Date(until).toISOString()}`)
}, [])
// Debounced save function - simple 2s debounce
const scheduleSave = useCallback((currentPosition: number) => {
if (!syncEnabled || !onSave) {
if (!syncEnabledRef.current || !onSaveRef.current) {
return
}
@@ -43,10 +58,11 @@ export const useReadingPosition = ({
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()
onSave(1)
onSaveRef.current(1)
return
}
@@ -54,62 +70,54 @@ export const useReadingPosition = ({
const MIN_DELTA = 0.05
const hasSignificantChange = Math.abs(currentPosition - lastSavedPosition.current) >= MIN_DELTA
// 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) {
if (!hasSignificantChange) {
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
// Clear any existing timer and schedule new save
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current)
}
// Schedule new save using the larger of autoSaveInterval and MIN_INTERVAL_MS
const delay = Math.max(autoSaveInterval, MIN_INTERVAL_MS)
const DEBOUNCE_MS = 3000 // Save max every 3 seconds
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()
onSave(currentPosition)
}, delay)
}, [syncEnabled, onSave, autoSaveInterval])
if (onSaveRef.current) {
onSaveRef.current(currentPosition)
}
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 (!syncEnabled || !onSave) return
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
}
lastSavedPosition.current = position
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()
onSave(position)
}, [syncEnabled, onSave, position])
onSaveRef.current(positionRef.current)
}, [])
useEffect(() => {
if (!enabled) return
@@ -123,21 +131,29 @@ 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) {
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)}%`)
}
// Completion detection with 2s hold at 100%
if (!hasTriggeredComplete.current) {
@@ -180,15 +196,24 @@ export const useReadingPosition = ({
window.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', handleScroll)
// Clear save timer on unmount
if (saveTimerRef.current) {
// 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)
}
}
if (completionTimerRef.current) {
clearTimeout(completionTimerRef.current)
}
}
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, scheduleSave, completionHoldMs])
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, completionHoldMs])
// Reset reading complete state when enabled changes
useEffect(() => {
@@ -208,6 +233,7 @@ export const useReadingPosition = ({
position,
isReadingComplete,
progressPercentage: Math.round(position * 100),
saveNow
saveNow,
suppressSavesFor
}
}

View File

@@ -5,13 +5,12 @@ import './styles/tailwind.css'
import './index.css'
import 'react-loading-skeleton/dist/skeleton.css'
// Register Service Worker for PWA functionality
if ('serviceWorker' in navigator) {
// Register Service Worker for PWA functionality (production only)
if ('serviceWorker' in navigator && import.meta.env.PROD) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/sw.js', { type: 'module' })
.register('/sw.js')
.then(registration => {
// Check for updates periodically
setInterval(() => {
registration.update()
@@ -24,8 +23,6 @@ if ('serviceWorker' in navigator) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New service worker available
// Optionally show a toast notification
const updateAvailable = new CustomEvent('sw-update-available')
window.dispatchEvent(updateAvailable)
}

View File

@@ -97,10 +97,10 @@ export async function fetchArticleByNaddr(
const pointer = decoded.data as AddressPointer
// Define relays to query - prefer relays from naddr, fallback to configured relays (including local)
const baseRelays = pointer.relays && pointer.relays.length > 0
? pointer.relays
: RELAYS
// Define relays to query - use union of relay hints from naddr and configured relays
// This avoids failures when naddr contains stale/unreachable relay hints
const hintedRelays = (pointer.relays && pointer.relays.length > 0) ? pointer.relays : []
const baseRelays = Array.from(new Set<string>([...hintedRelays, ...RELAYS]))
const orderedRelays = prioritizeLocalRelays(baseRelays)
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
@@ -114,7 +114,28 @@ export async function fetchArticleByNaddr(
// Parallel local+remote, stream immediate, collect up to first from each
const { local$, remote$ } = createParallelReqStreams(relayPool, localRelays, remoteRelays, filter, 1200, 6000)
const collected = await lastValueFrom(merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray()))
const events = collected as NostrEvent[]
let events = collected as NostrEvent[]
// Fallback: if nothing found, try a second round against a set of reliable public relays
if (events.length === 0) {
const reliableRelays = Array.from(new Set<string>([
'wss://relay.nostr.band',
'wss://relay.primal.net',
'wss://relay.damus.io',
'wss://nos.lol',
...remoteRelays // keep any configured remote relays
]))
const { remote$: fallback$ } = createParallelReqStreams(
relayPool,
[], // no local
reliableRelays,
filter,
1500,
12000
)
const fallbackCollected = await lastValueFrom(fallback$.pipe(take(1), rxToArray()))
events = fallbackCollected as NostrEvent[]
}
if (events.length === 0) {
throw new Error('Article not found')

View File

@@ -343,19 +343,27 @@ class BookmarkController {
...b,
tags: b.tags || [],
content: b.content || this.externalEventStore?.getEvent(b.id)?.content || '', // Fallback to eventStore content
created_at: b.created_at || this.externalEventStore?.getEvent(b.id)?.created_at || b.created_at
created_at: (b.created_at ?? this.externalEventStore?.getEvent(b.id)?.created_at ?? null)
}))
const sortedBookmarks = enriched
.map(b => ({ ...b, urlReferences: extractUrlsFromContent(b.content) }))
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
.map(b => ({
...b,
urlReferences: extractUrlsFromContent(b.content)
}))
.sort((a, b) => {
// Sort by display time: created_at, else listUpdatedAt. Newest first. Nulls last.
const aTs = (a.created_at ?? a.listUpdatedAt ?? -Infinity)
const bTs = (b.created_at ?? b.listUpdatedAt ?? -Infinity)
return bTs - aTs
})
const bookmark: Bookmark = {
id: `${activeAccount.pubkey}-bookmarks`,
title: `Bookmarks (${sortedBookmarks.length})`,
url: '',
content: latestContent,
created_at: newestCreatedAt || Math.floor(Date.now() / 1000),
created_at: newestCreatedAt || 0,
tags: allTags,
bookmarkCount: sortedBookmarks.length,
eventReferences: allTags.filter((tag: string[]) => tag[0] === 'e').map((tag: string[]) => tag[1]),

View File

@@ -15,28 +15,30 @@ export function dedupeNip51Events(events: NostrEvent[]): NostrEvent[] {
}
const unique = Array.from(byId.values())
// Separate web bookmarks (kind:39701) from list-based bookmarks
const webBookmarks = unique.filter(e => e.kind === 39701)
const bookmarkLists = unique
.filter(e => e.kind === 10003 || e.kind === 30003 || e.kind === 30001)
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
const latestBookmarkList = bookmarkLists.find(list => !list.tags?.some((t: string[]) => t[0] === 'd'))
// Deduplicate replaceable events (kind:30003, 30001, 39701) by d-tag
const byD = new Map<string, NostrEvent>()
for (const e of unique) {
if (e.kind === 10003 || e.kind === 30003 || e.kind === 30001) {
if (e.kind === 10003 || e.kind === 30003 || e.kind === 30001 || e.kind === 39701) {
const d = (e.tags || []).find((t: string[]) => t[0] === 'd')?.[1] || ''
const prev = byD.get(d)
if (!prev || (e.created_at || 0) > (prev.created_at || 0)) byD.set(d, e)
}
}
const setsAndNamedLists = Array.from(byD.values())
// Separate web bookmarks from bookmark sets/lists
const allReplaceable = Array.from(byD.values())
const webBookmarks = allReplaceable.filter(e => e.kind === 39701)
const setsAndNamedLists = allReplaceable.filter(e => e.kind !== 39701)
const out: NostrEvent[] = []
if (latestBookmarkList) out.push(latestBookmarkList)
out.push(...setsAndNamedLists)
// Add web bookmarks as individual events
// Add deduplicated web bookmarks as individual events
out.push(...webBookmarks)
return out
}

View File

@@ -81,14 +81,14 @@ export const processApplesauceBookmarks = (
allItems.push({
id: note.id,
content: '',
created_at: note.created_at || parentCreatedAt || 0,
created_at: note.created_at ?? null,
pubkey: note.author || activeAccount.pubkey,
kind: 1, // Short note kind
tags: [],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: note.added_at || parentCreatedAt || 0
listUpdatedAt: parentCreatedAt || 0
})
})
}
@@ -101,14 +101,14 @@ export const processApplesauceBookmarks = (
allItems.push({
id: coordinate,
content: '',
created_at: article.created_at || parentCreatedAt || 0,
created_at: article.created_at ?? null,
pubkey: article.pubkey,
kind: article.kind, // Usually 30023 for long-form articles
tags: [],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: article.added_at || parentCreatedAt || 0
listUpdatedAt: parentCreatedAt ?? null
})
})
}
@@ -119,14 +119,14 @@ export const processApplesauceBookmarks = (
allItems.push({
id: `hashtag-${hashtag}`,
content: `#${hashtag}`,
created_at: parentCreatedAt || 0,
created_at: 0, // Hashtags don't have their own creation time
pubkey: activeAccount.pubkey,
kind: 1,
tags: [['t', hashtag]],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: parentCreatedAt || 0
listUpdatedAt: parentCreatedAt ?? null
})
})
}
@@ -137,14 +137,14 @@ export const processApplesauceBookmarks = (
allItems.push({
id: `url-${url}`,
content: url,
created_at: parentCreatedAt || 0,
created_at: 0, // URLs don't have their own creation time
pubkey: activeAccount.pubkey,
kind: 1,
tags: [['r', url]],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: parentCreatedAt || 0
listUpdatedAt: parentCreatedAt || 0
})
})
}
@@ -153,20 +153,24 @@ export const processApplesauceBookmarks = (
}
const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks]
return bookmarkArray
const processed = bookmarkArray
.filter((bookmark: BookmarkData) => bookmark.id) // Skip bookmarks without valid IDs
.map((bookmark: BookmarkData) => ({
id: bookmark.id!,
content: bookmark.content || '',
created_at: bookmark.created_at || parentCreatedAt || 0,
pubkey: activeAccount.pubkey,
kind: bookmark.kind || 30001,
tags: bookmark.tags || [],
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
type: 'event' as const,
isPrivate,
added_at: bookmark.created_at || parentCreatedAt || 0
}))
.map((bookmark: BookmarkData) => {
return {
id: bookmark.id!,
content: bookmark.content || '',
created_at: bookmark.created_at ?? null,
pubkey: activeAccount.pubkey,
kind: bookmark.kind || 30001,
tags: bookmark.tags || [],
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
type: 'event' as const,
isPrivate,
listUpdatedAt: parentCreatedAt ?? null
}
})
return processed
}
// Types and guards around signer/decryption APIs

View File

@@ -133,29 +133,36 @@ export async function collectBookmarksFromEvents(
// Handle web bookmarks (kind:39701) as individual bookmarks
if (evt.kind === 39701) {
// Use coordinate format for web bookmarks to enable proper deduplication
// Web bookmarks are replaceable events (kind:39701:pubkey:d-tag)
const webBookmarkId = dTag ? `${evt.kind}:${evt.pubkey}:${dTag}` : evt.id
publicItemsAll.push({
id: evt.id,
id: webBookmarkId,
content: evt.content || '',
created_at: evt.created_at || Math.floor(Date.now() / 1000),
created_at: evt.created_at ?? null,
pubkey: evt.pubkey,
kind: evt.kind,
tags: evt.tags || [],
parsedContent: undefined,
type: 'web' as const,
isPrivate: false,
added_at: evt.created_at || Math.floor(Date.now() / 1000),
sourceKind: 39701,
setName: dTag,
setTitle,
setDescription,
setImage
setImage,
listUpdatedAt: evt.created_at ?? null
})
continue
}
const pub = Helpers.getPublicBookmarks(evt)
const processedPub = processApplesauceBookmarks(pub, activeAccount, false, evt.created_at)
publicItemsAll.push(
...processApplesauceBookmarks(pub, activeAccount, false, evt.created_at).map(i => ({
...processedPub.map(i => ({
...i,
sourceKind: evt.kind,
setName: dTag,

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

View File

@@ -76,7 +76,7 @@ export async function fetchAllReads(
source: 'bookmark',
type: 'article',
readingProgress: 0,
readingTimestamp: bookmark.added_at || bookmark.created_at
readingTimestamp: bookmark.created_at ?? undefined
}
readsMap.set(coordinate, item)
if (onItem) emitItem(item)

View File

@@ -35,6 +35,8 @@
.reading-time svg { font-size: 0.875rem; }
.highlight-indicator { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.75rem; background: rgba(99, 102, 241, 0.1); border: 1px solid rgba(99, 102, 241, 0.3); border-radius: 6px; font-size: 0.875rem; color: var(--color-text); }
.highlight-indicator svg { font-size: 0.875rem; }
.highlight-indicator.clickable { cursor: pointer; transition: all 0.2s ease; }
.highlight-indicator.clickable:hover { background: rgba(99, 102, 241, 0.15); border-color: rgba(99, 102, 241, 0.5); transform: translateY(-1px); }
.reader-html { color: var(--color-text); line-height: 1.6; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; font-family: var(--reading-font); font-size: var(--reading-font-size); }
.reader-markdown { color: var(--color-text); line-height: 1.7; font-family: var(--reading-font); font-size: var(--reading-font-size); }
/* Ensure font inheritance */

View File

@@ -59,6 +59,8 @@
align-items: center;
gap: 0.5rem;
margin-left: auto;
flex: 1;
justify-content: flex-end;
}
/* Mobile hamburger button now uses Tailwind utilities in ThreePaneLayout */
@@ -92,7 +94,7 @@
gap: 0.5rem;
}
.profile-avatar {
.profile-avatar-button {
min-width: 33px;
min-height: 33px;
width: 33px;
@@ -108,10 +110,27 @@
color: var(--color-text);
box-sizing: border-box;
padding: 0;
cursor: pointer;
transition: all 0.2s ease;
}
.profile-avatar img { width: 100%; height: 100%; object-fit: cover; }
.profile-avatar svg { font-size: 1rem; }
/* Mobile touch target improvements */
@media (max-width: 768px) {
.profile-avatar-button {
min-width: var(--min-touch-target);
min-height: var(--min-touch-target);
width: var(--min-touch-target);
height: var(--min-touch-target);
}
}
.profile-avatar-button:hover {
background: var(--color-bg-hover);
border-color: var(--color-border);
}
.profile-avatar-button img { width: 100%; height: 100%; object-fit: cover; }
.profile-avatar-button svg { font-size: 1rem; }
.sidebar-header-bar .toggle-sidebar-btn {
background: transparent;

View File

@@ -31,7 +31,8 @@ export interface Bookmark {
export interface IndividualBookmark {
id: string
content: string
created_at: number
// Timestamp when the content was created (from the content event itself)
created_at: number | null
pubkey: string
kind: number
tags: string[][]
@@ -40,8 +41,6 @@ export interface IndividualBookmark {
type: 'event' | 'article' | 'web'
isPrivate?: boolean
encryptedContent?: string
// When the item was added to the bookmark list (synthetic, for sorting)
added_at?: number
// The kind of the source list/set that produced this bookmark (e.g., 10003, 30003, 30001, or 39701 for web)
sourceKind?: number
// The 'd' tag value from kind 30003 bookmark sets
@@ -50,6 +49,9 @@ export interface IndividualBookmark {
setTitle?: string
setDescription?: string
setImage?: string
// Timestamp of the bookmark list event (best proxy for "when bookmarked")
// Note: This is imperfect - it's when the list was last updated, not necessarily when this item was added
listUpdatedAt?: number | null
}
export interface ActiveAccount {

View File

@@ -4,13 +4,15 @@ import { ParsedContent, ParsedNode, IndividualBookmark } from '../types/bookmark
import ResolvedMention from '../components/ResolvedMention'
// Note: RichContent is imported by components directly to keep this file component-only for fast refresh
export const formatDate = (timestamp: number) => {
export const formatDate = (timestamp: number | null | undefined) => {
if (!timestamp || !isFinite(timestamp) || timestamp <= 0) return ''
const date = new Date(timestamp * 1000)
return formatDistanceToNow(date, { addSuffix: true })
}
// Ultra-compact date format for tight spaces (e.g., compact view)
export const formatDateCompact = (timestamp: number) => {
export const formatDateCompact = (timestamp: number | null | undefined) => {
if (!timestamp || !isFinite(timestamp) || timestamp <= 0) return ''
const date = new Date(timestamp * 1000)
const now = new Date()
@@ -85,9 +87,8 @@ export const renderParsedContent = (parsedContent: ParsedContent) => {
// Sorting and grouping for bookmarks
export const sortIndividualBookmarks = (items: IndividualBookmark[]) => {
return items
.slice()
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
const getSortTime = (b: IndividualBookmark) => b.created_at ?? b.listUpdatedAt ?? -Infinity
return items.slice().sort((a, b) => getSortTime(b) - getSortTime(a))
}
export function groupIndividualBookmarks(items: IndividualBookmark[]) {
@@ -122,10 +123,7 @@ export function hasContent(bookmark: IndividualBookmark): boolean {
export function hasCreationDate(bookmark: IndividualBookmark): boolean {
if (!bookmark.created_at) return false
// If timestamp is missing or equals current time (within 1 second), consider it invalid
const now = Math.floor(Date.now() / 1000)
const createdAt = Math.floor(bookmark.created_at)
// If created_at is within 1 second of now, it's likely missing/placeholder
return Math.abs(createdAt - now) > 1
return true
}
// Bookmark sets helpers (kind 30003)

View File

@@ -51,7 +51,7 @@ export function deriveLinksFromBookmarks(bookmarks: Bookmark[]): ReadItem[] {
summary,
image,
readingProgress: 0,
readingTimestamp: bookmark.added_at || bookmark.created_at
readingTimestamp: bookmark.created_at ?? undefined
}
linksMap.set(url, item)

View File

@@ -49,7 +49,7 @@ export function deriveReadsFromBookmarks(bookmarks: Bookmark[]): ReadItem[] {
source: 'bookmark',
type: 'article',
readingProgress: 0,
readingTimestamp: bookmark.added_at || bookmark.created_at,
readingTimestamp: bookmark.created_at ?? undefined,
title,
summary,
image,