Compare commits

...

112 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
Gigi
550ee415f0 chore: bump version to 0.10.10 2025-10-22 11:37:08 +02:00
Gigi
aaaf226623 Merge pull request #24 from dergigi/controllers-and-fetching
Replace timeouts with streaming controllers and fix bookmark hydration
2025-10-22 11:36:35 +02:00
Gigi
23ce0c9d4c chore: remove debug logging from bookmark controller 2025-10-22 11:33:41 +02:00
Gigi
dddf8575c4 fix: resolve TypeScript type errors in bookmark hydration promises
Add .then() handlers to convert Promise<NostrEvent[]> to Promise<void>
for compatibility with Promise<void>[] array type.
2025-10-22 11:30:53 +02:00
Gigi
3ab0610e1e fix: prevent cascading hydration loops in bookmark controller
Run all coordinate queries in parallel with Promise.all instead of
sequential awaits. This prevents each query from triggering a rebuild
that causes another hydration cycle, which was creating infinite loops.

The issue was that awaiting each query sequentially would:
1. Fetch articles for author A
2. Call onProgress, rebuild bookmarks
3. Trigger new hydration because coordinates changed
4. Repeat indefinitely

Now all queries start at once and stream results as they arrive,
matching the original loader behavior.
2025-10-22 11:27:12 +02:00
Gigi
e40f820fdc fix: handle empty d-tags separately in bookmark hydration
Separate fetching of articles with empty vs non-empty d-tags to work
around relay filter handling issues. For empty d-tags, fetch all events
of that kind/author and filter client-side.
2025-10-22 11:25:30 +02:00
Gigi
3f82bc7873 debug: add logging for bookmark coordinate hydration 2025-10-22 11:23:26 +02:00
Gigi
b913cc4d7f fix: hide 'Open Original' button for nostr-native events
Only external URLs (/r/ paths) have original sources.
Nostr-native events don't need this option in the three-dot menu.
2025-10-22 11:21:08 +02:00
Gigi
bc1aed30b4 fix: open nostr events directly on ants.sh instead of as search query
When clicking search in the three-dot menu for a nostr event,
now opens https://ants.sh/e/<eventId> directly instead of
https://ants.sh/?q=nostr-event:<eventId>
2025-10-22 11:20:12 +02:00
Gigi
9a801975aa fix(bookmarks): replace applesauce loaders with local-first queryEvents
Replace EventLoader and AddressLoader with queryEvents for bookmark
hydration to properly prioritize local relays. The applesauce loaders
were not using local-first fetching strategy, causing bookmarked events
to not be hydrated from local relay cache.

- Remove createEventLoader and createAddressLoader usage
- Replace with queryEvents which handles local-first fetching
- Properly streams events from local relays before remote relays
- Follows the controller pattern used by other services (writings, etc)

This fixes the issue where bookmarks would only show event IDs instead
of full content, while blog posts (kind:30023) worked correctly.
2025-10-22 11:16:21 +02:00
Gigi
f3e44edd51 fix: remove unnecessary key prop causing lag on tab switching in Explore 2025-10-22 11:09:05 +02:00
Gigi
0be6aa81ce fix: add comments to empty catch blocks to satisfy linter 2025-10-22 09:00:01 +02:00
Gigi
c7b885cfcd refactor(reader): use startReadingPositionStream in ContentPanel 2025-10-22 08:55:50 +02:00
Gigi
11041df1fb refactor(reading-position): add startReadingPositionStream and remove timeouts 2025-10-22 08:55:18 +02:00
Gigi
89273e2a03 refactor(settings): use startSettingsStream in useSettings hook 2025-10-22 08:54:45 +02:00
Gigi
0610454e74 feat(settings): add startSettingsStream and remove timeout-based blocking 2025-10-22 08:54:17 +02:00
Gigi
a02413a7cb fix(reading-progress): load and display progress on fresh sessions; include external URL keys and avoid double-encoding; add debug guard 2025-10-22 02:02:39 +02:00
Gigi
0bc84e7c6c chore: update package-lock.json for v0.10.9 2025-10-22 01:41:46 +02:00
Gigi
a1e28c6bc9 docs: update CHANGELOG for v0.10.9 2025-10-22 01:41:34 +02:00
Gigi
a1a7f0e4a4 chore: bump version to 0.10.9 2025-10-22 01:41:14 +02:00
Gigi
cde8e30ab2 fix(events): improve /e/ reliability with retry + backoff in eventManager
- Add multi-attempt fetch with backoff
- Retry on not-found, errors, and timeouts before failing
- Keep deduplication and cache-first behavior
2025-10-22 01:40:26 +02:00
Gigi
aa7e532950 fix(bookmarks): use per-item added_at/created_at when available
- Read / from applesauce pointers for notes/articles
- Fallback to eventStore event  during enrichment
- Keeps sorting by  then  consistent
2025-10-22 01:35:06 +02:00
Gigi
c9208cfff2 chore: remove all debug console logs
- Remove console.log from bookmark hydration
- Remove console.log from relay initialization
- Remove all console.debug calls from TTS hook and controls
- Remove debug logging from RouteDebug component
- Fix useCallback dependency warning in speak function
2025-10-22 01:26:42 +02:00
Gigi
2fb4132342 docs: update CHANGELOG for v0.10.8 2025-10-22 01:25:41 +02:00
Gigi
81180c8ba8 chore: bump version to 0.10.8 2025-10-22 01:23:13 +02:00
Gigi
1c48adf44e Merge pull request #23 from dergigi/e-path
feat: add /e/:eventId path for individual event rendering
2025-10-22 01:22:52 +02:00
50 changed files with 1521 additions and 761 deletions

View File

@@ -2,4 +2,4 @@
alwaysApply: true alwaysApply: true
--- ---
Keep files below 210 lines. Keep files below 420 lines.

View File

@@ -0,0 +1,18 @@
---
description: fetching data from relays
alwaysApply: false
---
We fetch data from relays using controllers:
- Start controllers immediatly; dont await.
- Stream via onEvent; dedupe replaceables; emit immediately.
- Parallel local/remote queries; complete on EOSE.
- Finalize and persist since after completion.
- Guard with generations to cancel stale runs.
- UI flips off loading on first streamed result.
We always include and prefer local relays for reads; optionally rebroadcast fetched content to local relays (depending on setting); and tolerate localonly mode for writes (queueing for later).
Since we are streaming results, we should NEVER use timeouts for fetching data. We should always rely on EOSE.
In short: Local-first hydration, background network fetch, reactive updates, and replaceable lookups provide instant UI with eventual consistency. Use local relays as local data store for everything we fetch from remote relays.

View File

@@ -7,6 +7,208 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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
- Event fetching reliability with exponential backoff in eventManager
- Improved retry logic with incremental backoff delays
- Better handling of concurrent event requests
- More robust event retrieval from relay pool
- Bookmark timestamp handling
- Use per-item `added_at`/`created_at` timestamps when available
- Improves accuracy of bookmark date tracking
### Changed
- Removed all debug console logs
- Cleaner console output in development and production
- Improved performance by eliminating debugging statements
## [0.10.8] - 2025-10-21
### Added
- Individual event rendering via `/e/:eventId` path
- Display `kind:1` notes and other events with article-like presentation
- Publication date displayed in top-right corner like articles
- Author attribution with "Note by @author" titles
- Direct event loading with intelligent caching from eventStore
- Centralized event fetching via new `eventManager` singleton
- Request deduplication for concurrent fetches
- Automatic retry logic when relay pool becomes available
- Non-blocking background fetching with 12-second timeout
- Seamless integration with eventStore for instant cached event display
### Fixed
- Bookmark hydration efficiency
- Only request content for bookmarks missing data (not all bookmarks)
- Use eventStore fallback for instant display of cached profiles
- Prevents over-fetching and improves initial load performance
- Search button behavior for notes
- Opens `kind:1` notes directly via `/e/{eventId}` instead of search portal
- Articles continue to use search portal with proper naddr encoding
- Removes unwanted `nostr-event:` prefix from URLs
- Author profile resolution
- Fetch author profiles from eventStore cache first before relay requests
- Instant title updates if profile already loaded
- Graceful fallback to short pubkey display if profile unavailable
## [0.10.7] - 2025-10-21 ## [0.10.7] - 2025-10-21
### Fixed ### Fixed
@@ -2388,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 - Optimize relay usage following applesauce-relay best practices
- Use applesauce-react event models for better profile handling - 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.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.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 [0.10.2]: https://github.com/dergigi/boris/compare/v0.10.1...v0.10.2

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "boris", "name": "boris",
"version": "0.10.5", "version": "0.10.9",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "boris", "name": "boris",
"version": "0.10.5", "version": "0.10.9",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0", "@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0", "@fortawesome/free-regular-svg-icons": "^7.1.0",

View File

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

View File

@@ -253,7 +253,7 @@ function AppRoutes({
} }
/> />
<Route <Route
path="/me/reading-list" path="/me/bookmarks"
element={ element={
<Bookmarks <Bookmarks
relayPool={relayPool} relayPool={relayPool}
@@ -578,8 +578,6 @@ function App() {
// Handle user relay list and blocked relays when account changes // Handle user relay list and blocked relays when account changes
const userRelaysSub = accounts.active$.subscribe((account) => { const userRelaysSub = accounts.active$.subscribe((account) => {
console.log('[relay-init] userRelaysSub fired, account:', account ? 'logged in' : 'logged out')
console.log('[relay-init] Pool has', Array.from(pool.relays.keys()).length, 'relays before applying changes')
if (account) { if (account) {
// User logged in - start with hardcoded relays immediately, then stream user relay list updates // User logged in - start with hardcoded relays immediately, then stream user relay list updates
const pubkey = account.pubkey const pubkey = account.pubkey

View File

@@ -50,6 +50,14 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
return ( return (
<Link <Link
to={href} to={href}
state={{
previewData: {
title: post.title,
image: post.image,
summary: post.summary,
published: post.published
}
}}
className={`blog-post-card ${level ? `level-${level}` : ''}`} className={`blog-post-card ${level ? `level-${level}` : ''}`}
style={{ textDecoration: 'none', color: 'inherit' }} 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 { IconDefinition } from '@fortawesome/fontawesome-svg-core'
import { useEventModel } from 'applesauce-react/hooks' import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core' import { Models } from 'applesauce-core'
import { npubEncode, neventEncode } from 'nostr-tools/nip19' import { npubEncode } from 'nostr-tools/nip19'
import { IndividualBookmark } from '../types/bookmarks' import { IndividualBookmark } from '../types/bookmarks'
import { extractUrlsFromContent } from '../services/bookmarkHelpers' import { extractUrlsFromContent } from '../services/bookmarkHelpers'
import { classifyUrl } from '../utils/helpers' import { classifyUrl } from '../utils/helpers'
@@ -58,8 +58,6 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
// Resolve author profile using applesauce // Resolve author profile using applesauce
const authorProfile = useEventModel(Models.ProfileModel, [bookmark.pubkey]) const authorProfile = useEventModel(Models.ProfileModel, [bookmark.pubkey])
const authorNpub = npubEncode(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 // Get display name for author
const getAuthorDisplayName = () => { const getAuthorDisplayName = () => {
@@ -135,7 +133,6 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
extractedUrls, extractedUrls,
onSelectUrl, onSelectUrl,
authorNpub, authorNpub,
eventNevent,
getAuthorDisplayName, getAuthorDisplayName,
handleReadNow, handleReadNow,
articleImage, articleImage,
@@ -152,7 +149,6 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
extractedUrls, extractedUrls,
onSelectUrl, onSelectUrl,
authorNpub, authorNpub,
eventNevent,
getAuthorDisplayName, getAuthorDisplayName,
handleReadNow, handleReadNow,
articleSummary, 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 { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 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 { formatDistanceToNow } from 'date-fns'
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { Bookmark, IndividualBookmark } from '../types/bookmarks' import { Bookmark, IndividualBookmark } from '../types/bookmarks'
@@ -13,7 +14,7 @@ import { ViewMode } from './Bookmarks'
import { usePullToRefresh } from 'use-pull-to-refresh' import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator' import RefreshIndicator from './RefreshIndicator'
import { BookmarkSkeleton } from './Skeletons' 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 { UserSettings } from '../services/settingsService'
import AddBookmarkModal from './AddBookmarkModal' import AddBookmarkModal from './AddBookmarkModal'
import { createWebBookmark } from '../services/webBookmarkService' import { createWebBookmark } from '../services/webBookmarkService'
@@ -71,7 +72,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
const [selectedFilter, setSelectedFilter] = useState<BookmarkFilterType>('all') const [selectedFilter, setSelectedFilter] = useState<BookmarkFilterType>('all')
const [groupingMode, setGroupingMode] = useState<'grouped' | 'flat'>(() => { const [groupingMode, setGroupingMode] = useState<'grouped' | 'flat'>(() => {
const saved = localStorage.getItem('bookmarkGroupingMode') const saved = localStorage.getItem('bookmarkGroupingMode')
return saved === 'flat' ? 'flat' : 'grouped' return saved === 'grouped' ? 'grouped' : 'flat'
}) })
const activeAccount = Hooks.useActiveAccount() const activeAccount = Hooks.useActiveAccount()
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map()) const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
@@ -120,6 +121,18 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
localStorage.setItem('bookmarkGroupingMode', newMode) 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[]) => { const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
if (!activeAccount || !relayPool) { if (!activeAccount || !relayPool) {
throw new Error('Please login to create bookmarks') throw new Error('Please login to create bookmarks')
@@ -140,39 +153,58 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
isDisabled: !onRefresh isDisabled: !onRefresh
}) })
// Merge and flatten all individual bookmarks from all lists // Merge and flatten all individual bookmarks from all lists - memoized to ensure consistent sorting
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || []) const sections = useMemo(() => {
.filter(hasContent) const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b)) .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 // Get all filtered bookmarks for empty state checks
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter) 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 filteredBookmarks = useMemo(() =>
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks) filterBookmarksByType(allIndividualBookmarks, selectedFilter),
const bookmarkSets = getBookmarkSets(filteredBookmarks) [allIndividualBookmarks, selectedFilter]
)
// 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
})
})
if (isCollapsed) { if (isCollapsed) {
// Check if the selected URL is in bookmarks // Check if the selected URL is in bookmarks
@@ -286,7 +318,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
{activeAccount && ( {activeAccount && (
<div className="view-mode-right"> <div className="view-mode-right">
<IconButton <IconButton
icon={groupingMode === 'grouped' ? faLayerGroup : faBars} icon={groupingMode === 'grouped' ? faLayerGroup : faClock}
onClick={toggleGroupingMode} onClick={toggleGroupingMode}
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'} title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'} 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 { classifyUrl } from '../../utils/helpers'
import { useImageCache } from '../../hooks/useImageCache' import { useImageCache } from '../../hooks/useImageCache'
import { getPreviewImage, fetchOgImage } from '../../utils/imagePreview' import { getPreviewImage, fetchOgImage } from '../../utils/imagePreview'
import { getEventUrl } from '../../config/nostrGateways' import { naddrEncode } from 'nostr-tools/nip19'
interface CardViewProps { interface CardViewProps {
bookmark: IndividualBookmark bookmark: IndividualBookmark
@@ -18,7 +18,6 @@ interface CardViewProps {
extractedUrls: string[] extractedUrls: string[]
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
authorNpub: string authorNpub: string
eventNevent?: string
getAuthorDisplayName: () => string getAuthorDisplayName: () => string
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
articleImage?: string articleImage?: string
@@ -34,7 +33,6 @@ export const CardView: React.FC<CardViewProps> = ({
extractedUrls, extractedUrls,
onSelectUrl, onSelectUrl,
authorNpub, authorNpub,
eventNevent,
getAuthorDisplayName, getAuthorDisplayName,
handleReadNow, handleReadNow,
articleImage, 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 ( return (
<div <div
key={`${bookmark.id}-${index}`} key={`${bookmark.id}-${index}`}
@@ -103,19 +124,17 @@ export const CardView: React.FC<CardViewProps> = ({
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" /> <FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
</span> </span>
{eventNevent ? ( {getInternalRoute() ? (
<a <Link
href={getEventUrl(eventNevent)} to={getInternalRoute()!}
target="_blank"
rel="noopener noreferrer"
className="bookmark-date-link" className="bookmark-date-link"
title="Open event in search" title="Open in app"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{formatDate(bookmark.created_at)} {formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}
</a> </Link>
) : ( ) : (
<span className="bookmark-date">{formatDate(bookmark.created_at)}</span> <span className="bookmark-date">{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}</span>
)} )}
</div> </div>

View File

@@ -73,7 +73,7 @@ export const CompactView: React.FC<CompactViewProps> = ({
<code>{bookmark.id.slice(0, 12)}...</code> <code>{bookmark.id.slice(0, 12)}...</code>
</div> </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 */} {/* CTA removed */}
</div> </div>

View File

@@ -7,7 +7,7 @@ import { formatDate } from '../../utils/bookmarkUtils'
import RichContent from '../RichContent' import RichContent from '../RichContent'
import { IconGetter } from './shared' import { IconGetter } from './shared'
import { useImageCache } from '../../hooks/useImageCache' import { useImageCache } from '../../hooks/useImageCache'
import { getEventUrl } from '../../config/nostrGateways' import { naddrEncode } from 'nostr-tools/nip19'
interface LargeViewProps { interface LargeViewProps {
bookmark: IndividualBookmark bookmark: IndividualBookmark
@@ -18,7 +18,6 @@ interface LargeViewProps {
getIconForUrlType: IconGetter getIconForUrlType: IconGetter
previewImage: string | null previewImage: string | null
authorNpub: string authorNpub: string
eventNevent?: string
getAuthorDisplayName: () => string getAuthorDisplayName: () => string
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
articleSummary?: string articleSummary?: string
@@ -35,7 +34,6 @@ export const LargeView: React.FC<LargeViewProps> = ({
getIconForUrlType, getIconForUrlType,
previewImage, previewImage,
authorNpub, authorNpub,
eventNevent,
getAuthorDisplayName, getAuthorDisplayName,
handleReadNow, handleReadNow,
articleSummary, 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 ( return (
<div <div
key={`${bookmark.id}-${index}`} key={`${bookmark.id}-${index}`}
@@ -136,16 +158,17 @@ export const LargeView: React.FC<LargeViewProps> = ({
</Link> </Link>
</span> </span>
{eventNevent && ( {getInternalRoute() ? (
<a <Link
href={getEventUrl(eventNevent)} to={getInternalRoute()!}
target="_blank"
rel="noopener noreferrer"
className="bookmark-date-link" className="bookmark-date-link"
title="Open in app"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{formatDate(bookmark.created_at)} {formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}
</a> </Link>
) : (
<span className="bookmark-date">{formatDate(bookmark.created_at ?? bookmark.listUpdatedAt)}</span>
)} )}
{/* CTA removed */} {/* CTA removed */}

View File

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

View File

@@ -43,9 +43,9 @@ import { EventFactory } from 'applesauce-factory'
import { Hooks } from 'applesauce-react' import { Hooks } from 'applesauce-react'
import { import {
generateArticleIdentifier, generateArticleIdentifier,
loadReadingPosition, saveReadingPosition
saveReadingPosition
} from '../services/readingPositionService' } from '../services/readingPositionService'
import { readingProgressController } from '../services/readingProgressController'
import TTSControls from './TTSControls' import TTSControls from './TTSControls'
interface ContentPanelProps { interface ContentPanelProps {
@@ -76,6 +76,7 @@ interface ContentPanelProps {
// For reading progress indicator positioning // For reading progress indicator positioning
isSidebarCollapsed?: boolean isSidebarCollapsed?: boolean
isHighlightsCollapsed?: boolean isHighlightsCollapsed?: boolean
onOpenHighlights?: () => void
} }
const ContentPanel: React.FC<ContentPanelProps> = ({ const ContentPanel: React.FC<ContentPanelProps> = ({
@@ -103,7 +104,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
onTextSelection, onTextSelection,
onClearSelection, onClearSelection,
isSidebarCollapsed = false, isSidebarCollapsed = false,
isHighlightsCollapsed = false isHighlightsCollapsed = false,
onOpenHighlights
}) => { }) => {
const [isMarkedAsRead, setIsMarkedAsRead] = useState(false) const [isMarkedAsRead, setIsMarkedAsRead] = useState(false)
const [isCheckingReadStatus, setIsCheckingReadStatus] = useState(false) const [isCheckingReadStatus, setIsCheckingReadStatus] = useState(false)
@@ -132,6 +134,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
currentUserPubkey, currentUserPubkey,
followedPubkeys 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({ const { contentRef, handleSelectionEnd } = useHighlightInteractions({
onHighlightClick, onHighlightClick,
@@ -143,8 +150,15 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
// Get event store for reading position service // Get event store for reading position service
const eventStore = Hooks.useEventStore() const eventStore = Hooks.useEventStore()
// Reading position tracking - only for text content, not videos // Reading position tracking - only for text content that's loaded and long enough
const isTextContent = !loading && !!(markdown || html) && !selectedUrl?.includes('youtube') && !selectedUrl?.includes('vimeo') // 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 // Generate article identifier for saving/loading position
const articleIdentifier = useMemo(() => { const articleIdentifier = useMemo(() => {
@@ -155,20 +169,24 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
// Callback to save reading position // Callback to save reading position
const handleSavePosition = useCallback(async (position: number) => { const handleSavePosition = useCallback(async (position: number) => {
if (!activeAccount || !relayPool || !eventStore || !articleIdentifier) { if (!activeAccount || !relayPool || !eventStore || !articleIdentifier) {
console.log('[reading-position] ❌ Cannot save: missing dependencies')
return return
} }
if (!settings?.syncReadingPosition) { if (!settings?.syncReadingPosition) {
console.log('[reading-position] ⚠️ Save skipped: sync disabled in settings')
return return
} }
// Check if content is long enough to track reading progress // Check if content is long enough to track reading progress
if (!shouldTrackReadingProgress(html, markdown)) { if (!shouldTrackReadingProgress(html, markdown)) {
console.log('[reading-position] ⚠️ Save skipped: content too short')
return return
} }
const scrollTop = window.pageYOffset || document.documentElement.scrollTop const scrollTop = window.pageYOffset || document.documentElement.scrollTop
try { try {
console.log(`[reading-position] [${new Date().toISOString()}] 🚀 Publishing position ${Math.round(position * 100)}% to relays...`)
const factory = new EventFactory({ signer: activeAccount }) const factory = new EventFactory({ signer: activeAccount })
await saveReadingPosition( await saveReadingPosition(
relayPool, relayPool,
@@ -181,13 +199,34 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
scrollTop scrollTop
} }
) )
console.log(`[reading-position] [${new Date().toISOString()}] ✅ Position published successfully`)
} catch (error) { } catch (error) {
console.error('[progress] ❌ ContentPanel: Failed to save reading position:', error) console.error(`[reading-position] [${new Date().toISOString()}] ❌ Failed to save reading position:`, error)
} }
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, html, markdown]) }, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, html, markdown])
const { progressPercentage, saveNow } = useReadingPosition({ // Delay enabling position tracking to ensure content is stable
enabled: isTextContent, 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, syncEnabled: settings?.syncReadingPosition !== false,
onSave: handleSavePosition, onSave: handleSavePosition,
onReadingComplete: () => { onReadingComplete: () => {
@@ -207,59 +246,109 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
useEffect(() => { useEffect(() => {
}, [isTextContent, settings?.syncReadingPosition, activeAccount, relayPool, eventStore, articleIdentifier, progressPercentage]) }, [isTextContent, settings?.syncReadingPosition, activeAccount, relayPool, eventStore, articleIdentifier, progressPercentage])
// Load saved reading position when article loads // Load saved reading position when article loads (using pre-loaded data from controller)
const suppressSavesForRef = useRef(suppressSavesFor)
useEffect(() => { 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 return
} }
if (settings?.syncReadingPosition === false) { 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 return
} }
const loadPosition = async () => { // Only attempt restore once per article (after tracking is enabled)
try { if (hasAttemptedRestoreRef.current === restoreKey) {
const savedPosition = await loadReadingPosition( console.log('[reading-position] ⏭️ Restore skipped: already attempted for this article')
relayPool, return
eventStore, }
activeAccount.pubkey,
articleIdentifier
)
if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) { console.log('[reading-position] 🔄 Initiating restore for article:', articleIdentifier)
// Wait for content to be fully rendered before scrolling // Mark as attempted using composite key
setTimeout(() => { hasAttemptedRestoreRef.current = restoreKey
const documentHeight = document.documentElement.scrollHeight
const windowHeight = window.innerHeight // Get the saved position from the controller (already loaded and displayed on card)
const scrollTop = savedPosition.position * (documentHeight - windowHeight) const savedProgress = readingProgressController.getProgress(articleIdentifier)
window.scrollTo({ if (!savedProgress || savedProgress <= 0.05 || savedProgress >= 1) {
top: scrollTop, console.log('[reading-position] No position to restore (progress:', savedProgress, ')')
behavior: 'smooth' return
}) }
}, 500) // Give content time to render
} else if (savedPosition) { console.log('[reading-position] 🎯 Found saved position:', Math.round(savedProgress * 100) + '%')
if (savedPosition.position === 1) {
// Article was completed, start from top // Suppress saves during restore (500ms render + 1000ms animation + 500ms buffer = 2000ms)
} else { if (suppressSavesForRef.current) {
// Position was too early, skip restore 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)
} }
} catch (error) { return
console.error('❌ [ContentPanel] Failed to load reading position:', error)
} }
}
loadPosition() console.log('[reading-position] 📜 Restoring scroll position (delta:', deltaPx, 'px,', Math.round(deltaPct * 100) + '%)')
}, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
// Save position before unmounting or changing article // Perform smooth animated restore
useEffect(() => { window.scrollTo({
return () => { top: targetTop,
if (saveNow) { behavior: 'smooth'
saveNow() })
} console.log('[reading-position] ✅ Scroll restored to', Math.round(savedProgress * 100) + '%')
} }, 500) // Give content time to render
}, [saveNow, selectedUrl]) }, [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 // Close menu when clicking outside
useEffect(() => { useEffect(() => {
@@ -577,7 +666,13 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
const handleSearchExternalUrl = () => { const handleSearchExternalUrl = () => {
if (selectedUrl) { if (selectedUrl) {
window.open(getSearchUrl(selectedUrl), '_blank', 'noopener,noreferrer') // If it's a nostr event sentinel, open the event directly on ants.sh
if (selectedUrl.startsWith('nostr-event:')) {
const eventId = selectedUrl.replace('nostr-event:', '')
window.open(`https://ants.sh/e/${eventId}`, '_blank', 'noopener,noreferrer')
} else {
window.open(getSearchUrl(selectedUrl), '_blank', 'noopener,noreferrer')
}
} }
setShowExternalMenu(false) setShowExternalMenu(false)
} }
@@ -754,7 +849,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
<div className="reader" style={{ '--highlight-rgb': highlightRgb } as React.CSSProperties}> <div className="reader" style={{ '--highlight-rgb': highlightRgb } as React.CSSProperties}>
{/* Hidden markdown preview to convert markdown to HTML */} {/* Hidden markdown preview to convert markdown to HTML */}
{markdown && ( {markdown && (
<div ref={markdownPreviewRef} style={{ display: 'none' }}> <div ref={markdownPreviewRef} key={`preview:${contentKey}`} style={{ display: 'none' }}>
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypePrism]} rehypePlugins={[rehypeRaw, rehypePrism]}
@@ -783,6 +878,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
settings={settings} settings={settings}
highlights={relevantHighlights} highlights={relevantHighlights}
highlightVisibility={highlightVisibility} highlightVisibility={highlightVisibility}
onHighlightCountClick={onOpenHighlights}
/> />
{isTextContent && articleText && ( {isTextContent && articleText && (
<div style={{ padding: '0 0.75rem 0.5rem 0.75rem' }}> <div style={{ padding: '0 0.75rem 0.5rem 0.75rem' }}>
@@ -874,6 +970,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
{markdown ? ( {markdown ? (
renderedMarkdownHtml && finalHtml ? ( renderedMarkdownHtml && finalHtml ? (
<VideoEmbedProcessor <VideoEmbedProcessor
key={`content:${contentKey}`}
ref={contentRef} ref={contentRef}
html={finalHtml} html={finalHtml}
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo} renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo}
@@ -890,6 +987,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
) )
) : ( ) : (
<VideoEmbedProcessor <VideoEmbedProcessor
key={`content:${contentKey}`}
ref={contentRef} ref={contentRef}
html={finalHtml || html || ''} html={finalHtml || html || ''}
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo} renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo}
@@ -927,13 +1025,16 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
<FontAwesomeIcon icon={faCopy} /> <FontAwesomeIcon icon={faCopy} />
<span>Copy URL</span> <span>Copy URL</span>
</button> </button>
<button {/* Only show "Open Original" for actual external URLs, not nostr events */}
className="article-menu-item" {!selectedUrl?.startsWith('nostr-event:') && (
onClick={handleOpenExternalUrl} <button
> className="article-menu-item"
<FontAwesomeIcon icon={faExternalLinkAlt} /> onClick={handleOpenExternalUrl}
<span>Open Original</span> >
</button> <FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open Original</span>
</button>
)}
<button <button
className="article-menu-item" className="article-menu-item"
onClick={handleSearchExternalUrl} onClick={handleSearchExternalUrl}

View File

@@ -781,9 +781,16 @@ const Debug: React.FC<DebugProps> = ({
} }
}) })
// Load deduplicated results via controller // Load deduplicated results via controller (includes articles and external URLs)
const unsubProgress = readingProgressController.onProgress((progressMap) => { const unsubProgress = readingProgressController.onProgress((progressMap) => {
setDeduplicatedProgressMap(new Map(progressMap)) setDeduplicatedProgressMap(new Map(progressMap))
// Regression guard: ensure keys include both naddr and raw URL forms when present
try {
const keys = Array.from(progressMap.keys())
const sample = keys.slice(0, 5).join(', ')
DebugBus.info('debug', `Progress keys sample: ${sample}`)
} catch { /* ignore */ }
}) })
// Run both in parallel // Run both in parallel

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,7 +20,7 @@ export default function RouteDebug() {
// Unexpected during deep-link refresh tests // Unexpected during deep-link refresh tests
console.warn('[RouteDebug] unexpected root redirect', info) console.warn('[RouteDebug] unexpected root redirect', info)
} else { } else {
console.debug('[RouteDebug]', info) // silent
} }
}, [location, matchArticle]) }, [location, matchArticle])

View File

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

View File

@@ -1,7 +1,7 @@
import React from 'react' import React from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 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 { Hooks } from 'applesauce-react'
import { useEventModel } from 'applesauce-react/hooks' import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core' import { Models } from 'applesauce-core'
@@ -36,70 +36,61 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
return ( return (
<> <>
<div className="sidebar-header-bar"> <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 && ( {activeAccount && (
<div <button
className="profile-avatar" className="profile-avatar-button"
title={getUserDisplayName()} title={getUserDisplayName()}
onClick={() => navigate('/me')} onClick={() => navigate('/me')}
style={{ cursor: 'pointer' }} aria-label={`Profile: ${getUserDisplayName()}`}
> >
{profileImage ? ( {profileImage ? (
<img src={profileImage} alt={getUserDisplayName()} /> <img src={profileImage} alt={getUserDisplayName()} />
) : ( ) : (
<FontAwesomeIcon icon={faUserCircle} /> <FontAwesomeIcon icon={faUserCircle} />
)} )}
</div> </button>
)} )}
<IconButton <div className="sidebar-header-right">
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 && (
<IconButton <IconButton
icon={faRightFromBracket} icon={faHome}
onClick={onLogout} onClick={() => navigate('/')}
title="Logout" title="Home"
ariaLabel="Logout" ariaLabel="Home"
variant="ghost" 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>
</div> </div>
</> </>

View File

@@ -60,7 +60,7 @@ const TTSControls: React.FC<Props> = ({ text, defaultLang, className, settings }
const lang = detect(text) const lang = detect(text)
if (typeof lang === 'string' && lang.length >= 2) langOverride = lang.slice(0, 2) if (typeof lang === 'string' && lang.length >= 2) langOverride = lang.slice(0, 2)
} catch (err) { } catch (err) {
console.debug('[tts][detect] failed', err) // ignore detection errors
} }
} }
if (!langOverride && resolvedSystemLang) { if (!langOverride && resolvedSystemLang) {
@@ -78,7 +78,6 @@ const TTSControls: React.FC<Props> = ({ text, defaultLang, className, settings }
const currentIndex = SPEED_OPTIONS.indexOf(rate) const currentIndex = SPEED_OPTIONS.indexOf(rate)
const nextIndex = (currentIndex + 1) % SPEED_OPTIONS.length const nextIndex = (currentIndex + 1) % SPEED_OPTIONS.length
const next = SPEED_OPTIONS[nextIndex] const next = SPEED_OPTIONS[nextIndex]
console.debug('[tts][ui] cycle speed', { from: rate, to: next, speaking, paused })
setRate(next) setRate(next)
} }

View File

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

View File

@@ -21,9 +21,10 @@ const VideoEmbedProcessor = forwardRef<HTMLDivElement, VideoEmbedProcessorProps>
onMouseUp, onMouseUp,
onTouchEnd onTouchEnd
}, ref) => { }, 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) { if (!renderVideoLinksAsEmbeds || !html) {
return html return { processedHtml: html, videoUrls: [] }
} }
// Process HTML in stages: <video> blocks, <img> tags with video src, and bare video URLs // 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)) const remainingUrls = [...fileVideoUrls, ...platformVideoUrls].filter(url => !collectedUrls.includes(url))
let processedHtml = result let finalHtml = result
remainingUrls.forEach((url) => { remainingUrls.forEach((url) => {
const placeholder = `__VIDEO_EMBED_${placeholderIndex}__` 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) collectedUrls.push(url)
placeholderIndex++ placeholderIndex++
}) })
// If nothing collected, return original html // Return both processed HTML and collected URLs (in the same order as placeholders)
if (collectedUrls.length === 0) { return {
return html 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]) }, [html, renderVideoLinksAsEmbeds])
// If no video embedding is enabled, just render the HTML normally // If no video embedding is enabled, just render the HTML normally
@@ -195,13 +144,16 @@ const VideoEmbedProcessor = forwardRef<HTMLDivElement, VideoEmbedProcessorProps>
} }
} }
// Regular HTML content // Regular HTML content - only render if not empty
return ( if (part.trim()) {
<div return (
key={index} <div
dangerouslySetInnerHTML={{ __html: part }} key={index}
/> dangerouslySetInnerHTML={{ __html: part }}
) />
)
}
return null
})} })}
</div> </div>
) )

View File

@@ -1,5 +1,11 @@
import { useEffect, useRef, Dispatch, SetStateAction } from 'react' import { useEffect, useRef, Dispatch, SetStateAction } from 'react'
import { useLocation } from 'react-router-dom'
import { RelayPool } from 'applesauce-relay' 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 { fetchArticleByNaddr } from '../services/articleService'
import { fetchHighlightsForArticle } from '../services/highlightService' import { fetchHighlightsForArticle } from '../services/highlightService'
import { ReadableContent } from '../services/readerService' import { ReadableContent } from '../services/readerService'
@@ -7,9 +13,17 @@ import { Highlight } from '../types/highlights'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { UserSettings } from '../services/settingsService' import { UserSettings } from '../services/settingsService'
interface PreviewData {
title: string
image?: string
summary?: string
published?: number
}
interface UseArticleLoaderProps { interface UseArticleLoaderProps {
naddr: string | undefined naddr: string | undefined
relayPool: RelayPool | null relayPool: RelayPool | null
eventStore?: IEventStore | null
setSelectedUrl: (url: string) => void setSelectedUrl: (url: string) => void
setReaderContent: (content: ReadableContent | undefined) => void setReaderContent: (content: ReadableContent | undefined) => void
setReaderLoading: (loading: boolean) => void setReaderLoading: (loading: boolean) => void
@@ -25,6 +39,7 @@ interface UseArticleLoaderProps {
export function useArticleLoader({ export function useArticleLoader({
naddr, naddr,
relayPool, relayPool,
eventStore,
setSelectedUrl, setSelectedUrl,
setReaderContent, setReaderContent,
setReaderLoading, setReaderLoading,
@@ -36,7 +51,18 @@ export function useArticleLoader({
setCurrentArticle, setCurrentArticle,
settings settings
}: UseArticleLoaderProps) { }: UseArticleLoaderProps) {
const location = useLocation()
const mountedRef = useRef(true) 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(() => { useEffect(() => {
mountedRef.current = true mountedRef.current = true
@@ -44,67 +70,204 @@ export function useArticleLoader({
if (!relayPool || !naddr) return if (!relayPool || !naddr) return
const loadArticle = async () => { const loadArticle = async () => {
const requestId = ++currentRequestIdRef.current
if (!mountedRef.current) return if (!mountedRef.current) return
setReaderLoading(true)
setReaderContent(undefined)
setSelectedUrl(`nostr:${naddr}`) setSelectedUrl(`nostr:${naddr}`)
setIsCollapsed(true) setIsCollapsed(true)
try { // If we have preview data from navigation, show it immediately (no skeleton!)
const article = await fetchArticleByNaddr(relayPool, naddr, false, settings) if (previewData) {
if (!mountedRef.current) return
setReaderContent({ setReaderContent({
title: article.title, title: previewData.title,
markdown: article.markdown, markdown: '', // Will be loaded from store or relay
image: article.image, image: previewData.image,
summary: article.summary, summary: previewData.summary,
published: article.published, published: previewData.published,
url: `nostr:${naddr}` url: `nostr:${naddr}`
}) })
setReaderLoading(false) // Turn off loading immediately - we have the preview!
const dTag = article.event.tags.find(t => t[0] === 'd')?.[1] || '' } else {
const articleCoordinate = `${article.event.kind}:${article.author}:${dTag}` setReaderLoading(true)
setReaderContent(undefined)
setCurrentArticleCoordinate(articleCoordinate) }
setCurrentArticleEventId(article.event.id)
setCurrentArticle?.(article.event) try {
setReaderLoading(false) // Decode naddr to filter
const decoded = nip19.decode(naddr)
// Fetch highlights asynchronously without blocking article display 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 { try {
if (!mountedRef.current) return if (!mountedRef.current) return
setHighlightsLoading(true) const le = latestEvent as NostrEvent | null
setHighlights([]) 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( if (coord && eventId) {
relayPool, setHighlightsLoading(true)
articleCoordinate, setHighlights([])
article.event.id, await fetchHighlightsForArticle(
(highlight) => { relayPool,
if (!mountedRef.current) return coord,
eventId,
setHighlights((prev: Highlight[]) => { (highlight) => {
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev if (!mountedRef.current) return
const next = [highlight, ...prev] if (currentRequestIdRef.current !== requestId) return
return next.sort((a, b) => b.created_at - a.created_at) setHighlights((prev: Highlight[]) => {
}) if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
}, const next = [highlight, ...prev]
settings 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) { } catch (err) {
console.error('Failed to fetch highlights:', err) console.error('Failed to fetch highlights:', err)
} finally { } finally {
if (mountedRef.current) { if (mountedRef.current && currentRequestIdRef.current === requestId) {
setHighlightsLoading(false) setHighlightsLoading(false)
} }
} }
} catch (err) { } catch (err) {
console.error('Failed to load article:', err) console.error('Failed to load article:', err)
if (mountedRef.current) { if (mountedRef.current && currentRequestIdRef.current === requestId) {
setReaderContent({ setReaderContent({
title: 'Error Loading Article', title: 'Error Loading Article',
html: `<p>Failed to load article: ${err instanceof Error ? err.message : 'Unknown error'}</p>`, html: `<p>Failed to load article: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
@@ -123,7 +286,8 @@ export function useArticleLoader({
}, [ }, [
naddr, naddr,
relayPool, relayPool,
settings, eventStore,
previewData,
setSelectedUrl, setSelectedUrl,
setReaderContent, setReaderContent,
setReaderLoading, setReaderLoading,

View File

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

View File

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

View File

@@ -7,7 +7,6 @@ interface UseReadingPositionOptions {
readingCompleteThreshold?: number // Default 0.95 (95%) - matches filter threshold readingCompleteThreshold?: number // Default 0.95 (95%) - matches filter threshold
syncEnabled?: boolean // Whether to sync positions to Nostr syncEnabled?: boolean // Whether to sync positions to Nostr
onSave?: (position: number) => void // Callback for saving position 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) 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 readingCompleteThreshold = 0.95, // Match filter threshold for consistency
syncEnabled = false, syncEnabled = false,
onSave, onSave,
autoSaveInterval = 5000,
completionHoldMs = 2000 completionHoldMs = 2000
}: UseReadingPositionOptions = {}) => { }: UseReadingPositionOptions = {}) => {
const [position, setPosition] = useState(0) const [position, setPosition] = useState(0)
@@ -30,10 +28,27 @@ export const useReadingPosition = ({
const hasSavedOnce = useRef(false) const hasSavedOnce = useRef(false)
const completionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const completionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const lastSavedAtRef = useRef<number>(0) 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) => { const scheduleSave = useCallback((currentPosition: number) => {
if (!syncEnabled || !onSave) { if (!syncEnabledRef.current || !onSaveRef.current) {
return return
} }
@@ -43,10 +58,11 @@ export const useReadingPosition = ({
clearTimeout(saveTimerRef.current) clearTimeout(saveTimerRef.current)
saveTimerRef.current = null saveTimerRef.current = null
} }
console.log(`[reading-position] [${new Date().toISOString()}] 💾 Instant save at 100% completion`)
lastSavedPosition.current = 1 lastSavedPosition.current = 1
hasSavedOnce.current = true hasSavedOnce.current = true
lastSavedAtRef.current = Date.now() lastSavedAtRef.current = Date.now()
onSave(1) onSaveRef.current(1)
return return
} }
@@ -54,62 +70,54 @@ export const useReadingPosition = ({
const MIN_DELTA = 0.05 const MIN_DELTA = 0.05
const hasSignificantChange = Math.abs(currentPosition - lastSavedPosition.current) >= MIN_DELTA const hasSignificantChange = Math.abs(currentPosition - lastSavedPosition.current) >= MIN_DELTA
// Enforce a minimum interval between saves (15s) to avoid spamming if (!hasSignificantChange) {
const MIN_INTERVAL_MS = 15000
const nowMs = Date.now()
const enoughTimeElapsed = nowMs - lastSavedAtRef.current >= MIN_INTERVAL_MS
// Allow the very first meaningful save (when crossing 5%) regardless of interval
const isFirstMeaningful = !hasSavedOnce.current && currentPosition >= MIN_DELTA
if (!hasSignificantChange && !isFirstMeaningful) {
return return
} }
// If interval hasn't elapsed yet, delay until autoSaveInterval but still cap frequency // Clear any existing timer and schedule new save
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
if (saveTimerRef.current) { if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current) clearTimeout(saveTimerRef.current)
} }
// Schedule new save using the larger of autoSaveInterval and MIN_INTERVAL_MS const DEBOUNCE_MS = 3000 // Save max every 3 seconds
const delay = Math.max(autoSaveInterval, MIN_INTERVAL_MS)
saveTimerRef.current = setTimeout(() => { saveTimerRef.current = setTimeout(() => {
console.log(`[reading-position] [${new Date().toISOString()}] 💾 Auto-save at ${Math.round(currentPosition * 100)}%`)
lastSavedPosition.current = currentPosition lastSavedPosition.current = currentPosition
hasSavedOnce.current = true hasSavedOnce.current = true
lastSavedAtRef.current = Date.now() lastSavedAtRef.current = Date.now()
onSave(currentPosition) if (onSaveRef.current) {
}, delay) onSaveRef.current(currentPosition)
}, [syncEnabled, onSave, autoSaveInterval]) }
saveTimerRef.current = null
}, DEBOUNCE_MS)
}, [])
// Store scheduleSave in ref for use in scroll handler
useEffect(() => {
scheduleSaveRef.current = scheduleSave
}, [scheduleSave])
// Immediate save function // Immediate save function
const saveNow = useCallback(() => { 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) { if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current) clearTimeout(saveTimerRef.current)
saveTimerRef.current = null 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 hasSavedOnce.current = true
lastSavedAtRef.current = Date.now() lastSavedAtRef.current = Date.now()
onSave(position) onSaveRef.current(positionRef.current)
}, [syncEnabled, onSave, position]) }, [])
useEffect(() => { useEffect(() => {
if (!enabled) return if (!enabled) return
@@ -123,21 +131,29 @@ export const useReadingPosition = ({
const windowHeight = window.innerHeight const windowHeight = window.innerHeight
const documentHeight = document.documentElement.scrollHeight 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 // 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 maxScroll = documentHeight - windowHeight
const scrollProgress = maxScroll > 0 ? scrollTop / maxScroll : 0 const scrollProgress = maxScroll > 0 ? scrollTop / maxScroll : 0
// If we're within 5px of the bottom, consider it 100% // Only consider it 100% if we're truly at the bottom AND have scrolled significantly
const isAtBottom = scrollTop + windowHeight >= documentHeight - 5 // 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)) const clampedProgress = isAtBottom ? 1 : Math.max(0, Math.min(1, scrollProgress))
setPosition(clampedProgress) setPosition(clampedProgress)
positionRef.current = clampedProgress positionRef.current = clampedProgress
onPositionChange?.(clampedProgress) onPositionChange?.(clampedProgress)
// Schedule auto-save if sync is enabled // Schedule auto-save if sync is enabled (unless suppressed)
scheduleSave(clampedProgress) 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% // Completion detection with 2s hold at 100%
if (!hasTriggeredComplete.current) { if (!hasTriggeredComplete.current) {
@@ -180,15 +196,24 @@ export const useReadingPosition = ({
window.removeEventListener('scroll', handleScroll) window.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', handleScroll) window.removeEventListener('resize', handleScroll)
// Clear save timer on unmount // Flush pending save before unmount (don't lose progress if navigating away during debounce window)
if (saveTimerRef.current) { if (saveTimerRef.current && syncEnabledRef.current && onSaveRef.current) {
clearTimeout(saveTimerRef.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) { if (completionTimerRef.current) {
clearTimeout(completionTimerRef.current) clearTimeout(completionTimerRef.current)
} }
} }
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, scheduleSave, completionHoldMs]) }, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, completionHoldMs])
// Reset reading complete state when enabled changes // Reset reading complete state when enabled changes
useEffect(() => { useEffect(() => {
@@ -208,6 +233,7 @@ export const useReadingPosition = ({
position, position,
isReadingComplete, isReadingComplete,
progressPercentage: Math.round(position * 100), progressPercentage: Math.round(position * 100),
saveNow saveNow,
suppressSavesFor
} }
} }

View File

@@ -3,7 +3,7 @@ import { IEventStore } from 'applesauce-core'
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { EventFactory } from 'applesauce-factory' import { EventFactory } from 'applesauce-factory'
import { AccountManager } from 'applesauce-accounts' import { AccountManager } from 'applesauce-accounts'
import { UserSettings, loadSettings, saveSettings, watchSettings } from '../services/settingsService' import { UserSettings, saveSettings, watchSettings, startSettingsStream } from '../services/settingsService'
import { loadFont, getFontFamily } from '../utils/fontLoader' import { loadFont, getFontFamily } from '../utils/fontLoader'
import { applyTheme } from '../utils/theme' import { applyTheme } from '../utils/theme'
import { RELAYS } from '../config/relays' import { RELAYS } from '../config/relays'
@@ -20,26 +20,24 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
const [toastMessage, setToastMessage] = useState<string | null>(null) const [toastMessage, setToastMessage] = useState<string | null>(null)
const [toastType, setToastType] = useState<'success' | 'error'>('success') const [toastType, setToastType] = useState<'success' | 'error'>('success')
// Load settings and set up subscription // Load settings and set up streaming subscription (non-blocking, EOSE-driven)
useEffect(() => { useEffect(() => {
if (!relayPool || !pubkey || !eventStore) return if (!relayPool || !pubkey || !eventStore) return
const loadAndWatch = async () => { // Start settings stream: seed from store, stream updates to store in background
try { const stopNetwork = startSettingsStream(relayPool, eventStore, pubkey, RELAYS, (loadedSettings) => {
const loadedSettings = await loadSettings(relayPool, eventStore, pubkey, RELAYS) if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings }) })
} catch (err) {
console.error('Failed to load settings:', err)
}
}
loadAndWatch()
// Also watch store reactively for any further updates
const subscription = watchSettings(eventStore, pubkey, (loadedSettings) => { const subscription = watchSettings(eventStore, pubkey, (loadedSettings) => {
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings }) if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
}) })
return () => subscription.unsubscribe() return () => {
subscription.unsubscribe()
stopNetwork()
}
}, [relayPool, pubkey, eventStore]) }, [relayPool, pubkey, eventStore])
// Apply settings to document // Apply settings to document

View File

@@ -59,7 +59,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
// Update rate when defaultRate option changes // Update rate when defaultRate option changes
useEffect(() => { useEffect(() => {
if (options.defaultRate !== undefined) { if (options.defaultRate !== undefined) {
console.debug('[tts] defaultRate changed ->', options.defaultRate)
setRate(options.defaultRate) setRate(options.defaultRate)
} }
}, [options.defaultRate]) }, [options.defaultRate])
@@ -73,7 +72,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
if (!voice && v.length) { if (!voice && v.length) {
const byLang = v.find(x => x.lang?.toLowerCase().startsWith(defaultLang.toLowerCase())) const byLang = v.find(x => x.lang?.toLowerCase().startsWith(defaultLang.toLowerCase()))
setVoice(byLang || v[0] || null) setVoice(byLang || v[0] || null)
console.debug('[tts] voices loaded', { total: v.length, picked: (byLang || v[0] || null)?.lang })
} }
} }
load() load()
@@ -107,44 +105,37 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
u.onstart = () => { u.onstart = () => {
if (utteranceRef.current !== self) return if (utteranceRef.current !== self) return
console.debug('[tts] onstart')
setSpeaking(true) setSpeaking(true)
setPaused(false) setPaused(false)
} }
u.onpause = () => { u.onpause = () => {
if (utteranceRef.current !== self) return if (utteranceRef.current !== self) return
console.debug('[tts] onpause')
setPaused(true) setPaused(true)
} }
u.onresume = () => { u.onresume = () => {
if (utteranceRef.current !== self) return if (utteranceRef.current !== self) return
console.debug('[tts] onresume')
setPaused(false) setPaused(false)
} }
u.onend = () => { u.onend = () => {
if (utteranceRef.current !== self) return if (utteranceRef.current !== self) return
console.debug('[tts] onend')
// Continue with next chunk if available // Continue with next chunk if available
const hasMore = chunkIndexRef.current < (chunksRef.current.length - 1) const hasMore = chunkIndexRef.current < (chunksRef.current.length - 1)
if (hasMore) { if (hasMore) {
chunkIndexRef.current += 1 chunkIndexRef.current++
globalOffsetRef.current += self.text.length charIndexRef.current += self.text.length
const next = chunksRef.current[chunkIndexRef.current] || '' const nextChunk = chunksRef.current[chunkIndexRef.current]
const nextUtterance = createUtterance(next, langRef.current) const nextUtterance = createUtterance(nextChunk, langRef.current)
utteranceRef.current = nextUtterance utteranceRef.current = nextUtterance
synth!.speak(nextUtterance) synth!.speak(nextUtterance)
return } else {
setSpeaking(false)
setPaused(false)
} }
setSpeaking(false)
setPaused(false)
utteranceRef.current = null
} }
u.onerror = () => { u.onerror = () => {
if (utteranceRef.current !== self) return if (utteranceRef.current !== self) return
console.debug('[tts] onerror')
setSpeaking(false) setSpeaking(false)
setPaused(false) setPaused(false)
utteranceRef.current = null
} }
u.onboundary = (ev: SpeechSynthesisEvent) => { u.onboundary = (ev: SpeechSynthesisEvent) => {
if (utteranceRef.current !== self) return if (utteranceRef.current !== self) return
@@ -197,7 +188,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
const stop = useCallback(() => { const stop = useCallback(() => {
if (!supported) return if (!supported) return
console.debug('[tts] stop')
synth!.cancel() synth!.cancel()
setSpeaking(false) setSpeaking(false)
setPaused(false) setPaused(false)
@@ -211,18 +201,16 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
const speak = useCallback((text: string, langOverride?: string) => { const speak = useCallback((text: string, langOverride?: string) => {
if (!supported || !text?.trim()) return if (!supported || !text?.trim()) return
console.debug('[tts] speak', { len: text.length, rate })
synth!.cancel() synth!.cancel()
spokenTextRef.current = text spokenTextRef.current = text
charIndexRef.current = 0 charIndexRef.current = 0
langRef.current = langOverride langRef.current = langOverride
startSpeakingChunks(text) startSpeakingChunks(text)
}, [supported, synth, startSpeakingChunks, rate]) }, [supported, synth, startSpeakingChunks])
const pause = useCallback(() => { const pause = useCallback(() => {
if (!supported) return if (!supported) return
if (synth!.speaking && !synth!.paused) { if (synth!.speaking && !synth!.paused) {
console.debug('[tts] pause')
synth!.pause() synth!.pause()
setPaused(true) setPaused(true)
} }
@@ -231,7 +219,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
const resume = useCallback(() => { const resume = useCallback(() => {
if (!supported) return if (!supported) return
if (synth!.speaking && synth!.paused) { if (synth!.speaking && synth!.paused) {
console.debug('[tts] resume')
synth!.resume() synth!.resume()
setPaused(false) setPaused(false)
} }
@@ -242,14 +229,11 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
if (!supported) return if (!supported) return
if (!utteranceRef.current) return if (!utteranceRef.current) return
console.debug('[tts] rate change', { rate, speaking: synth!.speaking, paused: synth!.paused, charIndex: charIndexRef.current })
if (synth!.speaking && !synth!.paused) { if (synth!.speaking && !synth!.paused) {
const fullText = spokenTextRef.current const fullText = spokenTextRef.current
const startIndex = Math.max(0, Math.min(charIndexRef.current, fullText.length)) const startIndex = Math.max(0, Math.min(charIndexRef.current, fullText.length))
const remainingText = fullText.slice(startIndex) const remainingText = fullText.slice(startIndex)
console.debug('[tts] restart at new rate', { startIndex, remainingLen: remainingText.length })
synth!.cancel() synth!.cancel()
// restart chunked from current global index // restart chunked from current global index
spokenTextRef.current = remainingText spokenTextRef.current = remainingText
@@ -273,7 +257,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
const fullText = spokenTextRef.current const fullText = spokenTextRef.current
const startIndex = Math.max(0, Math.min(charIndexRef.current, fullText.length - 1)) const startIndex = Math.max(0, Math.min(charIndexRef.current, fullText.length - 1))
const remainingText = fullText.slice(startIndex) const remainingText = fullText.slice(startIndex)
console.debug('[tts] updateRate -> restart', { newRate, startIndex, remainingLen: remainingText.length })
synth!.cancel() synth!.cancel()
const u = createUtterance(remainingText) const u = createUtterance(remainingText)
// ensure the new rate is applied immediately on the new utterance // ensure the new rate is applied immediately on the new utterance
@@ -281,7 +264,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
utteranceRef.current = u utteranceRef.current = u
synth!.speak(u) synth!.speak(u)
} else if (utteranceRef.current) { } else if (utteranceRef.current) {
console.debug('[tts] updateRate -> set on utterance', { newRate })
utteranceRef.current.rate = newRate utteranceRef.current.rate = newRate
} }
}, [supported, synth, createUtterance]) }, [supported, synth, createUtterance])

View File

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

View File

@@ -97,10 +97,10 @@ export async function fetchArticleByNaddr(
const pointer = decoded.data as AddressPointer const pointer = decoded.data as AddressPointer
// Define relays to query - prefer relays from naddr, fallback to configured relays (including local) // Define relays to query - use union of relay hints from naddr and configured relays
const baseRelays = pointer.relays && pointer.relays.length > 0 // This avoids failures when naddr contains stale/unreachable relay hints
? pointer.relays const hintedRelays = (pointer.relays && pointer.relays.length > 0) ? pointer.relays : []
: RELAYS const baseRelays = Array.from(new Set<string>([...hintedRelays, ...RELAYS]))
const orderedRelays = prioritizeLocalRelays(baseRelays) const orderedRelays = prioritizeLocalRelays(baseRelays)
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays) 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 // Parallel local+remote, stream immediate, collect up to first from each
const { local$, remote$ } = createParallelReqStreams(relayPool, localRelays, remoteRelays, filter, 1200, 6000) 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 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) { if (events.length === 0) {
throw new Error('Article not found') throw new Error('Article not found')

View File

@@ -1,13 +1,8 @@
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { Helpers, EventStore } from 'applesauce-core' import { Helpers, EventStore } from 'applesauce-core'
import { createEventLoader, createAddressLoader } from 'applesauce-loaders/loaders'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { EventPointer } from 'nostr-tools/nip19'
import { from } from 'rxjs'
import { mergeMap } from 'rxjs/operators'
import { queryEvents } from './dataFetch' import { queryEvents } from './dataFetch'
import { KINDS } from '../config/kinds' import { KINDS } from '../config/kinds'
import { RELAYS } from '../config/relays'
import { collectBookmarksFromEvents } from './bookmarkProcessing' import { collectBookmarksFromEvents } from './bookmarkProcessing'
import { Bookmark, IndividualBookmark } from '../types/bookmarks' import { Bookmark, IndividualBookmark } from '../types/bookmarks'
import { import {
@@ -65,12 +60,8 @@ class BookmarkController {
}> = new Map() }> = new Map()
private isLoading = false private isLoading = false
private hydrationGeneration = 0 private hydrationGeneration = 0
// Event loaders for efficient batching
private eventStore = new EventStore()
private eventLoader: ReturnType<typeof createEventLoader> | null = null
private addressLoader: ReturnType<typeof createAddressLoader> | null = null
private externalEventStore: EventStore | null = null private externalEventStore: EventStore | null = null
private relayPool: RelayPool | null = null
onRawEvent(cb: RawEventCallback): () => void { onRawEvent(cb: RawEventCallback): () => void {
this.rawEventListeners.push(cb) this.rawEventListeners.push(cb)
@@ -119,15 +110,15 @@ class BookmarkController {
} }
/** /**
* Hydrate events by IDs using EventLoader (auto-batching, streaming) * Hydrate events by IDs using queryEvents (local-first, streaming)
*/ */
private hydrateByIds( private async hydrateByIds(
ids: string[], ids: string[],
idToEvent: Map<string, NostrEvent>, idToEvent: Map<string, NostrEvent>,
onProgress: () => void, onProgress: () => void,
generation: number generation: number
): void { ): Promise<void> {
if (!this.eventLoader) { if (!this.relayPool) {
return return
} }
@@ -137,86 +128,146 @@ class BookmarkController {
return return
} }
// Convert IDs to EventPointers // Fetch events using local-first queryEvents
const pointers: EventPointer[] = unique.map(id => ({ id })) await queryEvents(
this.relayPool,
// Use mergeMap with concurrency limit instead of merge to properly batch requests { ids: unique },
// This prevents overwhelming relays with 96+ simultaneous requests {
from(pointers).pipe( onEvent: (event) => {
mergeMap(pointer => this.eventLoader!(pointer), 5) // Check if hydration was cancelled
).subscribe({ if (this.hydrationGeneration !== generation) return
next: (event) => {
// Check if hydration was cancelled idToEvent.set(event.id, event)
if (this.hydrationGeneration !== generation) return
// Also index by coordinate for addressable events
idToEvent.set(event.id, event) if (event.kind && event.kind >= 30000 && event.kind < 40000) {
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
// Also index by coordinate for addressable events const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
if (event.kind && event.kind >= 30000 && event.kind < 40000) { idToEvent.set(coordinate, event)
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || '' }
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
idToEvent.set(coordinate, event) // Add to external event store if available
if (this.externalEventStore) {
this.externalEventStore.add(event)
}
onProgress()
} }
// Add to external event store if available
if (this.externalEventStore) {
this.externalEventStore.add(event)
}
onProgress()
},
error: () => {
// Silent error - EventLoader handles retries
} }
}) )
} }
/** /**
* Hydrate addressable events by coordinates using AddressLoader (auto-batching, streaming) * Hydrate addressable events by coordinates using queryEvents (local-first, streaming)
*/ */
private hydrateByCoordinates( private async hydrateByCoordinates(
coords: Array<{ kind: number; pubkey: string; identifier: string }>, coords: Array<{ kind: number; pubkey: string; identifier: string }>,
idToEvent: Map<string, NostrEvent>, idToEvent: Map<string, NostrEvent>,
onProgress: () => void, onProgress: () => void,
generation: number generation: number
): void { ): Promise<void> {
if (!this.addressLoader) { if (!this.relayPool) {
return return
} }
if (coords.length === 0) return if (coords.length === 0) {
return
}
// Convert coordinates to AddressPointers // Group by kind and pubkey for efficient batching
const pointers = coords.map(c => ({ const filtersByKind = new Map<number, Map<string, string[]>>()
kind: c.kind,
pubkey: c.pubkey, for (const coord of coords) {
identifier: c.identifier if (!filtersByKind.has(coord.kind)) {
})) filtersByKind.set(coord.kind, new Map())
}
const byPubkey = filtersByKind.get(coord.kind)!
if (!byPubkey.has(coord.pubkey)) {
byPubkey.set(coord.pubkey, [])
}
byPubkey.get(coord.pubkey)!.push(coord.identifier || '')
}
// Use mergeMap with concurrency limit instead of merge to properly batch requests // Kick off all queries in parallel (fire-and-forget)
from(pointers).pipe( const promises: Promise<void>[] = []
mergeMap(pointer => this.addressLoader!(pointer), 5)
).subscribe({ for (const [kind, byPubkey] of filtersByKind) {
next: (event) => { for (const [pubkey, identifiers] of byPubkey) {
// Check if hydration was cancelled // Separate empty and non-empty identifiers
if (this.hydrationGeneration !== generation) return const nonEmptyIdentifiers = identifiers.filter(id => id && id.length > 0)
const hasEmptyIdentifier = identifiers.some(id => !id || id.length === 0)
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
idToEvent.set(coordinate, event)
idToEvent.set(event.id, event)
// Add to external event store if available // Fetch events with non-empty d-tags
if (this.externalEventStore) { if (nonEmptyIdentifiers.length > 0) {
this.externalEventStore.add(event) promises.push(
queryEvents(
this.relayPool,
{ kinds: [kind], authors: [pubkey], '#d': nonEmptyIdentifiers },
{
onEvent: (event) => {
// Check if hydration was cancelled
if (this.hydrationGeneration !== generation) return
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
idToEvent.set(coordinate, event)
idToEvent.set(event.id, event)
// Add to external event store if available
if (this.externalEventStore) {
this.externalEventStore.add(event)
}
onProgress()
}
}
).then(() => {
// Query completed successfully
}).catch(() => {
// Silent error - individual query failed
})
)
} }
onProgress() // Fetch events with empty d-tag separately (without '#d' filter)
}, if (hasEmptyIdentifier) {
error: () => { promises.push(
// Silent error - AddressLoader handles retries queryEvents(
this.relayPool,
{ kinds: [kind], authors: [pubkey] },
{
onEvent: (event) => {
// Check if hydration was cancelled
if (this.hydrationGeneration !== generation) return
// Only process events with empty d-tag
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
if (dTag !== '') return
const coordinate = `${event.kind}:${event.pubkey}:`
idToEvent.set(coordinate, event)
idToEvent.set(event.id, event)
// Add to external event store if available
if (this.externalEventStore) {
this.externalEventStore.add(event)
}
onProgress()
}
}
).then(() => {
// Query completed successfully
}).catch(() => {
// Silent error - individual query failed
})
)
}
} }
}) }
// Wait for all queries to complete
await Promise.all(promises)
} }
private async buildAndEmitBookmarks( private async buildAndEmitBookmarks(
@@ -279,8 +330,6 @@ class BookmarkController {
} }
}) })
console.log(`📋 Requesting hydration for: ${noteIds.length} note IDs, ${coordinates.length} coordinates`)
// Helper to build and emit bookmarks // Helper to build and emit bookmarks
const emitBookmarks = (idToEvent: Map<string, NostrEvent>) => { const emitBookmarks = (idToEvent: Map<string, NostrEvent>) => {
// Now hydrate the ORIGINAL items (which may have duplicates), using the deduplicated results // Now hydrate the ORIGINAL items (which may have duplicates), using the deduplicated results
@@ -293,22 +342,28 @@ class BookmarkController {
const enriched = allBookmarks.map(b => ({ const enriched = allBookmarks.map(b => ({
...b, ...b,
tags: b.tags || [], tags: b.tags || [],
// Prefer hydrated content; fallback to any cached event content in external store content: b.content || this.externalEventStore?.getEvent(b.id)?.content || '', // Fallback to eventStore content
content: b.content && b.content.length > 0 created_at: (b.created_at ?? this.externalEventStore?.getEvent(b.id)?.created_at ?? null)
? b.content
: (this.externalEventStore?.getEvent(b.id)?.content || '')
})) }))
const sortedBookmarks = enriched const sortedBookmarks = enriched
.map(b => ({ ...b, urlReferences: extractUrlsFromContent(b.content) })) .map(b => ({
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0))) ...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 = { const bookmark: Bookmark = {
id: `${activeAccount.pubkey}-bookmarks`, id: `${activeAccount.pubkey}-bookmarks`,
title: `Bookmarks (${sortedBookmarks.length})`, title: `Bookmarks (${sortedBookmarks.length})`,
url: '', url: '',
content: latestContent, content: latestContent,
created_at: newestCreatedAt || Math.floor(Date.now() / 1000), created_at: newestCreatedAt || 0,
tags: allTags, tags: allTags,
bookmarkCount: sortedBookmarks.length, bookmarkCount: sortedBookmarks.length,
eventReferences: allTags.filter((tag: string[]) => tag[0] === 'e').map((tag: string[]) => tag[1]), eventReferences: allTags.filter((tag: string[]) => tag[0] === 'e').map((tag: string[]) => tag[1]),
@@ -324,7 +379,7 @@ class BookmarkController {
const idToEvent: Map<string, NostrEvent> = new Map() const idToEvent: Map<string, NostrEvent> = new Map()
emitBookmarks(idToEvent) emitBookmarks(idToEvent)
// Now fetch events progressively in background using batched hydrators // Now fetch events progressively in background using local-first queries
const generation = this.hydrationGeneration const generation = this.hydrationGeneration
const onProgress = () => emitBookmarks(idToEvent) const onProgress = () => emitBookmarks(idToEvent)
@@ -339,10 +394,14 @@ class BookmarkController {
} }
}) })
// Kick off batched hydration (streaming, non-blocking) // Kick off hydration (streaming, non-blocking, local-first)
// EventLoader and AddressLoader handle batching and streaming automatically // Fire-and-forget - don't await, let it run in background
this.hydrateByIds(noteIds, idToEvent, onProgress, generation) this.hydrateByIds(noteIds, idToEvent, onProgress, generation).catch(() => {
this.hydrateByCoordinates(coordObjs, idToEvent, onProgress, generation) // Silent error - hydration will retry or show partial results
})
this.hydrateByCoordinates(coordObjs, idToEvent, onProgress, generation).catch(() => {
// Silent error - hydration will retry or show partial results
})
} catch (error) { } catch (error) {
console.error('Failed to build bookmarks:', error) console.error('Failed to build bookmarks:', error)
this.bookmarksListeners.forEach(cb => cb([])) this.bookmarksListeners.forEach(cb => cb([]))
@@ -357,7 +416,8 @@ class BookmarkController {
}): Promise<void> { }): Promise<void> {
const { relayPool, activeAccount, accountManager, eventStore } = options const { relayPool, activeAccount, accountManager, eventStore } = options
// Store the external event store reference for adding hydrated events // Store references for hydration
this.relayPool = relayPool
this.externalEventStore = eventStore || null this.externalEventStore = eventStore || null
if (!activeAccount || typeof (activeAccount as { pubkey?: string }).pubkey !== 'string') { if (!activeAccount || typeof (activeAccount as { pubkey?: string }).pubkey !== 'string') {
@@ -369,16 +429,6 @@ class BookmarkController {
// Increment generation to cancel any in-flight hydration // Increment generation to cancel any in-flight hydration
this.hydrationGeneration++ this.hydrationGeneration++
// Initialize loaders for this session
this.eventLoader = createEventLoader(relayPool, {
eventStore: this.eventStore,
extraRelays: RELAYS
})
this.addressLoader = createAddressLoader(relayPool, {
eventStore: this.eventStore,
extraRelays: RELAYS
})
this.setLoading(true) this.setLoading(true)
try { try {

View File

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

View File

@@ -21,12 +21,16 @@ export interface AddressPointer {
pubkey: string pubkey: string
identifier: string identifier: string
relays?: string[] relays?: string[]
added_at?: number
created_at?: number
} }
export interface EventPointer { export interface EventPointer {
id: string id: string
relays?: string[] relays?: string[]
author?: string author?: string
added_at?: number
created_at?: number
} }
export interface ApplesauceBookmarks { export interface ApplesauceBookmarks {
@@ -77,14 +81,14 @@ export const processApplesauceBookmarks = (
allItems.push({ allItems.push({
id: note.id, id: note.id,
content: '', content: '',
created_at: parentCreatedAt || 0, created_at: note.created_at ?? null,
pubkey: note.author || activeAccount.pubkey, pubkey: note.author || activeAccount.pubkey,
kind: 1, // Short note kind kind: 1, // Short note kind
tags: [], tags: [],
parsedContent: undefined, parsedContent: undefined,
type: 'event' as const, type: 'event' as const,
isPrivate, isPrivate,
added_at: parentCreatedAt || 0 listUpdatedAt: parentCreatedAt || 0
}) })
}) })
} }
@@ -97,14 +101,14 @@ export const processApplesauceBookmarks = (
allItems.push({ allItems.push({
id: coordinate, id: coordinate,
content: '', content: '',
created_at: parentCreatedAt || 0, created_at: article.created_at ?? null,
pubkey: article.pubkey, pubkey: article.pubkey,
kind: article.kind, // Usually 30023 for long-form articles kind: article.kind, // Usually 30023 for long-form articles
tags: [], tags: [],
parsedContent: undefined, parsedContent: undefined,
type: 'event' as const, type: 'event' as const,
isPrivate, isPrivate,
added_at: parentCreatedAt || 0 listUpdatedAt: parentCreatedAt ?? null
}) })
}) })
} }
@@ -115,14 +119,14 @@ export const processApplesauceBookmarks = (
allItems.push({ allItems.push({
id: `hashtag-${hashtag}`, id: `hashtag-${hashtag}`,
content: `#${hashtag}`, content: `#${hashtag}`,
created_at: parentCreatedAt || 0, created_at: 0, // Hashtags don't have their own creation time
pubkey: activeAccount.pubkey, pubkey: activeAccount.pubkey,
kind: 1, kind: 1,
tags: [['t', hashtag]], tags: [['t', hashtag]],
parsedContent: undefined, parsedContent: undefined,
type: 'event' as const, type: 'event' as const,
isPrivate, isPrivate,
added_at: parentCreatedAt || 0 listUpdatedAt: parentCreatedAt ?? null
}) })
}) })
} }
@@ -133,14 +137,14 @@ export const processApplesauceBookmarks = (
allItems.push({ allItems.push({
id: `url-${url}`, id: `url-${url}`,
content: url, content: url,
created_at: parentCreatedAt || 0, created_at: 0, // URLs don't have their own creation time
pubkey: activeAccount.pubkey, pubkey: activeAccount.pubkey,
kind: 1, kind: 1,
tags: [['r', url]], tags: [['r', url]],
parsedContent: undefined, parsedContent: undefined,
type: 'event' as const, type: 'event' as const,
isPrivate, isPrivate,
added_at: parentCreatedAt || 0 listUpdatedAt: parentCreatedAt || 0
}) })
}) })
} }
@@ -149,20 +153,24 @@ export const processApplesauceBookmarks = (
} }
const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks] const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks]
return bookmarkArray const processed = bookmarkArray
.filter((bookmark: BookmarkData) => bookmark.id) // Skip bookmarks without valid IDs .filter((bookmark: BookmarkData) => bookmark.id) // Skip bookmarks without valid IDs
.map((bookmark: BookmarkData) => ({ .map((bookmark: BookmarkData) => {
id: bookmark.id!, return {
content: bookmark.content || '', id: bookmark.id!,
created_at: bookmark.created_at || parentCreatedAt || 0, content: bookmark.content || '',
pubkey: activeAccount.pubkey, created_at: bookmark.created_at ?? null,
kind: bookmark.kind || 30001, pubkey: activeAccount.pubkey,
tags: bookmark.tags || [], kind: bookmark.kind || 30001,
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined, tags: bookmark.tags || [],
type: 'event' as const, parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
isPrivate, type: 'event' as const,
added_at: bookmark.created_at || parentCreatedAt || 0 isPrivate,
})) listUpdatedAt: parentCreatedAt ?? null
}
})
return processed
} }
// Types and guards around signer/decryption APIs // 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 // Handle web bookmarks (kind:39701) as individual bookmarks
if (evt.kind === 39701) { 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({ publicItemsAll.push({
id: evt.id, id: webBookmarkId,
content: evt.content || '', content: evt.content || '',
created_at: evt.created_at || Math.floor(Date.now() / 1000), created_at: evt.created_at ?? null,
pubkey: evt.pubkey, pubkey: evt.pubkey,
kind: evt.kind, kind: evt.kind,
tags: evt.tags || [], tags: evt.tags || [],
parsedContent: undefined, parsedContent: undefined,
type: 'web' as const, type: 'web' as const,
isPrivate: false, isPrivate: false,
added_at: evt.created_at || Math.floor(Date.now() / 1000),
sourceKind: 39701, sourceKind: 39701,
setName: dTag, setName: dTag,
setTitle, setTitle,
setDescription, setDescription,
setImage setImage,
listUpdatedAt: evt.created_at ?? null
}) })
continue continue
} }
const pub = Helpers.getPublicBookmarks(evt) const pub = Helpers.getPublicBookmarks(evt)
const processedPub = processApplesauceBookmarks(pub, activeAccount, false, evt.created_at)
publicItemsAll.push( publicItemsAll.push(
...processApplesauceBookmarks(pub, activeAccount, false, evt.created_at).map(i => ({ ...processedPub.map(i => ({
...i, ...i,
sourceKind: evt.kind, sourceKind: evt.kind,
setName: dTag, setName: dTag,

View File

@@ -22,6 +22,9 @@ class EventManager {
// Safety timeout for event fetches (ms) // Safety timeout for event fetches (ms)
private fetchTimeoutMs = 12000 private fetchTimeoutMs = 12000
// Retry policy
private maxAttempts = 4
private baseBackoffMs = 700
/** /**
* Initialize the event manager with event store and relay pool * Initialize the event manager with event store and relay pool
@@ -70,7 +73,7 @@ class EventManager {
// Start a new fetch request // Start a new fetch request
this.pendingRequests.set(eventId, [{ resolve, reject }]) this.pendingRequests.set(eventId, [{ resolve, reject }])
this.fetchFromRelay(eventId) this.fetchFromRelayWithRetry(eventId, 1)
}) })
} }
@@ -86,17 +89,14 @@ class EventManager {
requests.forEach(req => req.reject(error)) requests.forEach(req => req.reject(error))
} }
/** private fetchFromRelayWithRetry(eventId: string, attempt: number): void {
* Actually fetch the event from relay
*/
private fetchFromRelay(eventId: string): void {
// If no loader yet, schedule retry // If no loader yet, schedule retry
if (!this.relayPool || !this.eventLoader) { if (!this.relayPool || !this.eventLoader) {
setTimeout(() => { setTimeout(() => {
if (this.eventLoader && this.pendingRequests.has(eventId)) { if (this.pendingRequests.has(eventId)) {
this.fetchFromRelay(eventId) this.fetchFromRelayWithRetry(eventId, attempt)
} }
}, 500) }, this.baseBackoffMs)
return return
} }
@@ -111,14 +111,23 @@ class EventManager {
error: (err: unknown) => { error: (err: unknown) => {
clearTimeout(timeoutId) clearTimeout(timeoutId)
const error = err instanceof Error ? err : new Error(String(err)) const error = err instanceof Error ? err : new Error(String(err))
this.rejectPending(eventId, error) // Retry on error until attempts exhausted
if (attempt < this.maxAttempts && this.pendingRequests.has(eventId)) {
setTimeout(() => this.fetchFromRelayWithRetry(eventId, attempt + 1), this.baseBackoffMs * attempt)
} else {
this.rejectPending(eventId, error)
}
subscription.unsubscribe() subscription.unsubscribe()
}, },
complete: () => { complete: () => {
// Completed without next - consider not found // Completed without next - consider not found, but retry a few times
if (!delivered) { if (!delivered) {
clearTimeout(timeoutId) clearTimeout(timeoutId)
this.rejectPending(eventId, new Error('Event not found')) if (attempt < this.maxAttempts && this.pendingRequests.has(eventId)) {
setTimeout(() => this.fetchFromRelayWithRetry(eventId, attempt + 1), this.baseBackoffMs * attempt)
} else {
this.rejectPending(eventId, new Error('Event not found'))
}
} }
subscription.unsubscribe() subscription.unsubscribe()
} }
@@ -127,8 +136,13 @@ class EventManager {
// Safety timeout // Safety timeout
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
if (!delivered) { if (!delivered) {
this.rejectPending(eventId, new Error('Timed out fetching event')) if (attempt < this.maxAttempts && this.pendingRequests.has(eventId)) {
subscription.unsubscribe() subscription.unsubscribe()
this.fetchFromRelayWithRetry(eventId, attempt + 1)
} else {
subscription.unsubscribe()
this.rejectPending(eventId, new Error('Timed out fetching event'))
}
} }
}, this.fetchTimeoutMs) }, this.fetchTimeoutMs)
} }
@@ -139,7 +153,7 @@ class EventManager {
private retryAllPending(): void { private retryAllPending(): void {
const pendingIds = Array.from(this.pendingRequests.keys()) const pendingIds = Array.from(this.pendingRequests.keys())
pendingIds.forEach(eventId => { pendingIds.forEach(eventId => {
this.fetchFromRelay(eventId) this.fetchFromRelayWithRetry(eventId, 1)
}) })
} }
} }

View File

@@ -75,10 +75,17 @@ export function processReadingProgress(
continue continue
} }
} else if (dTag.startsWith('url:')) { } else if (dTag.startsWith('url:')) {
// It's a URL with base64url encoding // It's a URL. We support both raw URLs and base64url-encoded URLs.
const encoded = dTag.replace('url:', '') const value = dTag.slice(4)
const looksBase64Url = /^[A-Za-z0-9_-]+$/.test(value) && (value.includes('-') || value.includes('_'))
try { try {
itemUrl = atob(encoded.replace(/-/g, '+').replace(/_/g, '/')) if (looksBase64Url) {
// Decode base64url to raw URL
itemUrl = atob(value.replace(/-/g, '+').replace(/_/g, '/'))
} else {
// Treat as raw URL (already decoded)
itemUrl = value
}
itemId = itemUrl itemId = itemUrl
itemType = 'external' itemType = 'external'
} catch (e) { } catch (e) {

View File

@@ -98,11 +98,8 @@ export function generateArticleIdentifier(naddrOrUrl: string): string {
if (naddrOrUrl.startsWith('nostr:')) { if (naddrOrUrl.startsWith('nostr:')) {
return naddrOrUrl.replace('nostr:', '') return naddrOrUrl.replace('nostr:', '')
} }
// For URLs, use base64url encoding (URL-safe) // For URLs, return the raw URL. Downstream tag generation will encode as needed.
return btoa(naddrOrUrl) return naddrOrUrl
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
} }
/** /**
@@ -138,8 +135,148 @@ export async function saveReadingPosition(
await publishEvent(relayPool, eventStore, signed) await publishEvent(relayPool, eventStore, signed)
} }
/**
* Streaming reading position loader (non-blocking, EOSE-driven)
* Seeds from local eventStore, streams relay updates to store in background
* @returns Unsubscribe function to cancel both store watch and network stream
*/
export function startReadingPositionStream(
relayPool: RelayPool,
eventStore: IEventStore,
pubkey: string,
articleIdentifier: string,
onPosition: (pos: ReadingPosition | null) => void
): () => void {
const dTag = generateDTag(articleIdentifier)
// 1) Seed from local replaceable immediately and watch for updates
const storeSub = eventStore
.replaceable(READING_PROGRESS_KIND, pubkey, dTag)
.subscribe((event: NostrEvent | undefined) => {
if (!event) {
onPosition(null)
return
}
const parsed = getReadingProgressContent(event)
onPosition(parsed || null)
})
// 2) Stream from relays in background; pipe into store; no timeout/unsubscribe timer
const networkSub = relayPool
.subscription(RELAYS, {
kinds: [READING_PROGRESS_KIND],
authors: [pubkey],
'#d': [dTag]
})
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe()
// Caller manages lifecycle
return () => {
try { storeSub.unsubscribe() } catch { /* ignore */ }
try { networkSub.unsubscribe() } catch { /* ignore */ }
}
}
/**
* 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) * Load reading position from Nostr (kind 39802)
* @deprecated Use startReadingPositionStream for non-blocking behavior
* Returns current local position immediately (or null) and starts background sync
*/ */
export async function loadReadingPosition( export async function loadReadingPosition(
relayPool: RelayPool, relayPool: RelayPool,
@@ -149,101 +286,29 @@ export async function loadReadingPosition(
): Promise<ReadingPosition | null> { ): Promise<ReadingPosition | null> {
const dTag = generateDTag(articleIdentifier) const dTag = generateDTag(articleIdentifier)
// Check local event store first let initial: ReadingPosition | null = null
try { try {
const localEvent = await firstValueFrom( const localEvent = await firstValueFrom(
eventStore.replaceable(READING_PROGRESS_KIND, pubkey, dTag) eventStore.replaceable(READING_PROGRESS_KIND, pubkey, dTag)
) )
if (localEvent) { if (localEvent) {
const content = getReadingProgressContent(localEvent) const content = getReadingProgressContent(localEvent)
if (content) { if (content) initial = content
// Fetch from relays in background to get any updates
relayPool
.subscription(RELAYS, {
kinds: [READING_PROGRESS_KIND],
authors: [pubkey],
'#d': [dTag]
})
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe()
return content
}
} }
} catch (err) { } catch {
// Ignore errors and fetch from relays // ignore
} }
// Fetch from relays // Start background sync (fire-and-forget; no timeout)
const result = await fetchFromRelays( relayPool
relayPool, .subscription(RELAYS, {
eventStore, kinds: [READING_PROGRESS_KIND],
pubkey, authors: [pubkey],
READING_PROGRESS_KIND, '#d': [dTag]
dTag, })
getReadingProgressContent .pipe(onlyEvents(), mapEventsToStore(eventStore))
) .subscribe()
return result || null return initial
}
// Helper function to fetch from relays with timeout
async function fetchFromRelays(
relayPool: RelayPool,
eventStore: IEventStore,
pubkey: string,
kind: number,
dTag: string,
parser: (event: NostrEvent) => ReadingPosition | undefined
): Promise<ReadingPosition | null> {
return new Promise((resolve) => {
let hasResolved = false
const timeout = setTimeout(() => {
if (!hasResolved) {
hasResolved = true
resolve(null)
}
}, 3000)
const sub = relayPool
.subscription(RELAYS, {
kinds: [kind],
authors: [pubkey],
'#d': [dTag]
})
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe({
complete: async () => {
clearTimeout(timeout)
if (!hasResolved) {
hasResolved = true
try {
const event = await firstValueFrom(
eventStore.replaceable(kind, pubkey, dTag)
)
if (event) {
const content = parser(event)
resolve(content || null)
} else {
resolve(null)
}
} catch (err) {
resolve(null)
}
}
},
error: () => {
clearTimeout(timeout)
if (!hasResolved) {
hasResolved = true
resolve(null)
}
}
})
setTimeout(() => {
sub.unsubscribe()
}, 3000)
})
} }

View File

@@ -276,10 +276,10 @@ class ReadingProgressController {
// Process new events // Process new events
processReadingProgress(events, readsMap) processReadingProgress(events, readsMap)
// Convert back to progress map (naddr -> progress) // Convert back to progress map (id -> progress). Include both articles and external URLs.
const newProgressMap = new Map<string, number>() const newProgressMap = new Map<string, number>()
for (const [id, item] of readsMap.entries()) { for (const [id, item] of readsMap.entries()) {
if (item.readingProgress !== undefined && item.type === 'article') { if (item.readingProgress !== undefined) {
newProgressMap.set(id, item.readingProgress) newProgressMap.set(id, item.readingProgress)
} }
} }

View File

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

View File

@@ -75,90 +75,82 @@ export interface UserSettings {
ttsDefaultSpeed?: number // default: 2.1 ttsDefaultSpeed?: number // default: 2.1
} }
/**
* Streaming settings loader (non-blocking, EOSE-driven)
* Seeds from local eventStore, streams relay updates to store in background
* @returns Unsubscribe function to cancel both store watch and network stream
*/
export function startSettingsStream(
relayPool: RelayPool,
eventStore: IEventStore,
pubkey: string,
relays: string[],
onSettings: (settings: UserSettings | null) => void
): () => void {
// 1) Seed from local replaceable immediately and watch for updates
const storeSub = eventStore
.replaceable(APP_DATA_KIND, pubkey, SETTINGS_IDENTIFIER)
.subscribe((event: NostrEvent | undefined) => {
if (!event) {
onSettings(null)
return
}
const content = getAppDataContent<UserSettings>(event)
onSettings(content || null)
})
// 2) Stream from relays in background; pipe into store; no timeout/unsubscribe timer
const networkSub = relayPool
.subscription(relays, {
kinds: [APP_DATA_KIND],
authors: [pubkey],
'#d': [SETTINGS_IDENTIFIER]
})
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe()
// Caller manages lifecycle
return () => {
try { storeSub.unsubscribe() } catch { /* ignore */ }
try { networkSub.unsubscribe() } catch { /* ignore */ }
}
}
/**
* @deprecated Use startSettingsStream + watchSettings for non-blocking behavior.
* Returns current local settings immediately (or null if not present) and starts background sync.
*/
export async function loadSettings( export async function loadSettings(
relayPool: RelayPool, relayPool: RelayPool,
eventStore: IEventStore, eventStore: IEventStore,
pubkey: string, pubkey: string,
relays: string[] relays: string[]
): Promise<UserSettings | null> { ): Promise<UserSettings | null> {
let initial: UserSettings | null = null
// First, check if we already have settings in the local event store
try { try {
const localEvent = await firstValueFrom( const localEvent = await firstValueFrom(
eventStore.replaceable(APP_DATA_KIND, pubkey, SETTINGS_IDENTIFIER) eventStore.replaceable(APP_DATA_KIND, pubkey, SETTINGS_IDENTIFIER)
) )
if (localEvent) { if (localEvent) {
const content = getAppDataContent<UserSettings>(localEvent) const content = getAppDataContent<UserSettings>(localEvent)
initial = content || null
// Still fetch from relays in the background to get any updates
relayPool
.subscription(relays, {
kinds: [APP_DATA_KIND],
authors: [pubkey],
'#d': [SETTINGS_IDENTIFIER]
})
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe()
return content || null
} }
} catch (_err) { } catch {
// Ignore local store errors // ignore
} }
// If not in local store, fetch from relays
return new Promise((resolve) => {
let hasResolved = false
const timeout = setTimeout(() => {
if (!hasResolved) {
console.warn('⚠️ Settings load timeout - no settings event found')
hasResolved = true
resolve(null)
}
}, 5000)
const sub = relayPool // Start background sync (fire-and-forget; no timeout)
.subscription(relays, { relayPool
kinds: [APP_DATA_KIND], .subscription(relays, {
authors: [pubkey], kinds: [APP_DATA_KIND],
'#d': [SETTINGS_IDENTIFIER] authors: [pubkey],
}) '#d': [SETTINGS_IDENTIFIER]
.pipe(onlyEvents(), mapEventsToStore(eventStore)) })
.subscribe({ .pipe(onlyEvents(), mapEventsToStore(eventStore))
complete: async () => { .subscribe()
clearTimeout(timeout)
if (!hasResolved) {
hasResolved = true
try {
const event = await firstValueFrom(
eventStore.replaceable(APP_DATA_KIND, pubkey, SETTINGS_IDENTIFIER)
)
if (event) {
const content = getAppDataContent<UserSettings>(event)
resolve(content || null)
} else {
resolve(null)
}
} catch (err) {
console.error('❌ Error loading settings:', err)
resolve(null)
}
}
},
error: (err) => {
console.error('❌ Settings subscription error:', err)
clearTimeout(timeout)
if (!hasResolved) {
hasResolved = true
resolve(null)
}
}
})
setTimeout(() => { return initial
sub.unsubscribe()
}, 5000)
})
} }
export async function saveSettings( export async function saveSettings(

View File

@@ -35,6 +35,8 @@
.reading-time svg { font-size: 0.875rem; } .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 { 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 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-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); } .reader-markdown { color: var(--color-text); line-height: 1.7; font-family: var(--reading-font); font-size: var(--reading-font-size); }
/* Ensure font inheritance */ /* Ensure font inheritance */

View File

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

View File

@@ -31,7 +31,8 @@ export interface Bookmark {
export interface IndividualBookmark { export interface IndividualBookmark {
id: string id: string
content: string content: string
created_at: number // Timestamp when the content was created (from the content event itself)
created_at: number | null
pubkey: string pubkey: string
kind: number kind: number
tags: string[][] tags: string[][]
@@ -40,8 +41,6 @@ export interface IndividualBookmark {
type: 'event' | 'article' | 'web' type: 'event' | 'article' | 'web'
isPrivate?: boolean isPrivate?: boolean
encryptedContent?: string 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) // The kind of the source list/set that produced this bookmark (e.g., 10003, 30003, 30001, or 39701 for web)
sourceKind?: number sourceKind?: number
// The 'd' tag value from kind 30003 bookmark sets // The 'd' tag value from kind 30003 bookmark sets
@@ -50,6 +49,9 @@ export interface IndividualBookmark {
setTitle?: string setTitle?: string
setDescription?: string setDescription?: string
setImage?: 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 { export interface ActiveAccount {

View File

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

View File

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

View File

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