Compare commits

...

82 Commits

Author SHA1 Message Date
Gigi
0d53027818 chore: bump version to 0.8.5 2025-10-20 16:34:30 +02:00
Gigi
811d96dee0 refactor: extract common isMounted pattern into reusable useMountedState hook
- Create useMountedState hook to track component mount status
- Refactor useArticleLoader to use shared hook
- Refactor useExternalUrlLoader to use shared hook
- Remove duplicated isMounted pattern across both loaders
- Cleaner, more DRY code with same functionality
2025-10-20 16:33:05 +02:00
Gigi
21335d56dc fix: prevent infinite loading spinner by fixing race conditions in article/URL loaders
- Add isMounted flag to track component lifecycle in useArticleLoader
- Add isMounted flag to track component lifecycle in useExternalUrlLoader
- Remove setter functions from useEffect dependencies to prevent re-triggers
- Add cleanup functions to cancel pending state updates on unmount
- Check isMounted before all state updates in async operations
- Fixes issue where spinner would spin forever when loading articles
2025-10-20 15:00:39 +02:00
Gigi
f7e50023a3 feat: replace ContentWithResolvedProfiles with comprehensive RichContent component
- Create RichContent component to handle ALL nostr URI types
- Support npub, nprofile, note, nevent, naddr with profile resolution
- Handle both 'nostr:npub1...' and plain 'npub1...' formats
- Replace all ContentWithResolvedProfiles usages in CardView, LargeView, and CompactView
- Now all bookmark content properly displays resolved nostr mentions
2025-10-20 14:57:39 +02:00
Gigi
6b09212fe9 feat: resolve user profiles for npub mentions in highlight comments
- Create NostrMentionLink component to fetch and display user names
- Replace truncated pubkey display with resolved profile names
- Fetch profiles in background non-blocking way using useEventModel
- Falls back to truncated pubkey if profile not available
2025-10-20 14:55:00 +02:00
Gigi
cecff6b8d5 fix: filter out bookmark list events from individual bookmarks display
- Bookmark list events (kind:10003, 30003, 30001) are containers, not content
- Add filter in hydrateItems to exclude these kinds after hydration
- Add debug logging to track which items are being filtered
- Prevents bookmark list events from showing as individual bookmarks in UI
2025-10-20 14:45:30 +02:00
Gigi
2b061afa47 debug: add [BOOKMARK_TS] logging to investigate timestamp issues
- Log parentCreatedAt value when processApplesauceBookmarks is called
- Log each bookmark event with its kind and created_at timestamp
- Log count and timestamp for notes, articles, and URLs being processed
- Prefixed with [BOOKMARK_TS] for easy console filtering
2025-10-20 13:56:07 +02:00
Gigi
7516013e67 fix: use parent event timestamp for bookmarks instead of placeholder
- Add parentCreatedAt parameter to processApplesauceBookmarks function
- Replace all Math.floor(Date.now() / 1000) placeholders with parentCreatedAt || 0
- Update all call sites in bookmarkProcessing.ts to pass evt.created_at
- Individual bookmarks now inherit timestamp from their bookmark list event
- Bookmarks without valid parent timestamp will show as 0 (epoch) and be filtered by hideBookmarksWithoutCreationDate setting
- Eliminates 'now' placeholder timestamps in bookmark sidebar
2025-10-20 13:51:26 +02:00
Gigi
567641de77 fix: improve detection of placeholder bookmarks without valid timestamps
- Enhanced hasCreationDate() to better detect unhydrated bookmark references
- Web bookmarks (kind 39701) always have real timestamps, always shown
- Filter out bookmarks with no content (failed hydration)
- Filter out URL-only bookmarks with minimal tags and synthetic IDs
- These are created during NIP-51 processing and show 'now' if not hydrated
- Fixes issue where placeholder timestamps would pass filter after time elapsed
2025-10-20 13:45:00 +02:00
Gigi
4e86907663 fix: apply hideBookmarksWithoutCreationDate setting to Me component
- Import hasCreationDate utility function in Me.tsx
- Add UserSettings to MeProps interface
- Pass settings prop from Bookmarks to Me component
- Filter out bookmarks without creation dates when setting is enabled
- This ensures bookmarks showing 'Now' are hidden by default
2025-10-20 13:41:45 +02:00
Gigi
ec34e00573 docs: update CHANGELOG for v0.8.4 release
- Document progressive article hydration feature for reads tab
- Document React type imports fix in useArticleLoader
2025-10-20 13:36:19 +02:00
Gigi
5e6c8b7516 chore: bump version to 0.8.4 2025-10-20 13:35:13 +02:00
Gigi
e50af42c96 fix: import React types correctly in useArticleLoader
- Import Dispatch and SetStateAction directly from 'react'
- Fixes linting errors about React not being defined
- Resolves eslint no-undef errors
2025-10-20 13:34:48 +02:00
Gigi
73470987be feat: add progressive article hydration for reads tab
- Create readsController service with background article fetching
- Implement progressive hydration pattern similar to bookmarkController
- Use AddressLoader for efficient batched article event retrieval
- Update Me.tsx to use readsController instead of direct readingProgressController
- Articles now show titles, summaries, images as data arrives from relays
- Fixes issue where reads showed 'Untitled' for all articles
- Keep event store integration for caching article events
- Maintain DRY principle by centralizing reads data fetching
2025-10-20 13:33:17 +02:00
Gigi
31e203825d fix(types): correct setHighlights type to accept setState updater functions 2025-10-20 13:19:39 +02:00
Gigi
6f9c0a35e2 fix(reader): trigger archive animation even if already archived on auto-complete 2025-10-20 13:17:35 +02:00
Gigi
96f59a54f3 fix(reading): ensure 2s linger at 100% uses live position ref for auto-archive 2025-10-20 13:14:10 +02:00
Gigi
87c0a0454b refactor(me): DRY archive-only builders into shared helper for reads/links 2025-10-20 13:12:34 +02:00
Gigi
77c2ef1794 feat(links): mirror archive-only vs progress-only behavior in Links tab 2025-10-20 13:02:56 +02:00
Gigi
8d08911bd3 feat(reads): separate archive vs reading-progress filters; archive shows emoji-only, progress filters ignore emoji 2025-10-20 13:00:34 +02:00
Gigi
31b005a989 fix(reads): build archive list exactly like debug loader (streamed union, no overwrite) 2025-10-20 12:56:19 +02:00
Gigi
337bfe5432 fix(reads): union archive marks from readingProgress and archiveController to prevent empty archive view 2025-10-20 12:49:29 +02:00
Gigi
2f275375f7 ui(animation): restore archive success burst on manual archive (animating state) 2025-10-20 12:45:12 +02:00
Gigi
27cbcb56ec ui(reader): keep Archived label and subtle style while remaining clickable 2025-10-20 12:43:28 +02:00
Gigi
7f150003b5 feat(reader): wire unarchive actions to delete matching reactions and clear controller 2025-10-20 12:39:28 +02:00
Gigi
1f50d8e1b6 feat(reader): make Archived button clickable and perform unarchive via NIP-09 2025-10-20 12:39:09 +02:00
Gigi
f53decef16 feat(archive): add unarchive service to delete ARCHIVE_EMOJI reactions (kind 7/17) 2025-10-20 12:38:27 +02:00
Gigi
f272943b64 chore: commit pending working changes before implementing unarchive behavior 2025-10-20 12:36:27 +02:00
Gigi
49745e1b8a refactor(archive): remove direct markedIds mutation; use controller.mark/unmark for DRY updates; fix duplicate import in reactionService 2025-10-20 11:23:45 +02:00
Gigi
470f4fb34e feat(archive): support un-archive toggle; add ArchiveController mark/unmark; prep NIP-09 deletion hook 2025-10-20 11:21:59 +02:00
Gigi
8cde36c08c fix(archive): add 'a' coord tag to mark-as-read reactions for articles; archiveController maps a-tag instantly; add debug 2025-10-20 11:17:30 +02:00
Gigi
c21f96f5bb chore(debug): deepen [archive] mapping with eventStore timeline and logs; add sampleMarked logs in Me 2025-10-20 11:05:59 +02:00
Gigi
c9fef5804b chore(debug): add [archive] debug logs in archiveController, Me, and ContentPanel to trace archive filter behavior 2025-10-20 10:48:44 +02:00
Gigi
8337622a22 feat(archive): introduce archiveController to manage marked-as-read (kind:7/17); wire into App, Me, and ContentPanel for DRY archive state 2025-10-20 10:33:42 +02:00
Gigi
572f0fed6f fix(reads/links): keep DRY filtering but enforce type separation (articles vs external) for /me/reads and /me/links filters 2025-10-20 10:14:20 +02:00
Gigi
27a55ec329 fix(links): keep Links tab active when using /me/links/:filter by recognizing links path prefix in tab detection 2025-10-20 09:50:13 +02:00
Gigi
7ba362a3bb feat(links): add /me/links/:filter routes and mirror Reads filters/state for Links tab 2025-10-20 09:47:31 +02:00
Gigi
dc1844907e feat(settings): enable 'Hide bookmarks missing a creation date' by default 2025-10-20 09:43:51 +02:00
Gigi
28123b5e13 feat(archive): rename 'Mark as Read' UI to 'Move to Archive' and show 'Archived' state; update settings and filters wording 2025-10-20 09:42:34 +02:00
Gigi
d9eb87aa5c feat(reads): rename 'emoji' filter to 'archive' and use fa-books icon; map legacy /me/reads/emoji to /me/reads/archive 2025-10-20 09:39:45 +02:00
Gigi
a0ff0daf9d docs: update CHANGELOG.md for v0.8.3 release 2025-10-20 09:30:30 +02:00
Gigi
8c3baf1416 chore: bump version to 0.8.3 2025-10-20 09:29:11 +02:00
Gigi
e0c169edbc fix(highlights): avoid unintended reload by decoupling cached highlight sync from content loading in useExternalUrlLoader 2025-10-20 09:15:41 +02:00
Gigi
d2181ad772 fix(highlights): preserve immediate UI highlight after creation by merging streaming results instead of overwriting in article and external URL loaders 2025-10-20 09:07:42 +02:00
Gigi
8ff3f08d8c fix(highlights): restore FAB selection updates by listening to document selectionchange; keep clearing selection after creation 2025-10-20 08:57:00 +02:00
Gigi
e17e1bc824 fix(lint): resolve unused var and empty catch issues 2025-10-20 00:47:11 +02:00
Gigi
948674ae8c feat(reading-progress): stream mark-as-read reactions non-blockingly and emit updates as they arrive 2025-10-20 00:45:35 +02:00
Gigi
431f14f56d feat(reads): move highlighted filter next to All for prominence 2025-10-20 00:44:03 +02:00
Gigi
4cc9d557a0 feat(reads): add emoji filter, refine completed to 95%+, and show checkmark only at >=95% progress 2025-10-20 00:43:31 +02:00
Gigi
cc60f9584a temp: disable mark-as-read reactions loading due to queryEvents hanging
Temporarily skip loading mark-as-read reactions to unblock the reads feature.
Focus on getting reading progress working first.

TODO: Debug why queryEvents hangs when querying kind:7 and kind:17 reactions.
The Promise never resolves even though we're not using timeouts.
2025-10-20 00:38:14 +02:00
Gigi
94f1f9035b debug: add logging before/after queryEvents calls for reactions 2025-10-20 00:35:51 +02:00
Gigi
e5b1594933 feat: add listener for markedAsReadChanged events
Implemented event listener pattern in readingProgressController:
- Added onMarkedAsReadChanged() method for subscribers
- Added emitMarkedAsReadChanged() to notify when marked IDs update
- Call emitMarkedAsReadChanged() after loading reactions

In Me.tsx:
- Subscribe to onMarkedAsReadChanged() in new useEffect
- When fired, rebuild reads list with new marked-as-read items
- Include marked-only items (no progress event)

Now when reactions finish loading in background, /me/reads/completed
will update automatically with newly marked articles.
2025-10-20 00:34:38 +02:00
Gigi
2bf9b9789b debug: add detailed logging to mark-as-read reactions loading
Added comprehensive logging to see:
- When reactions queries start and complete
- How many kind:17 and kind:7 events are returned
- What reactions have MARK_AS_READ_EMOJI content
- Event ID to naddr mapping progress
- Final count of markedAsReadIds

This will help identify why markedAsReadIds is empty.
2025-10-20 00:33:01 +02:00
Gigi
d3405a4029 refactor: use bookmarkController pattern in readingProgressController
Non-blocking, background loading pattern:
- Subscribe to eventStore timeline immediately (returns right away)
- Mark as loaded immediately
- Fire-and-forget background queries for reading progress from relays
- Fire-and-forget background queries for mark-as-read reactions
- All updates stream via eventStore subscription

No timeouts. No blocking awaits. Updates arrive progressively as relays
respond, UI shows data as soon as eventStore delivers it.
2025-10-20 00:29:39 +02:00
Gigi
763f7bef4d debug: add granular logging to identify where loading hangs
Added logs at each step:
- Setting up timeline subscription
- Timeline subscription ready
- Querying reading progress events
- Got reading progress events count
- Generation changed abort

This will show exactly which step is blocking.
2025-10-20 00:23:04 +02:00
Gigi
e8e629f4e1 fix: prevent concurrent start() calls in readingProgressController
Added isLoading flag to block multiple start() calls from running in parallel.
The repeated start() calls were all waiting on queryEvents() calls,
creating a thundering herd that prevented any from completing.

Now only one start() runs at a time, and concurrent calls are skipped
with a console log.
2025-10-20 00:18:23 +02:00
Gigi
a0829e834f feat: mirror debug behavior in Me tabs for MARK_AS_READ
- Reads (/me/reads/completed): fetch kind:7 📚 reactions and map #e -> 30023 naddr; include as completed reads
- Links (/me/links/completed): fetch kind:17 📚 reactions and use #r URL; include as completed links
- Keep progress-based items from readingProgressController, but explicitly add marked-only items per tab

This matches the debug page behavior and splits articles vs links cleanly.
2025-10-20 00:00:00 +02:00
Gigi
ff938aa384 feat(reads): include marked-as-read-only items in /me/reads
If an article or URL is marked as read (📚) but has no reading
progress event yet, include it in the reads list so the 'completed'
filter surfaces it.

Uses readingProgressController.getMarkedAsReadIds() to synthesize
ReadItems for marked-only entries.
2025-10-19 23:57:20 +02:00
Gigi
3991bfeeb2 fix: move lastLoadedPubkey assignment to end of start() method
The bug: start() was setting lastLoadedPubkey at the beginning, so if
start() got called twice (which it was), the second call would see
isLoadedFor(pubkey) return true and skip the entire loading process,
including fetching mark-as-read reactions.

Fix: Only set lastLoadedPubkey AFTER all fetching is complete. This
ensures that concurrent start() calls don't skip the loading.

This allows kind:7 and kind:17 mark-as-read reactions to be fetched
and tracked properly.
2025-10-19 23:54:44 +02:00
Gigi
e8c35c8914 debug: add module-level log to confirm module is loaded
If this log doesn't appear in console, the module isn't being imported at all.
2025-10-19 23:53:31 +02:00
Gigi
46345c154b debug: add log before fetching mark-as-read reactions
Shows we reached the point where we're about to fetch kind:7/kind:17 reactions.
2025-10-19 23:53:00 +02:00
Gigi
f43dae92aa debug: add start() method logs to confirm controller initialization
Added initial logs to show:
- When start() is called
- Whether already loaded (and skipped)

This helps confirm the controller is even being initialized.
2025-10-19 23:52:24 +02:00
Gigi
99c164a5e9 debug: add detailed logging to understand markedAsReadIds population
Added:
- getMarkedAsReadIds() method to expose markedAsReadIds for debugging
- Final state logging showing all progressMap keys and markedAsReadIds
- Comprehensive logging throughout kind:7/kind:17 processing

This will help identify why markedAsRead articles aren't showing in /me/reads/completed.
Check console logs to see:
1. All progressMap entries (nadrs)
2. All markedAsReadIds entries
3. Step-by-step kind:7 and kind:17 processing
2025-10-19 23:51:05 +02:00
Gigi
569b4357f2 fix: skip title fetching for raw event IDs in HighlightCitation
The eventReference can be either:
1. Raw event ID (hex string) - from event pointers
2. Coordinate string (kind:pubkey:identifier) - from address pointers
3. Already-encoded naddr - from some sources

Raw event IDs cannot be converted to nadrs without additional context
(we don't have the kind, pubkey, or identifier), so skip title fetching
for them to avoid bech32 decoding errors.

Fixes console errors:
- 'Invalid checksum in <hex>'
- 'Unknown letter: "b". Allowed: qpzry9x8gf2tvdw0s3jn54khce6mua7l'

These errors occurred when trying to decode raw hex event IDs as bech32.
2025-10-19 23:49:12 +02:00
Gigi
de287c625b chore: remove relay.current.fyi from relay list
Removed 'wss://relay.current.fyi' from both api/article-og.ts and
src/config/relays.ts as this relay is no longer used.
2025-10-19 23:47:33 +02:00
Gigi
1424f6ebc5 debug: add console.log statements to debug mark-as-read reaction tracking
Added detailed logging throughout the kind:7 and kind:17 reaction
processing to understand:
- What reactions are being fetched
- Which ones have MARK_AS_READ_EMOJI
- Event ID extraction
- Article lookups
- Event ID to naddr mapping
- Final markedAsReadIds set

Check browser console when loading /me/reads to see the full flow.
2025-10-19 23:46:25 +02:00
Gigi
b0a368fc64 fix: properly handle kind:7 mark-as-read reactions with event ID to naddr mapping
Restored kind:7 reaction handling with proper implementation:
1. Fetch kind:7 reactions with MARK_AS_READ_EMOJI
2. Extract event IDs from #e tags
3. Fetch the referenced articles (kind:30023)
4. Build mapping of event IDs to nadrs
5. Add marked articles to markedAsReadIds using their nadrs

Now both kind:7 (Nostr articles) and kind:17 (URLs) mark-as-read
reactions are properly tracked and will appear in /me/reads/completed.

Added nip19 import for naddr encoding.
2025-10-19 23:44:56 +02:00
Gigi
6f8cf641b7 fix: correctly track mark-as-read reactions in readingProgressController
Fixed several issues:
1. Clear markedAsReadIds on reset() so it doesn't persist across logouts
2. Skip kind:7 reactions (events) as they require complex event ID to naddr mapping
3. Only process kind:17 reactions (URLs) which directly use URLs as identifiers
4. Correctly extract URL from #r tag instead of using emoji content

Now kind:17 mark-as-read reactions for external URLs are properly tracked.
These articles will appear in /me/reads/completed.
2025-10-19 23:42:22 +02:00
Gigi
23b4c3475f feat: track mark-as-read reactions in readingProgressController
Extended readingProgressController to also fetch and track mark-as-read
reactions (kind:7 and kind:17 with MARK_AS_READ_EMOJI) alongside reading
progress events.

Changes:
- Added markedAsReadIds Set to controller
- Query mark-as-read reactions in parallel with reading progress
- Added isMarkedAsRead() method to check if article is marked as read
- Updated Me.tsx to include markedAsRead status in ReadItems

Now /me/reads/completed shows:
- Articles with >= 95% reading progress
- Articles marked as read with the 📚 emoji
2025-10-19 23:33:22 +02:00
Gigi
5633dc640c refactor: simplify reads - use readingProgressController directly
Removed the complex readsController wrapper. Now /me/reads simply:
1. Uses readingProgressController (already loaded in App.tsx)
2. Converts progress map to ReadItems
3. Subscribes to progress updates

This is much simpler and DRY - no need for a separate controller.
Reading progress is already deduped and managed centrally.

Same approach as debug page - just use the data source directly.
2025-10-19 23:29:06 +02:00
Gigi
0f1dfa445a refactor: simplify reads loading - don't require bookmarks
Reads don't actually need bookmarks to load. Reading progress (kind:39802)
is independent and stands on its own. Bookmarks are just optional enrichment.

Changed:
- readsController.start() no longer takes bookmarks parameter
- Pass empty array to fetchAllReads instead
- Load reads immediately in App.tsx like highlights/writings
- No more circular dependency on bookmarks loading first

This is simpler and loads reading progress faster.
2025-10-19 23:26:00 +02:00
Gigi
ab5225de50 fix: emit all reading items not just articles
The onItem callback was filtering to only 'article' type items,
which excluded external URLs from reading progress. Now all items
(articles and external URLs) are emitted to readsController.

This fixes the empty reads list issue where reading progress exists
but wasn't being displayed.
2025-10-19 23:24:58 +02:00
Gigi
b89705cf43 feat: load reads centrally in App.tsx like bookmarks and highlights
- Import readsController in App.tsx
- Start readsController in the central useEffect when user logs in
- Pass bookmarks to readsController.start() for article lookups
- Simplify Me.tsx loadReadsTab to just mark tab as loaded
- Subscription to readsController in Me.tsx still streams updates to UI

This means:
- Reads load in the background automatically
- Data is available even before clicking the Reads tab
- Consistent with how bookmarks, highlights, and writings are loaded
- Non-blocking - readsController streams updates progressively
2025-10-19 23:23:16 +02:00
Gigi
740dd53299 fix: properly subscribe to readsController updates with useEffect
The loadReadsTab async function was trying to return cleanup functions,
which doesn't work in React. Moved the subscription logic to a separate
useEffect hook with empty dependency array so:
- Subscriptions are set up once on mount
- Cleanup happens properly on unmount
- readsController updates flow through to UI correctly

This fixes the empty reads list issue.
2025-10-19 23:21:12 +02:00
Gigi
eb61553c20 feat: create readsController following highlightsController pattern
- New src/services/readsController.ts manages all reading activity centrally
- Streams reading items as they arrive (progress, marks as read, bookmarks)
- Supports subscriptions via onReads() and onLoading() callbacks
- Tracks loading state and last synced timestamp per user
- Generation-based cancellation for logout/pubkey changes
- Deduplicates by article ID and sorts by reading activity
- Updated Me.tsx loadReadsTab to use readsController instead of calling fetchAllReads
- Provides same reactive, non-blocking UX as highlightsController
2025-10-19 23:19:46 +02:00
Gigi
8b708535ca fix: don't block UI while loading reads - stream updates as data arrives
Changed loadReadsTab to not await fetchAllReads. Instead:
- Start with empty state immediately
- Use onItem callback to stream updates as they're fetched
- Reading data flows in as it arrives (reading progress, marks as read, etc)
- UI doesn't block waiting for all article data to be fetched

Same pattern as debug page - provides responsive UI with progressive loading.
2025-10-19 23:17:40 +02:00
Gigi
f77761c002 feat: show all reading activity in /me/reads, not just bookmarks
Changed loadReadsTab to use fetchAllReads directly instead of deriveReadsFromBookmarks.
Now /me/reads shows ALL articles with any reading activity:
- Articles with reading progress (kind:39802)
- Articles marked as read (kind:7, kind:17 reactions)
- Articles with highlights
- Bookmarked articles

Previously only showed bookmarked articles and tried to enrich with reading data.
Now the reading data (progress, marks as read) is the primary source.
2025-10-19 23:15:53 +02:00
Gigi
b900666eb8 feat: add category breakdown to reading progress debug output
Shows counts of articles in each reading progress category:
- Unopened (0%)
- Started (0% < progress ≤ 10%)
- Reading (10% < progress ≤ 94%) - highlighted in green
- Completed (≥ 95%)

This helps understand why /me/reads/reading shows fewer articles than
the total reading progress events - most articles fall into other categories.
2025-10-19 23:11:38 +02:00
Gigi
2639c78957 feat: display both raw and deduplicated reading progress events
- Load raw events from queryEvents for transparency
- Load deduplicated results from readingProgressController in parallel
- Display raw events first, then deduplicated results below for comparison
- Helps debugging by showing all events plus the final processed state
2025-10-19 23:08:31 +02:00
Gigi
8320911bc9 refactor: use readingProgressController for deduplicated progress in debug
- Replace raw queryEvents with readingProgressController.start() for reading progress
- Controller already handles deduplication by article (d-tag) and keeps most recent
- Display deduplicated progress map below raw events for easy comparison
- Add progress percentage and visual progress bar for each article
- Add styling with blue background to distinguish deduplicated results
2025-10-19 23:07:32 +02:00
Gigi
00d6bd4c46 feat: add reading progress loading section to debug page
- Add state variables for reading progress events and mark-as-read reactions
- Implement handler to load all reading progress events (kind:39802) for logged-in user
- Implement handler to load all mark-as-read reactions (kind:7, kind:17) with MARK_AS_READ_EMOJI filter
- Add two new sections to debug page with buttons and results display
- Display event details including author, creation time, and relevant tags
- Include timing metrics for load operations
2025-10-19 23:02:15 +02:00
Gigi
cd377b6f26 docs: update CHANGELOG.md for v0.8.2 release
- Added reading progress indicator in compact cards
- Compact cards layout optimizations (reduced padding, row height, gaps)
- Reading progress bar styling (thinner, aligned with text)
- Fixed: Removed borders from compact bookmarks
2025-10-19 22:55:39 +02:00
37 changed files with 1969 additions and 395 deletions

View File

@@ -7,6 +7,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.8.4] - 2024-10-20
### Added
- Progressive article hydration for reads tab
- Articles now load titles, summaries, images, and author information progressively
- Implemented readsController following the same pattern as bookmarkController
- Uses AddressLoader for efficient batched article event retrieval
- Articles rehydrate as data arrives from relays without blocking initial display
- Event store integration for caching article events
- Centralized reads data fetching following DRY principles
### Fixed
- Fixed React type imports in useArticleLoader
- Import `Dispatch` and `SetStateAction` directly from 'react' instead of using `React.` prefix
- Resolves ESLint no-undef errors
## [0.8.3] - 2025-01-19
### Fixed
- Highlight creation now shows immediate UI feedback without page refresh
- Fixed streaming highlight merge logic to preserve newly created highlights
- Decoupled cached highlight sync from content loading to prevent unintended reloads
- Newly created highlights appear instantly in both reader and highlights panel
- Highlights remain visible while remote results stream in and merge properly
### Changed
- Improved highlight creation user experience
- Selection clearing and synchronous rendering for immediate highlight display
- Better error handling for bunker permission issues with user-friendly messages
## [0.8.2] - 2025-10-19
### Added
- Reading progress indicator in compact bookmark cards
- Shows progress bar for articles and web bookmarks with reading data
- Progress bar aligned with bookmark text for better visual association
- Color-coded progress (blue for reading, green for completed, gray for started)
### Changed
- Compact cards layout optimizations for more space-efficient display
- Reduced vertical padding from 0.5rem to 0.25rem
- Reduced compact row height from 28px to 24px
- Reduced gap between compact cards from 0.5rem to 0.25rem
- Reading progress bar styling for compact view
- Bar height reduced from 2px to 1px for more subtle appearance
- Left margin of 1.5rem aligns bar with bookmark text instead of appearing as separator
### Fixed
- Removed borders from compact bookmarks in bookmarks sidebar
- Border styling from `.bookmarks-list` no longer applies to compact cards
- Compact cards now display as truly borderless and transparent
## [0.8.0] - 2025-10-19
### Added
@@ -2044,7 +2103,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Optimize relay usage following applesauce-relay best practices
- Use applesauce-react event models for better profile handling
[Unreleased]: https://github.com/dergigi/boris/compare/v0.8.0...HEAD
[Unreleased]: https://github.com/dergigi/boris/compare/v0.8.3...HEAD
[0.8.3]: https://github.com/dergigi/boris/compare/v0.8.2...v0.8.3
[0.8.2]: https://github.com/dergigi/boris/compare/v0.8.0...v0.8.2
[0.8.0]: https://github.com/dergigi/boris/compare/v0.7.4...v0.8.0
[0.7.4]: https://github.com/dergigi/boris/compare/v0.7.3...v0.7.4
[0.7.3]: https://github.com/dergigi/boris/compare/v0.7.2...v0.7.3

View File

@@ -15,7 +15,6 @@ const RELAYS = [
'wss://relay.dergigi.com',
'wss://wot.dergigi.com',
'wss://relay.snort.social',
'wss://relay.current.fyi',
'wss://nostr-pub.wellorder.net',
'wss://purplepag.es',
'wss://relay.primal.net'

4
package-lock.json generated
View File

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

View File

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

View File

@@ -28,6 +28,7 @@ import { readingProgressController } from './services/readingProgressController'
// import { fetchNostrverseHighlights } from './services/nostrverseService'
import { nostrverseHighlightsController } from './services/nostrverseHighlightsController'
import { nostrverseWritingsController } from './services/nostrverseWritingsController'
import { archiveController } from './services/archiveController'
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
@@ -114,6 +115,11 @@ function AppRoutes({
readingProgressController.start({ relayPool, eventStore, pubkey })
}
// Load archive (marked-as-read) controller
if (pubkey && eventStore && !archiveController.isLoadedFor(pubkey)) {
archiveController.start({ relayPool, eventStore, pubkey })
}
// Start centralized nostrverse highlights controller (non-blocking)
if (eventStore) {
nostrverseHighlightsController.start({ relayPool, eventStore })
@@ -145,6 +151,7 @@ function AppRoutes({
contactsController.reset() // Clear contacts via controller
highlightsController.reset() // Clear highlights via controller
readingProgressController.reset() // Clear reading progress via controller
archiveController.reset() // Clear archive state
showToast('Logged out successfully')
}
@@ -286,6 +293,18 @@ function AppRoutes({
/>
}
/>
<Route
path="/me/links/:filter"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
/>
}
/>
<Route
path="/me/writings"
element={

View File

@@ -16,7 +16,7 @@ const ArchiveFilters: React.FC<ArchiveFiltersProps> = ({ selectedFilter, onFilte
{ type: 'to-read' as const, icon: faBookmark, label: 'To Read' },
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' },
{ type: 'marked' as const, icon: faBooks, label: 'Marked as Read' }
{ type: 'marked' as const, icon: faBooks, label: 'Archived' }
]
return (

View File

@@ -5,7 +5,7 @@ import { faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
import { IndividualBookmark } from '../../types/bookmarks'
import { formatDate, renderParsedContent } from '../../utils/bookmarkUtils'
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
import RichContent from '../RichContent'
import { classifyUrl } from '../../utils/helpers'
import { useImageCache } from '../../hooks/useImageCache'
import { getPreviewImage, fetchOgImage } from '../../utils/imagePreview'
@@ -147,19 +147,15 @@ export const CardView: React.FC<CardViewProps> = ({
)}
{isArticle && articleSummary ? (
<div className="bookmark-content article-summary">
<ContentWithResolvedProfiles content={articleSummary} />
</div>
<RichContent content={articleSummary} className="bookmark-content article-summary" />
) : bookmark.parsedContent ? (
<div className="bookmark-content">
{shouldTruncate && bookmark.content
? <ContentWithResolvedProfiles content={`${bookmark.content.slice(0, 210).trimEnd()}`} />
? <RichContent content={`${bookmark.content.slice(0, 210).trimEnd()}`} className="" />
: renderParsedContent(bookmark.parsedContent)}
</div>
) : bookmark.content && (
<div className="bookmark-content">
<ContentWithResolvedProfiles content={shouldTruncate ? `${bookmark.content.slice(0, 210).trimEnd()}` : bookmark.content} />
</div>
<RichContent content={shouldTruncate ? `${bookmark.content.slice(0, 210).trimEnd()}` : bookmark.content} />
)}
{contentLength > 210 && (

View File

@@ -3,7 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
import { IndividualBookmark } from '../../types/bookmarks'
import { formatDateCompact } from '../../utils/bookmarkUtils'
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
import RichContent from '../RichContent'
interface CompactViewProps {
bookmark: IndividualBookmark
@@ -66,7 +66,7 @@ export const CompactView: React.FC<CompactViewProps> = ({
</span>
{displayText && (
<div className="compact-text">
<ContentWithResolvedProfiles content={displayText.slice(0, 60) + (displayText.length > 60 ? '…' : '')} />
<RichContent content={displayText.slice(0, 60) + (displayText.length > 60 ? '…' : '')} className="" />
</div>
)}
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at)}</span>

View File

@@ -4,7 +4,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
import { IndividualBookmark } from '../../types/bookmarks'
import { formatDate } from '../../utils/bookmarkUtils'
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
import RichContent from '../RichContent'
import { IconGetter } from './shared'
import { useImageCache } from '../../hooks/useImageCache'
import { getEventUrl } from '../../config/nostrGateways'
@@ -95,13 +95,9 @@ export const LargeView: React.FC<LargeViewProps> = ({
<div className="large-content">
{isArticle && articleSummary ? (
<div className="large-text article-summary">
<ContentWithResolvedProfiles content={articleSummary} />
</div>
<RichContent content={articleSummary} className="large-text article-summary" />
) : bookmark.content && (
<div className="large-text">
<ContentWithResolvedProfiles content={bookmark.content} />
</div>
<RichContent content={bookmark.content} className="large-text" />
)}
{/* Reading progress indicator for articles - shown only if there's progress */}

View File

@@ -64,7 +64,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
location.pathname === '/me/highlights' ? 'highlights' :
location.pathname === '/me/reading-list' ? 'reading-list' :
location.pathname.startsWith('/me/reads') ? 'reads' :
location.pathname === '/me/links' ? 'links' :
location.pathname.startsWith('/me/links') ? 'links' :
location.pathname === '/me/writings' ? 'writings' : 'highlights'
// Extract tab from profile routes
@@ -328,7 +328,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
) : undefined}
me={showMe ? (
relayPool ? <Me relayPool={relayPool} eventStore={eventStore} activeTab={meTab} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} /> : null
relayPool ? <Me relayPool={relayPool} eventStore={eventStore} activeTab={meTab} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} settings={settings} /> : null
) : undefined}
profile={showProfile && profilePubkey ? (
relayPool ? <Profile relayPool={relayPool} eventStore={eventStore} pubkey={profilePubkey} activeTab={profileTab} /> : null

View File

@@ -29,6 +29,8 @@ import {
hasMarkedEventAsRead,
hasMarkedWebsiteAsRead
} from '../services/reactionService'
import { unarchiveEvent, unarchiveWebsite } from '../services/unarchiveService'
import { archiveController } from '../services/archiveController'
import AuthorCard from './AuthorCard'
import { faBooks } from '../icons/customIcons'
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
@@ -182,14 +184,19 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
}
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, html, markdown])
const { isReadingComplete, progressPercentage, saveNow } = useReadingPosition({
const { progressPercentage, saveNow } = useReadingPosition({
enabled: isTextContent,
syncEnabled: settings?.syncReadingPosition !== false,
onSave: handleSavePosition,
onReadingComplete: () => {
// Auto-mark as read when reading is complete (if enabled in settings)
if (activeAccount && !isMarkedAsRead && settings?.autoMarkAsReadOnCompletion) {
if (!settings?.autoMarkAsReadOnCompletion || !activeAccount) return
if (!isMarkedAsRead) {
handleMarkAsRead()
} else {
// Already archived: still show the success animation for feedback
setShowCheckAnimation(true)
setTimeout(() => setShowCheckAnimation(false), 600)
}
}
})
@@ -566,12 +573,27 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
activeAccount.pubkey,
relayPool
)
// Also check archiveController
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1]
if (dTag) {
try {
const naddr = nip19.naddrEncode({ kind: 30023, pubkey: currentArticle.pubkey, identifier: dTag })
hasRead = hasRead || archiveController.isMarked(naddr)
console.log('[archive][content] check article', { naddr: naddr.slice(0, 24) + '...', hasRead })
} catch (e) {
console.warn('[archive][content] encode naddr failed', e)
}
}
} else {
hasRead = await hasMarkedWebsiteAsRead(
selectedUrl,
activeAccount.pubkey,
relayPool
)
// Also check archiveController
const ctrl = archiveController.isMarked(selectedUrl)
hasRead = hasRead || ctrl
console.log('[archive][content] check url', { url: selectedUrl, hasRead, ctrl })
}
setIsMarkedAsRead(hasRead)
} catch (error) {
@@ -585,7 +607,35 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
}, [selectedUrl, currentArticle, activeAccount, relayPool, isNostrArticle])
const handleMarkAsRead = () => {
if (!activeAccount || !relayPool || isMarkedAsRead) {
if (!activeAccount || !relayPool) return
// Toggle archive state: if already archived, request deletion; else archive
if (isMarkedAsRead) {
// Optimistically unarchive in UI; background deletion request (NIP-09)
setIsMarkedAsRead(false)
;(async () => {
try {
if (isNostrArticle && currentArticle) {
// Send deletion for all matching reactions
await unarchiveEvent(currentArticle.id, activeAccount, relayPool)
// Also clear controller mark so lists update
try {
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1]
if (dTag) {
const naddr = nip19.naddrEncode({ kind: 30023, pubkey: currentArticle.pubkey, identifier: dTag })
archiveController.unmark(naddr)
}
} catch (e) {
console.warn('[archive][content] encode naddr failed', e)
}
} else if (selectedUrl) {
await unarchiveWebsite(selectedUrl, activeAccount, relayPool)
archiveController.unmark(selectedUrl)
}
} catch (err) {
console.warn('[archive][content] unarchive failed', err)
}
})()
return
}
@@ -607,14 +657,36 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
currentArticle.pubkey,
currentArticle.kind,
activeAccount,
relayPool
relayPool,
{
aCoord: (() => {
try {
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1]
if (!dTag) return undefined
return `${30023}:${currentArticle.pubkey}:${dTag}`
} catch { return undefined }
})()
}
)
// Update archiveController immediately
try {
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1]
if (dTag) {
const naddr = nip19.naddrEncode({ kind: 30023, pubkey: currentArticle.pubkey, identifier: dTag })
archiveController.mark(naddr)
console.log('[archive][content] optimistic mark article', naddr.slice(0, 24) + '...')
}
} catch (err) {
console.warn('[archive][content] optimistic article mark failed', err)
}
} else if (selectedUrl) {
await createWebsiteReaction(
selectedUrl,
activeAccount,
relayPool
)
archiveController.mark(selectedUrl)
console.log('[archive][content] optimistic mark url', selectedUrl)
}
} catch (error) {
console.error('Failed to mark as read:', error)
@@ -648,7 +720,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
{isTextContent && (
<ReadingProgressIndicator
progress={progressPercentage}
isComplete={isReadingComplete}
// Consider complete only at 95%+
isComplete={progressPercentage >= 95}
showPercentage={true}
isSidebarCollapsed={isSidebarCollapsed}
isHighlightsCollapsed={isHighlightsCollapsed}
@@ -754,8 +827,9 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
<button
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
onClick={handleMarkAsRead}
disabled={isMarkedAsRead || isCheckingReadStatus}
disabled={isCheckingReadStatus}
title={isMarkedAsRead ? 'Already Marked as Watched' : 'Mark as Watched'}
style={isMarkedAsRead ? { opacity: 0.85 } : undefined}
>
<FontAwesomeIcon
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
@@ -917,21 +991,22 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
</div>
)}
{/* Mark as Read button */}
{/* Archive button */}
{activeAccount && (
<div className="mark-as-read-container">
<button
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
onClick={handleMarkAsRead}
disabled={isMarkedAsRead || isCheckingReadStatus}
title={isMarkedAsRead ? 'Already Marked as Read' : 'Mark as Read'}
disabled={isCheckingReadStatus}
title={isMarkedAsRead ? 'Already Archived' : 'Move to Archive'}
style={isMarkedAsRead ? { opacity: 0.85 } : undefined}
>
<FontAwesomeIcon
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
spin={isCheckingReadStatus}
/>
<span>
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Marked as Read' : 'Mark as Read'}
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Archived' : 'Move to Archive'}
</span>
</button>
</div>

View File

@@ -19,6 +19,7 @@ import { useSettings } from '../hooks/useSettings'
import { fetchHighlights, fetchHighlightsFromAuthors } from '../services/highlightService'
import { contactsController } from '../services/contactsController'
import { writingsController } from '../services/writingsController'
import { readingProgressController } from '../services/readingProgressController'
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
const defaultPayload = 'The quick brown fox jumps over the lazy dog.'
@@ -102,6 +103,21 @@ const Debug: React.FC<DebugProps> = ({
const [tLoadWritings, setTLoadWritings] = useState<number | null>(null)
const [tFirstWriting, setTFirstWriting] = useState<number | null>(null)
// Reading Progress loading state
const [isLoadingReadingProgress, setIsLoadingReadingProgress] = useState(false)
const [readingProgressEvents, setReadingProgressEvents] = useState<NostrEvent[]>([])
const [tLoadReadingProgress, setTLoadReadingProgress] = useState<number | null>(null)
const [tFirstReadingProgress, setTFirstReadingProgress] = useState<number | null>(null)
// Mark-as-read reactions loading state
const [isLoadingMarkAsRead, setIsLoadingMarkAsRead] = useState(false)
const [markAsReadReactions, setMarkAsReadReactions] = useState<NostrEvent[]>([])
const [tLoadMarkAsRead, setTLoadMarkAsRead] = useState<number | null>(null)
const [tFirstMarkAsRead, setTFirstMarkAsRead] = useState<number | null>(null)
// Deduplicated reading progress from controller
const [deduplicatedProgressMap, setDeduplicatedProgressMap] = useState<Map<string, number>>(new Map())
// Live timing state
const [liveTiming, setLiveTiming] = useState<{
nip44?: { type: 'encrypt' | 'decrypt'; startTime: number }
@@ -109,6 +125,8 @@ const Debug: React.FC<DebugProps> = ({
loadBookmarks?: { startTime: number }
decryptBookmarks?: { startTime: number }
loadHighlights?: { startTime: number }
loadReadingProgress?: { startTime: number }
loadMarkAsRead?: { startTime: number }
}>({})
// Web of Trust state
@@ -724,6 +742,150 @@ const Debug: React.FC<DebugProps> = ({
setTFirstWriting(null)
}
const handleLoadReadingProgress = async () => {
if (!relayPool || !eventStore || !activeAccount?.pubkey) {
DebugBus.warn('debug', 'Please log in to load reading progress')
return
}
try {
setIsLoadingReadingProgress(true)
setReadingProgressEvents([])
setTLoadReadingProgress(null)
setTFirstReadingProgress(null)
setDeduplicatedProgressMap(new Map())
DebugBus.info('debug', 'Loading reading progress events...')
const start = performance.now()
let firstEventTime: number | null = null
setLiveTiming(prev => ({ ...prev, loadReadingProgress: { startTime: start } }))
const { queryEvents } = await import('../services/dataFetch')
const { KINDS } = await import('../config/kinds')
// Load raw events for display
const rawEvents: NostrEvent[] = []
const rawQueryPromise = queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [activeAccount.pubkey] }, {
onEvent: (evt) => {
if (firstEventTime === null) {
firstEventTime = performance.now() - start
setTFirstReadingProgress(Math.round(firstEventTime))
}
rawEvents.push(evt)
setReadingProgressEvents([...rawEvents])
}
})
// Load deduplicated results via controller
const unsubProgress = readingProgressController.onProgress((progressMap) => {
setDeduplicatedProgressMap(new Map(progressMap))
})
// Run both in parallel
await Promise.all([
rawQueryPromise,
readingProgressController.start({ relayPool, eventStore, pubkey: activeAccount.pubkey, force: true })
])
unsubProgress()
const elapsed = Math.round(performance.now() - start)
setTLoadReadingProgress(elapsed)
setLiveTiming(prev => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
const { loadReadingProgress, ...rest } = prev
return rest
})
const finalMap = readingProgressController.getProgressMap()
DebugBus.info('debug', `Loaded ${rawEvents.length} raw events, deduplicated to ${finalMap.size} articles in ${elapsed}ms`)
} catch (err) {
console.error('Failed to load reading progress:', err)
DebugBus.error('debug', `Failed to load reading progress: ${err instanceof Error ? err.message : String(err)}`)
} finally {
setIsLoadingReadingProgress(false)
}
}
const handleClearReadingProgress = () => {
setReadingProgressEvents([])
setTLoadReadingProgress(null)
setTFirstReadingProgress(null)
setDeduplicatedProgressMap(new Map())
DebugBus.info('debug', 'Cleared reading progress data')
}
const handleLoadMarkAsReadReactions = async () => {
if (!relayPool || !activeAccount?.pubkey) {
DebugBus.warn('debug', 'Please log in to load mark-as-read reactions')
return
}
try {
setIsLoadingMarkAsRead(true)
setMarkAsReadReactions([])
setTLoadMarkAsRead(null)
setTFirstMarkAsRead(null)
DebugBus.info('debug', 'Loading mark-as-read reactions...')
const start = performance.now()
let firstEventTime: number | null = null
setLiveTiming(prev => ({ ...prev, loadMarkAsRead: { startTime: start } }))
const { queryEvents } = await import('../services/dataFetch')
const { ARCHIVE_EMOJI } = await import('../services/reactionService')
// Load both kind:7 (reactions to events) and kind:17 (reactions to URLs)
const [kind7Events, kind17Events] = await Promise.all([
queryEvents(relayPool, { kinds: [7], authors: [activeAccount.pubkey] }, {
onEvent: (evt) => {
if (evt.content === ARCHIVE_EMOJI) {
if (firstEventTime === null) {
firstEventTime = performance.now() - start
setTFirstMarkAsRead(Math.round(firstEventTime))
}
setMarkAsReadReactions(prev => [...prev, evt])
}
}
}),
queryEvents(relayPool, { kinds: [17], authors: [activeAccount.pubkey] }, {
onEvent: (evt) => {
if (evt.content === ARCHIVE_EMOJI) {
if (firstEventTime === null) {
firstEventTime = performance.now() - start
setTFirstMarkAsRead(Math.round(firstEventTime))
}
setMarkAsReadReactions(prev => [...prev, evt])
}
}
})
])
const totalEvents = kind7Events.length + kind17Events.length
const elapsed = Math.round(performance.now() - start)
setTLoadMarkAsRead(elapsed)
setLiveTiming(prev => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
const { loadMarkAsRead, ...rest } = prev
return rest
})
DebugBus.info('debug', `Loaded ${totalEvents} mark-as-read reactions in ${elapsed}ms`)
} catch (err) {
console.error('Failed to load mark-as-read reactions:', err)
DebugBus.error('debug', `Failed to load mark-as-read reactions: ${err instanceof Error ? err.message : String(err)}`)
} finally {
setIsLoadingMarkAsRead(false)
}
}
const handleClearMarkAsRead = () => {
setMarkAsReadReactions([])
setTLoadMarkAsRead(null)
setTFirstMarkAsRead(null)
DebugBus.info('debug', 'Cleared mark-as-read reactions data')
}
const handleLoadFriendsList = async () => {
if (!relayPool || !activeAccount?.pubkey) {
DebugBus.warn('debug', 'Please log in to load friends list')
@@ -1348,6 +1510,194 @@ const Debug: React.FC<DebugProps> = ({
)}
</div>
{/* Reading Progress Loading Section */}
<div className="settings-section">
<h3 className="section-title">Reading Progress Loading</h3>
<div className="text-sm opacity-70 mb-3">Test reading progress loading (kind: 39802) for the logged-in user</div>
<div className="flex gap-2 mb-3 items-center">
<button
className="btn btn-primary"
onClick={handleLoadReadingProgress}
disabled={isLoadingReadingProgress || !relayPool || !activeAccount}
>
{isLoadingReadingProgress ? (
<>
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-2" />
Loading...
</>
) : (
'Load Reading Progress'
)}
</button>
<button
className="btn btn-secondary ml-auto"
onClick={handleClearReadingProgress}
disabled={readingProgressEvents.length === 0}
>
Clear
</button>
</div>
<div className="mb-3 flex gap-2 flex-wrap">
<Stat label="total" value={tLoadReadingProgress} />
<Stat label="first event" value={tFirstReadingProgress} />
</div>
{readingProgressEvents.length > 0 && (
<div className="mb-3">
<div className="text-sm opacity-70 mb-2">Loaded Reading Progress ({readingProgressEvents.length}):</div>
<div className="space-y-2 max-h-96 overflow-y-auto">
{readingProgressEvents.map((evt, idx) => {
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1]
const aTag = evt.tags?.find((t: string[]) => t[0] === 'a')?.[1]
const content = evt.content || ''
return (
<div key={idx} className="font-mono text-xs p-2 bg-gray-100 dark:bg-gray-800 rounded">
<div className="font-semibold mb-1">Reading Progress #{idx + 1}</div>
<div className="opacity-70 mb-1">
<div>Author: {evt.pubkey.slice(0, 16)}...</div>
<div>Created: {new Date(evt.created_at * 1000).toLocaleString()}</div>
</div>
<div className="mt-1">
{dTag && <div>d-tag: {dTag}</div>}
{aTag && <div className="text-[11px] opacity-70">#a: {aTag}</div>}
{content && <div>Progress: {content}</div>}
</div>
<div className="opacity-50 mt-1 text-[10px] break-all">ID: {evt.id}</div>
</div>
)
})}
</div>
</div>
)}
{deduplicatedProgressMap.size > 0 && (
<div className="mb-3">
<div className="text-sm opacity-70 mb-2">Deduplicated Reading Progress ({deduplicatedProgressMap.size} articles):</div>
{/* Category breakdown */}
<div className="mb-3 p-2 bg-purple-50 dark:bg-purple-900/20 rounded border border-purple-200 dark:border-purple-700">
<div className="text-sm font-semibold mb-2">Breakdown by Category:</div>
<div className="space-y-1">
{(() => {
let unopened = 0, started = 0, reading = 0, completed = 0
for (const progress of deduplicatedProgressMap.values()) {
if (progress === 0) unopened++
else if (progress > 0 && progress <= 0.10) started++
else if (progress > 0.10 && progress <= 0.94) reading++
else if (progress >= 0.95) completed++
}
return (
<>
<div className="flex justify-between text-xs">
<span>Unopened (0%):</span>
<span className="font-semibold">{unopened}</span>
</div>
<div className="flex justify-between text-xs">
<span>Started (0% &lt; progress 10%):</span>
<span className="font-semibold">{started}</span>
</div>
<div className="flex justify-between text-xs bg-green-100 dark:bg-green-900/30 px-1 py-0.5 rounded">
<span>Reading (10% &lt; progress 94%) :</span>
<span className="font-semibold text-green-700 dark:text-green-400">{reading}</span>
</div>
<div className="flex justify-between text-xs">
<span>Completed ( 95%):</span>
<span className="font-semibold">{completed}</span>
</div>
</>
)
})()}
</div>
</div>
<div className="space-y-2 max-h-96 overflow-y-auto">
{Array.from(deduplicatedProgressMap.entries()).map(([articleId, progress], idx) => {
return (
<div key={idx} className="font-mono text-xs p-2 bg-blue-50 dark:bg-blue-900/20 rounded border border-blue-200 dark:border-blue-700">
<div className="font-semibold mb-1">Article #{idx + 1}</div>
<div className="mt-1">
<div className="break-all">ID: {articleId}</div>
<div className="mt-1">
<div className="text-[11px] opacity-70">Progress: {(progress * 100).toFixed(1)}%</div>
<div className="w-full bg-gray-300 dark:bg-gray-700 rounded-full h-1.5 mt-1 overflow-hidden">
<div
className="bg-blue-600 h-full"
style={{ width: `${progress * 100}%` }}
/>
</div>
</div>
</div>
</div>
)
})}
</div>
</div>
)}
</div>
{/* Mark-as-read Reactions Loading Section */}
<div className="settings-section">
<h3 className="section-title">Mark-as-read Reactions Loading</h3>
<div className="text-sm opacity-70 mb-3">Test loading mark-as-read reactions (kind: 7 and 17) with the ARCHIVE_EMOJI for the logged-in user</div>
<div className="flex gap-2 mb-3 items-center">
<button
className="btn btn-primary"
onClick={handleLoadMarkAsReadReactions}
disabled={isLoadingMarkAsRead || !relayPool || !activeAccount}
>
{isLoadingMarkAsRead ? (
<>
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-2" />
Loading...
</>
) : (
'Load Mark-as-read Reactions'
)}
</button>
<button
className="btn btn-secondary ml-auto"
onClick={handleClearMarkAsRead}
disabled={markAsReadReactions.length === 0}
>
Clear
</button>
</div>
<div className="mb-3 flex gap-2 flex-wrap">
<Stat label="total" value={tLoadMarkAsRead} />
<Stat label="first event" value={tFirstMarkAsRead} />
</div>
{markAsReadReactions.length > 0 && (
<div className="mb-3">
<div className="text-sm opacity-70 mb-2">Loaded Mark-as-read Reactions ({markAsReadReactions.length}):</div>
<div className="space-y-2 max-h-96 overflow-y-auto">
{markAsReadReactions.map((evt, idx) => {
const eTag = evt.tags?.find((t: string[]) => t[0] === 'e')?.[1]
const rTag = evt.tags?.find((t: string[]) => t[0] === 'r')?.[1]
const pTag = evt.tags?.find((t: string[]) => t[0] === 'p')?.[1]
return (
<div key={idx} className="font-mono text-xs p-2 bg-gray-100 dark:bg-gray-800 rounded">
<div className="font-semibold mb-1">Mark-as-read Reaction #{idx + 1}</div>
<div className="opacity-70 mb-1">
<div>Kind: {evt.kind}</div>
<div>Author: {evt.pubkey.slice(0, 16)}...</div>
<div>Created: {new Date(evt.created_at * 1000).toLocaleString()}</div>
</div>
<div className="mt-1">
<div>Emoji: {evt.content}</div>
{eTag && <div className="text-[11px] opacity-70">#e: {eTag.slice(0, 16)}...</div>}
{rTag && <div className="text-[11px] opacity-70">#r: {rTag.length > 60 ? rTag.substring(0, 60) + '...' : rTag}</div>}
{pTag && <div className="text-[11px] opacity-70">#p: {pTag.slice(0, 16)}...</div>}
</div>
<div className="opacity-50 mt-1 text-[10px] break-all">ID: {evt.id}</div>
</div>
)
})}
</div>
</div>
)}
</div>
{/* Web of Trust Section */}
<div className="settings-section">
<h3 className="section-title">Web of Trust</h3>

View File

@@ -44,6 +44,12 @@ export const HighlightCitation: React.FC<HighlightCitationProps> = ({
try {
if (!highlight.eventReference) return
// Skip if it's a raw event ID (hex string without colons)
// Raw event IDs cannot be decoded to nadrs without additional context
if (!highlight.eventReference.includes(':') && !highlight.eventReference.startsWith('naddr')) {
return
}
// Convert eventReference to naddr if needed
let naddr: string
if (highlight.eventReference.includes(':')) {

View File

@@ -17,6 +17,7 @@ import { getNostrUrl } from '../config/nostrGateways'
import CompactButton from './CompactButton'
import { HighlightCitation } from './HighlightCitation'
import { useNavigate } from 'react-router-dom'
import NostrMentionLink from './NostrMentionLink'
// Helper to detect if a URL is an image
const isImageUrl = (url: string): boolean => {
@@ -29,99 +30,6 @@ const isImageUrl = (url: string): boolean => {
}
}
// Helper to render a nostr identifier
const renderNostrId = (nostrUri: string, index: number): React.ReactElement => {
try {
// Remove nostr: prefix
const identifier = nostrUri.replace(/^nostr:/, '')
const decoded = nip19.decode(identifier)
switch (decoded.type) {
case 'npub': {
const pubkey = decoded.data
return (
<a
key={index}
href={`/p/${nip19.npubEncode(pubkey)}`}
className="highlight-comment-link"
onClick={(e) => e.stopPropagation()}
>
@{pubkey.slice(0, 8)}...
</a>
)
}
case 'nprofile': {
const { pubkey } = decoded.data
const npub = nip19.npubEncode(pubkey)
return (
<a
key={index}
href={`/p/${npub}`}
className="highlight-comment-link"
onClick={(e) => e.stopPropagation()}
>
@{pubkey.slice(0, 8)}...
</a>
)
}
case 'naddr': {
const { kind, pubkey, identifier } = decoded.data
// Check if it's a blog post (kind:30023)
if (kind === 30023) {
const naddr = nip19.naddrEncode({ kind, pubkey, identifier })
return (
<a
key={index}
href={`/a/${naddr}`}
className="highlight-comment-link"
onClick={(e) => e.stopPropagation()}
>
{identifier || 'Article'}
</a>
)
}
// For other kinds, show shortened identifier
return (
<span key={index} className="highlight-comment-nostr-id">
nostr:{identifier.slice(0, 12)}...
</span>
)
}
case 'note': {
const eventId = decoded.data
return (
<span key={index} className="highlight-comment-nostr-id">
note:{eventId.slice(0, 12)}...
</span>
)
}
case 'nevent': {
const { id } = decoded.data
return (
<span key={index} className="highlight-comment-nostr-id">
event:{id.slice(0, 12)}...
</span>
)
}
default:
// Fallback for unrecognized types
return (
<span key={index} className="highlight-comment-nostr-id">
{identifier.slice(0, 20)}...
</span>
)
}
} catch (error) {
// If decoding fails, show shortened identifier
const identifier = nostrUri.replace(/^nostr:/, '')
return (
<span key={index} className="highlight-comment-nostr-id">
{identifier.slice(0, 20)}...
</span>
)
}
}
// Component to render comment with links, inline images, and nostr identifiers
const CommentContent: React.FC<{ text: string }> = ({ text }) => {
// Pattern to match both http(s) URLs and nostr: URIs
@@ -131,9 +39,15 @@ const CommentContent: React.FC<{ text: string }> = ({ text }) => {
return (
<>
{parts.map((part, index) => {
// Handle nostr: URIs
// Handle nostr: URIs - now with profile resolution
if (part.startsWith('nostr:')) {
return renderNostrId(part, index)
return (
<NostrMentionLink
key={index}
nostrUri={part}
onClick={(e) => e.stopPropagation()}
/>
)
}
// Handle http(s) URLs

View File

@@ -11,8 +11,8 @@ import { Highlight } from '../types/highlights'
import { HighlightItem } from './HighlightItem'
import { highlightsController } from '../services/highlightsController'
import { writingsController } from '../services/writingsController'
import { fetchAllReads, ReadItem } from '../services/readsService'
import { fetchLinks } from '../services/linksService'
import { ReadItem, readsController } from '../services/readsController'
import { BlogPostPreview } from '../services/exploreService'
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
import AuthorCard from './AuthorCard'
@@ -23,15 +23,15 @@ import { getCachedMeData, updateCachedHighlights } from '../services/meCache'
import { faBooks } from '../icons/customIcons'
import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator'
import { groupIndividualBookmarks, hasContent } from '../utils/bookmarkUtils'
import { groupIndividualBookmarks, hasContent, hasCreationDate } from '../utils/bookmarkUtils'
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters'
import { filterByReadingProgress } from '../utils/readingProgressUtils'
import { deriveReadsFromBookmarks } from '../utils/readsFromBookmarks'
import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks'
import { mergeReadItem } from '../utils/readItemMerge'
import { readingProgressController } from '../services/readingProgressController'
import { archiveController } from '../services/archiveController'
import { UserSettings } from '../services/settingsService'
interface MeProps {
relayPool: RelayPool
@@ -39,18 +39,20 @@ interface MeProps {
activeTab?: TabType
bookmarks: Bookmark[] // From centralized App.tsx state
bookmarksLoading?: boolean // From centralized App.tsx state (reserved for future use)
settings: UserSettings
}
type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
// Valid reading progress filters
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed', 'highlighted']
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed', 'highlighted', 'archive']
const Me: React.FC<MeProps> = ({
relayPool,
eventStore,
activeTab: propActiveTab,
bookmarks
bookmarks,
settings
}) => {
const activeAccount = Hooks.useActiveAccount()
const navigate = useNavigate()
@@ -61,7 +63,6 @@ const Me: React.FC<MeProps> = ({
const viewingPubkey = activeAccount?.pubkey
const [highlights, setHighlights] = useState<Highlight[]>([])
const [reads, setReads] = useState<ReadItem[]>([])
const [, setReadsMap] = useState<Map<string, ReadItem>>(new Map())
const [links, setLinks] = useState<ReadItem[]>([])
const [, setLinksMap] = useState<Map<string, ReadItem>>(new Map())
const [writings, setWritings] = useState<BlogPostPreview[]>([])
@@ -90,8 +91,10 @@ const Me: React.FC<MeProps> = ({
}
// Initialize reading progress filter from URL param
const initialFilter = urlFilter && VALID_FILTERS.includes(urlFilter as ReadingProgressFilterType)
? (urlFilter as ReadingProgressFilterType)
// Backward compat: map legacy 'emoji' route to 'archive'
const normalizedUrlFilter = urlFilter === 'emoji' ? 'archive' : urlFilter
const initialFilter = normalizedUrlFilter && VALID_FILTERS.includes(normalizedUrlFilter as ReadingProgressFilterType)
? (normalizedUrlFilter as ReadingProgressFilterType)
: 'all'
const [readingProgressFilter, setReadingProgressFilter] = useState<ReadingProgressFilterType>(initialFilter)
@@ -135,8 +138,9 @@ const Me: React.FC<MeProps> = ({
// Sync filter state with URL changes
useEffect(() => {
const filterFromUrl = urlFilter && VALID_FILTERS.includes(urlFilter as ReadingProgressFilterType)
? (urlFilter as ReadingProgressFilterType)
const normalized = urlFilter === 'emoji' ? 'archive' : urlFilter
const filterFromUrl = normalized && VALID_FILTERS.includes(normalized as ReadingProgressFilterType)
? (normalized as ReadingProgressFilterType)
: 'all'
setReadingProgressFilter(filterFromUrl)
}, [urlFilter])
@@ -150,10 +154,29 @@ const Me: React.FC<MeProps> = ({
} else {
navigate(`/me/reads/${filter}`, { replace: true })
}
} else if (activeTab === 'links') {
if (filter === 'all') {
navigate('/me/links', { replace: true })
} else {
navigate(`/me/links/${filter}`, { replace: true })
}
}
}
// Subscribe to reading progress controller
// Subscribe to reads controller
useEffect(() => {
// Get initial state immediately
setReads(readsController.getReads())
// Subscribe to updates
const unsubReads = readsController.onReads(setReads)
return () => {
unsubReads()
}
}, [])
// Subscribe to reading progress map for writings and links enrichment
useEffect(() => {
// Get initial state immediately
setReadingProgressMap(readingProgressController.getProgressMap())
@@ -165,6 +188,7 @@ const Me: React.FC<MeProps> = ({
unsubProgress()
}
}, [])
// Load reading progress data for writings tab
useEffect(() => {
@@ -232,34 +256,16 @@ const Me: React.FC<MeProps> = ({
try {
if (!hasBeenLoaded) setLoading(true)
// Derive reads from bookmarks immediately (bookmarks come from centralized loading in App.tsx)
const initialReads = deriveReadsFromBookmarks(bookmarks)
const initialMap = new Map(initialReads.map(item => [item.id, item]))
setReadsMap(initialMap)
setReads(initialReads)
// Use readsController to get reads with progressive hydration
await readsController.start({
relayPool,
eventStore,
pubkey: viewingPubkey
})
setLoadedTabs(prev => new Set(prev).add('reads'))
if (!hasBeenLoaded) setLoading(false)
// Background enrichment: merge reading progress and mark-as-read
// Only update items that are already in our map
fetchAllReads(relayPool, viewingPubkey, bookmarks, (item) => {
setReadsMap(prevMap => {
// Only update if item exists in our current map
if (!prevMap.has(item.id)) {
return prevMap
}
const newMap = new Map(prevMap)
const merged = mergeReadItem(newMap, item)
if (merged) {
// Update reads array after map is updated
setReads(Array.from(newMap.values()))
return newMap
}
return prevMap
})
}).catch(err => console.warn('Failed to enrich reads:', err))
} catch (err) {
console.error('Failed to load reads:', err)
if (!hasBeenLoaded) setLoading(false)
@@ -290,12 +296,13 @@ const Me: React.FC<MeProps> = ({
if (!prevMap.has(item.id)) return prevMap
const newMap = new Map(prevMap)
if (mergeReadItem(newMap, item)) {
// Update links array after map is updated
setLinks(Array.from(newMap.values()))
return newMap
if (item.type === 'article' && item.author) {
const progress = readingProgressMap.get(item.id)
if (progress !== undefined) {
newMap.set(item.id, { ...item, readingProgress: progress })
}
}
return prevMap
return newMap
})
}).catch(err => console.warn('Failed to enrich links:', err))
@@ -485,23 +492,14 @@ const Me: React.FC<MeProps> = ({
// Merge and flatten all individual bookmarks
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
.filter(hasContent)
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b))
// Apply bookmark filter
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, bookmarkFilter)
const groups = groupIndividualBookmarks(filteredBookmarks)
// Enrich reads and links with reading progress from controller
const readsWithProgress = reads.map(item => {
if (item.type === 'article' && item.author) {
const progress = readingProgressMap.get(item.id)
if (progress !== undefined) {
return { ...item, readingProgress: progress }
}
}
return item
})
// Enrich links with reading progress (reads already have progress from controller)
const linksWithProgress = links.map(item => {
if (item.url) {
const progress = readingProgressMap.get(item.url)
@@ -512,9 +510,81 @@ const Me: React.FC<MeProps> = ({
return item
})
// Apply reading progress filter
const filteredReads = filterByReadingProgress(readsWithProgress, readingProgressFilter, highlights)
const filteredLinks = filterByReadingProgress(linksWithProgress, readingProgressFilter, highlights)
// Apply reading progress filter with simple type separation to keep Views distinct and DRY
const filteredReads = filterByReadingProgress(
reads.filter(item => item.type === 'article'),
readingProgressFilter,
highlights
)
const filteredLinks = filterByReadingProgress(
linksWithProgress.filter(item => item.type === 'external'),
readingProgressFilter,
highlights
)
// Helper: build archive-only list from marked IDs and a base list
const buildArchiveOnly = (
baseItems: ReadItem[],
options: { kind: 'article' | 'external' }
): ReadItem[] => {
const allMarked = archiveController.getMarkedIds()
const relevantMarked = options.kind === 'article'
? allMarked.filter(id => id.startsWith('naddr1'))
: allMarked.filter(id => !id.startsWith('naddr1'))
const markedSet = new Set(relevantMarked)
const items: ReadItem[] = []
for (const item of baseItems) {
const key = options.kind === 'article' ? item.id : (item.url || item.id)
if (key && markedSet.has(key)) {
items.push({ ...item, markedAsRead: true })
}
}
for (const id of markedSet) {
const exists = items.find(i => (options.kind === 'article' ? i.id : (i.url || i.id)) === id)
if (!exists) {
items.push({
id,
source: 'marked-as-read',
type: options.kind,
url: options.kind === 'article' ? undefined : id,
markedAsRead: true,
readingTimestamp: Math.floor(Date.now() / 1000)
})
}
}
return items
}
// Archive-only lists: independent of reading progress
const archiveOnlyReads: ReadItem[] = readingProgressFilter === 'archive'
? buildArchiveOnly(reads, { kind: 'article' })
: []
const archiveOnlyLinks: ReadItem[] = readingProgressFilter === 'archive'
? buildArchiveOnly(linksWithProgress, { kind: 'external' })
: []
// Debug logs for archive filter issues
if (readingProgressFilter === 'archive') {
const ids = Array.from(new Set([
...archiveController.getMarkedIds(),
...readingProgressController.getMarkedAsReadIds()
]))
const readIds = new Set(reads.map(i => i.id))
const matches = ids.filter(id => readIds.has(id))
const nonMatches = ids.filter(id => !readIds.has(id)).slice(0, 5)
console.log('[archive][me] counts', {
reads: reads.length,
filteredReads: filteredReads.length,
links: links.length,
linksWithProgress: linksWithProgress.length,
filteredLinks: filteredLinks.length,
markedIds: ids.length,
sampleMarked: ids.slice(0, 3),
matches: matches.length,
nonMatches
})
}
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
groupingMode === 'flat'
? [{ key: 'all', title: `All Bookmarks (${filteredBookmarks.length})`, items: filteredBookmarks }]
@@ -652,21 +722,42 @@ const Me: React.FC<MeProps> = ({
selectedFilter={readingProgressFilter}
onFilterChange={handleReadingProgressFilterChange}
/>
{filteredReads.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No articles match this filter.
</div>
{readingProgressFilter === 'archive' ? (
archiveOnlyReads.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No articles in archive.
</div>
) : (
<div className="explore-grid">
{archiveOnlyReads
.filter(item => item.type === 'article')
.map((item) => (
<BlogPostCard
key={item.id}
post={convertReadItemToBlogPostPreview(item)}
href={getReadItemUrl(item)}
readingProgress={item.readingProgress}
/>
))}
</div>
)
) : (
<div className="explore-grid">
{filteredReads.map((item) => (
<BlogPostCard
key={item.id}
post={convertReadItemToBlogPostPreview(item)}
href={getReadItemUrl(item)}
readingProgress={item.readingProgress}
/>
))}
</div>
filteredReads.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No articles match this filter.
</div>
) : (
<div className="explore-grid">
{filteredReads.map((item) => (
<BlogPostCard
key={item.id}
post={convertReadItemToBlogPostPreview(item)}
href={getReadItemUrl(item)}
readingProgress={item.readingProgress}
/>
))}
</div>
)
)}
</>
)
@@ -699,21 +790,40 @@ const Me: React.FC<MeProps> = ({
selectedFilter={readingProgressFilter}
onFilterChange={handleReadingProgressFilterChange}
/>
{filteredLinks.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No links match this filter.
</div>
{readingProgressFilter === 'archive' ? (
archiveOnlyLinks.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No links in archive.
</div>
) : (
<div className="explore-grid">
{archiveOnlyLinks.map((item) => (
<BlogPostCard
key={item.id}
post={convertReadItemToBlogPostPreview(item)}
href={getReadItemUrl(item)}
readingProgress={item.readingProgress}
/>
))}
</div>
)
) : (
<div className="explore-grid">
{filteredLinks.map((item) => (
<BlogPostCard
key={item.id}
post={convertReadItemToBlogPostPreview(item)}
href={getReadItemUrl(item)}
readingProgress={item.readingProgress}
/>
))}
</div>
filteredLinks.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No links match this filter.
</div>
) : (
<div className="explore-grid">
{filteredLinks.map((item) => (
<BlogPostCard
key={item.id}
post={convertReadItemToBlogPostPreview(item)}
href={getReadItemUrl(item)}
readingProgress={item.readingProgress}
/>
))}
</div>
)
)}
</>
)

View File

@@ -0,0 +1,118 @@
import React from 'react'
import { nip19 } from 'nostr-tools'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
interface NostrMentionLinkProps {
nostrUri: string
onClick?: (e: React.MouseEvent) => void
className?: string
}
/**
* Component to render nostr mentions with resolved profile names
* Handles npub, nprofile, note, nevent, and naddr URIs
*/
const NostrMentionLink: React.FC<NostrMentionLinkProps> = ({
nostrUri,
onClick,
className = 'highlight-comment-link'
}) => {
try {
// Remove nostr: prefix
const identifier = nostrUri.replace(/^nostr:/, '')
const decoded = nip19.decode(identifier)
switch (decoded.type) {
case 'npub': {
const pubkey = decoded.data
// Fetch profile in the background
const profile = useEventModel(Models.ProfileModel, [pubkey])
const displayName = profile?.name || profile?.display_name || profile?.nip05 || `${pubkey.slice(0, 8)}...`
return (
<a
href={`/p/${nip19.npubEncode(pubkey)}`}
className={className}
onClick={onClick}
>
@{displayName}
</a>
)
}
case 'nprofile': {
const { pubkey } = decoded.data
// Fetch profile in the background
const profile = useEventModel(Models.ProfileModel, [pubkey])
const displayName = profile?.name || profile?.display_name || profile?.nip05 || `${pubkey.slice(0, 8)}...`
const npub = nip19.npubEncode(pubkey)
return (
<a
href={`/p/${npub}`}
className={className}
onClick={onClick}
>
@{displayName}
</a>
)
}
case 'naddr': {
const { kind, pubkey, identifier: addrIdentifier } = decoded.data
// Check if it's a blog post (kind:30023)
if (kind === 30023) {
const naddr = nip19.naddrEncode({ kind, pubkey, identifier: addrIdentifier })
return (
<a
href={`/a/${naddr}`}
className={className}
onClick={onClick}
>
{addrIdentifier || 'Article'}
</a>
)
}
// For other kinds, show shortened identifier
return (
<span className="highlight-comment-nostr-id">
nostr:{addrIdentifier.slice(0, 12)}...
</span>
)
}
case 'note': {
const eventId = decoded.data
return (
<span className="highlight-comment-nostr-id">
note:{eventId.slice(0, 12)}...
</span>
)
}
case 'nevent': {
const { id } = decoded.data
return (
<span className="highlight-comment-nostr-id">
event:{id.slice(0, 12)}...
</span>
)
}
default:
// Fallback for unrecognized types
return (
<span className="highlight-comment-nostr-id">
{identifier.slice(0, 20)}...
</span>
)
}
} catch (error) {
// If decoding fails, show shortened identifier
const identifier = nostrUri.replace(/^nostr:/, '')
return (
<span className="highlight-comment-nostr-id">
{identifier.slice(0, 20)}...
</span>
)
}
}
export default NostrMentionLink

View File

@@ -1,9 +1,10 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBookOpen, faCheckCircle, faAsterisk, faHighlighter } from '@fortawesome/free-solid-svg-icons'
import { faBooks } from '../icons/customIcons'
import { faEnvelope, faEnvelopeOpen } from '@fortawesome/free-regular-svg-icons'
export type ReadingProgressFilterType = 'all' | 'unopened' | 'started' | 'reading' | 'completed' | 'highlighted'
export type ReadingProgressFilterType = 'all' | 'unopened' | 'started' | 'reading' | 'completed' | 'highlighted' | 'archive'
interface ReadingProgressFiltersProps {
selectedFilter: ReadingProgressFilterType
@@ -13,11 +14,13 @@ interface ReadingProgressFiltersProps {
const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selectedFilter, onFilterChange }) => {
const filters = [
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
{ type: 'highlighted' as const, icon: faHighlighter, label: 'Highlighted' },
{ type: 'unopened' as const, icon: faEnvelope, label: 'Unopened' },
{ type: 'started' as const, icon: faEnvelopeOpen, label: 'Started' },
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
{ type: 'highlighted' as const, icon: faHighlighter, label: 'Highlighted' },
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' }
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' },
// Archive-marked items (previously emoji-marked)
{ type: 'archive' as const, icon: faBooks, label: 'Archive' }
]
return (
@@ -31,6 +34,8 @@ const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selecte
activeStyle = { color: '#10b981' } // green
} else if (filter.type === 'highlighted') {
activeStyle = { color: '#fde047' } // yellow
} else if (filter.type === 'archive') {
activeStyle = { color: '#60a5fa' } // blue accent
}
}

View File

@@ -0,0 +1,77 @@
import React from 'react'
import NostrMentionLink from './NostrMentionLink'
interface RichContentProps {
content: string
className?: string
}
/**
* Component to render text content with:
* - Clickable links
* - Resolved nostr mentions (npub, nprofile, note, nevent, naddr)
* - Plain text
*
* Handles both nostr:npub1... and plain npub1... formats
*/
const RichContent: React.FC<RichContentProps> = ({
content,
className = 'bookmark-content'
}) => {
// Pattern to match:
// 1. nostr: URIs (nostr:npub1..., nostr:note1..., etc.)
// 2. Plain nostr identifiers (npub1..., nprofile1..., note1..., etc.)
// 3. http(s) URLs
const pattern = /(nostr:[a-z0-9]+|npub1[a-z0-9]+|nprofile1[a-z0-9]+|note1[a-z0-9]+|nevent1[a-z0-9]+|naddr1[a-z0-9]+|https?:\/\/[^\s]+)/gi
const parts = content.split(pattern)
return (
<div className={className}>
{parts.map((part, index) => {
// Handle nostr: URIs
if (part.startsWith('nostr:')) {
return (
<NostrMentionLink
key={index}
nostrUri={part}
/>
)
}
// Handle plain nostr identifiers (add nostr: prefix)
if (
part.match(/^(npub1|nprofile1|note1|nevent1|naddr1)[a-z0-9]+$/i)
) {
return (
<NostrMentionLink
key={index}
nostrUri={`nostr:${part}`}
/>
)
}
// Handle http(s) URLs
if (part.match(/^https?:\/\//)) {
return (
<a
key={index}
href={part}
className="nostr-link"
target="_blank"
rel="noopener noreferrer"
>
{part}
</a>
)
}
// Plain text
return <React.Fragment key={index}>{part}</React.Fragment>
})}
</div>
)
}
export default RichContent

View File

@@ -41,7 +41,7 @@ const DEFAULT_SETTINGS: UserSettings = {
paragraphAlignment: 'justify',
syncReadingPosition: true,
autoMarkAsReadOnCompletion: false,
hideBookmarksWithoutCreationDate: false,
hideBookmarksWithoutCreationDate: true,
}
interface SettingsProps {

View File

@@ -127,7 +127,7 @@ const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ setting
onChange={(e) => onUpdate({ autoMarkAsReadOnCompletion: e.target.checked })}
className="setting-checkbox"
/>
<span>Automatically mark as read at 100%</span>
<span>Automatically move to archive at 100%</span>
</label>
</div>

View File

@@ -14,7 +14,6 @@ export const RELAYS = [
'wss://relay.dergigi.com',
'wss://wot.dergigi.com',
'wss://relay.snort.social',
'wss://relay.current.fyi',
'wss://nostr-pub.wellorder.net',
'wss://purplepag.es',
'wss://relay.primal.net',

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react'
import { useEffect, Dispatch, SetStateAction } from 'react'
import { RelayPool } from 'applesauce-relay'
import { fetchArticleByNaddr } from '../services/articleService'
import { fetchHighlightsForArticle } from '../services/highlightService'
@@ -6,6 +6,7 @@ import { ReadableContent } from '../services/readerService'
import { Highlight } from '../types/highlights'
import { NostrEvent } from 'nostr-tools'
import { UserSettings } from '../services/settingsService'
import { useMountedState } from './useMountedState'
interface UseArticleLoaderProps {
naddr: string | undefined
@@ -14,7 +15,7 @@ interface UseArticleLoaderProps {
setReaderContent: (content: ReadableContent | undefined) => void
setReaderLoading: (loading: boolean) => void
setIsCollapsed: (collapsed: boolean) => void
setHighlights: (highlights: Highlight[]) => void
setHighlights: Dispatch<SetStateAction<Highlight[]>>
setHighlightsLoading: (loading: boolean) => void
setCurrentArticleCoordinate: (coord: string | undefined) => void
setCurrentArticleEventId: (id: string | undefined) => void
@@ -36,18 +37,24 @@ export function useArticleLoader({
setCurrentArticle,
settings
}: UseArticleLoaderProps) {
const isMounted = useMountedState()
useEffect(() => {
if (!relayPool || !naddr) return
const loadArticle = async () => {
if (!isMounted()) return
setReaderLoading(true)
setReaderContent(undefined)
setSelectedUrl(`nostr:${naddr}`)
setIsCollapsed(true)
// Keep highlights panel collapsed by default - only open on user interaction
try {
const article = await fetchArticleByNaddr(relayPool, naddr, false, settings)
if (!isMounted()) return
setReaderContent({
title: article.title,
markdown: article.markdown,
@@ -63,49 +70,52 @@ export function useArticleLoader({
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(article.event.id)
setCurrentArticle?.(article.event)
// Set reader loading to false immediately after article content is ready
// Don't wait for highlights to finish loading
setReaderLoading(false)
// Fetch highlights asynchronously without blocking article display
// Stream them as they arrive for instant rendering
try {
if (!isMounted()) return
setHighlightsLoading(true)
setHighlights([]) // Clear old highlights
const highlightsMap = new Map<string, Highlight>()
setHighlights([])
await fetchHighlightsForArticle(
relayPool,
articleCoordinate,
article.event.id,
(highlight) => {
// Deduplicate highlights by ID as they arrive
if (!highlightsMap.has(highlight.id)) {
highlightsMap.set(highlight.id, highlight)
const highlightsList = Array.from(highlightsMap.values())
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
}
if (!isMounted()) return
setHighlights((prev: Highlight[]) => {
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
const next = [highlight, ...prev]
return next.sort((a, b) => b.created_at - a.created_at)
})
},
settings
)
} catch (err) {
console.error('Failed to fetch highlights:', err)
} finally {
setHighlightsLoading(false)
if (isMounted()) {
setHighlightsLoading(false)
}
}
} catch (err) {
console.error('Failed to load article:', err)
setReaderContent({
title: 'Error Loading Article',
html: `<p>Failed to load article: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
url: `nostr:${naddr}`
})
setReaderLoading(false)
if (isMounted()) {
setReaderContent({
title: 'Error Loading Article',
html: `<p>Failed to load article: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
url: `nostr:${naddr}`
})
setReaderLoading(false)
}
}
}
loadArticle()
}, [naddr, relayPool, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId, setCurrentArticle, settings])
// Intentionally excluding setter functions from dependencies to prevent race conditions
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [naddr, relayPool, settings, isMounted])
}

View File

@@ -7,6 +7,7 @@ import { Highlight } from '../types/highlights'
import { useStoreTimeline } from './useStoreTimeline'
import { eventToHighlight } from '../services/highlightEventProcessor'
import { KINDS } from '../config/kinds'
import { useMountedState } from './useMountedState'
// Helper to extract filename from URL
function getFilenameFromUrl(url: string): string {
@@ -48,6 +49,8 @@ export function useExternalUrlLoader({
setCurrentArticleCoordinate,
setCurrentArticleEventId
}: UseExternalUrlLoaderProps) {
const isMounted = useMountedState()
// Load cached URL-specific highlights from event store
const urlFilter = useMemo(() => {
if (!url) return null
@@ -61,79 +64,108 @@ export function useExternalUrlLoader({
[url]
)
// Load content and start streaming highlights when URL changes
useEffect(() => {
if (!relayPool || !url) return
const loadExternalUrl = async () => {
if (!isMounted()) return
setReaderLoading(true)
setReaderContent(undefined)
setSelectedUrl(url)
setIsCollapsed(true)
// Clear article-specific state
setCurrentArticleCoordinate(undefined)
setCurrentArticleEventId(undefined)
try {
const content = await fetchReadableContent(url)
if (!isMounted()) return
setReaderContent(content)
// Set reader loading to false immediately after content is ready
setReaderLoading(false)
// Fetch highlights for this URL asynchronously
try {
if (!isMounted()) return
setHighlightsLoading(true)
// Seed with cached highlights first
if (cachedUrlHighlights.length > 0) {
setHighlights(cachedUrlHighlights.sort((a, b) => b.created_at - a.created_at))
setHighlights((prev) => {
const seen = new Set<string>(cachedUrlHighlights.map(h => h.id))
const localOnly = prev.filter(h => !seen.has(h.id))
const next = [...cachedUrlHighlights, ...localOnly]
return next.sort((a, b) => b.created_at - a.created_at)
})
} else {
setHighlights([])
}
// Check if fetchHighlightsForUrl exists, otherwise skip
if (typeof fetchHighlightsForUrl === 'function') {
const seen = new Set<string>()
// Seed with cached IDs
cachedUrlHighlights.forEach(h => seen.add(h.id))
await fetchHighlightsForUrl(
relayPool,
url,
(highlight) => {
if (!isMounted()) return
if (seen.has(highlight.id)) return
seen.add(highlight.id)
setHighlights((prev) => {
if (prev.some(h => h.id === highlight.id)) return prev
const next = [...prev, highlight]
const next = [highlight, ...prev]
return next.sort((a, b) => b.created_at - a.created_at)
})
},
undefined, // settings
false, // force
undefined,
false,
eventStore || undefined
)
}
} catch (err) {
console.error('Failed to fetch highlights:', err)
} finally {
setHighlightsLoading(false)
if (isMounted()) {
setHighlightsLoading(false)
}
}
} catch (err) {
console.error('Failed to load external URL:', err)
// For videos and other media files, use the filename as the title
const filename = getFilenameFromUrl(url)
setReaderContent({
title: filename,
html: `<p>Failed to load content: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
url
})
setReaderLoading(false)
if (isMounted()) {
const filename = getFilenameFromUrl(url)
setReaderContent({
title: filename,
html: `<p>Failed to load content: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
url
})
setReaderLoading(false)
}
}
}
loadExternalUrl()
}, [url, relayPool, eventStore, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId, cachedUrlHighlights])
// Intentionally excluding setter functions from dependencies to prevent race conditions
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url, relayPool, eventStore, cachedUrlHighlights, isMounted])
// Keep UI highlights synced with cached store updates without reloading content
useEffect(() => {
if (!url) return
if (cachedUrlHighlights.length === 0) return
setHighlights((prev) => {
const seen = new Set<string>(prev.map(h => h.id))
const additions = cachedUrlHighlights.filter(h => !seen.has(h.id))
if (additions.length === 0) return prev
const next = [...additions, ...prev]
return next.sort((a, b) => b.created_at - a.created_at)
})
// setHighlights is intentionally excluded from dependencies - it's stable
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cachedUrlHighlights, url])
}

View File

@@ -0,0 +1,28 @@
import { useRef, useEffect, useCallback } from 'react'
/**
* Hook to track if component is mounted and prevent state updates after unmount.
* Returns a function to check if still mounted.
*
* @example
* const isMounted = useMountedState()
*
* async function loadData() {
* const data = await fetch(...)
* if (isMounted()) {
* setState(data)
* }
* }
*/
export function useMountedState(): () => boolean {
const mountedRef = useRef(true)
useEffect(() => {
return () => {
mountedRef.current = false
}
}, [])
return useCallback(() => mountedRef.current, [])
}

View File

@@ -22,6 +22,7 @@ export const useReadingPosition = ({
completionHoldMs = 2000
}: UseReadingPositionOptions = {}) => {
const [position, setPosition] = useState(0)
const positionRef = useRef(0)
const [isReadingComplete, setIsReadingComplete] = useState(false)
const hasTriggeredComplete = useRef(false)
const lastSavedPosition = useRef(0)
@@ -104,6 +105,7 @@ export const useReadingPosition = ({
}
setPosition(clampedProgress)
positionRef.current = clampedProgress
onPositionChange?.(clampedProgress)
// Schedule auto-save if sync is enabled
@@ -115,7 +117,7 @@ export const useReadingPosition = ({
if (clampedProgress === 1) {
if (!completionTimerRef.current) {
completionTimerRef.current = setTimeout(() => {
if (!hasTriggeredComplete.current && position === 1) {
if (!hasTriggeredComplete.current && positionRef.current === 1) {
setIsReadingComplete(true)
hasTriggeredComplete.current = true
onReadingComplete?.()

View File

@@ -0,0 +1,212 @@
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { NostrEvent } from 'nostr-tools'
import { queryEvents } from './dataFetch'
import { KINDS } from '../config/kinds'
import { RELAYS } from '../config/relays'
import { ARCHIVE_EMOJI } from './reactionService'
import { nip19 } from 'nostr-tools'
type MarkedChangeCallback = (markedIds: Set<string>) => void
class ArchiveController {
private markedIds: Set<string> = new Set()
private lastLoadedPubkey: string | null = null
private listeners: MarkedChangeCallback[] = []
private generation = 0
private timelineSubscription: { unsubscribe: () => void } | null = null
private pendingEventIds: Set<string> = new Set()
onMarked(cb: MarkedChangeCallback): () => void {
this.listeners.push(cb)
// Emit current state immediately to new subscribers
cb(new Set(this.markedIds))
return () => {
this.listeners = this.listeners.filter(l => l !== cb)
}
}
private emit(): void {
const snapshot = new Set(this.markedIds)
this.listeners.forEach(cb => cb(snapshot))
}
mark(id: string): void {
if (!this.markedIds.has(id)) {
this.markedIds.add(id)
this.emit()
console.log('[archive] mark() added', id.slice(0, 48))
}
}
unmark(id: string): void {
if (this.markedIds.delete(id)) {
this.emit()
console.log('[archive] unmark() removed', id.slice(0, 48))
}
}
isMarked(id: string): boolean {
return this.markedIds.has(id)
}
getMarkedIds(): string[] {
return Array.from(this.markedIds)
}
isLoadedFor(pubkey: string): boolean {
return this.lastLoadedPubkey === pubkey
}
reset(): void {
this.generation++
if (this.timelineSubscription) {
try { this.timelineSubscription.unsubscribe() } catch (e) { console.warn('[archive] timeline unsub error', e) }
this.timelineSubscription = null
}
this.markedIds = new Set()
this.pendingEventIds = new Set()
this.lastLoadedPubkey = null
this.emit()
}
async start(options: {
relayPool: RelayPool
eventStore: IEventStore
pubkey: string
force?: boolean
}): Promise<void> {
const { relayPool, eventStore, pubkey, force = false } = options
const startGen = this.generation
if (!force && this.isLoadedFor(pubkey)) {
console.log('[archive] start() skipped - already loaded for pubkey')
return
}
// Mark as loaded immediately (fetch runs non-blocking)
this.lastLoadedPubkey = pubkey
console.log('[archive] start() begin for pubkey:', pubkey.slice(0, 12), '...')
// Handlers for streaming queries
const handleUrlReaction = (evt: NostrEvent) => {
if (evt.content !== ARCHIVE_EMOJI) return
const rTag = evt.tags.find(t => t[0] === 'r')?.[1]
if (!rTag) return
this.markedIds.add(rTag)
this.emit()
console.log('[archive] mark url:', rTag)
}
const handleEventReaction = (evt: NostrEvent) => {
if (evt.content !== ARCHIVE_EMOJI) return
// Direct coordinate tag ('a') - can be mapped immediately
const aTag = evt.tags.find(t => t[0] === 'a')?.[1]
if (aTag) {
try {
const [kindStr, pubkey, identifier] = aTag.split(':')
const kind = Number(kindStr)
if (kind === KINDS.BlogPost && pubkey && identifier) {
const naddr = nip19.naddrEncode({ kind, pubkey, identifier })
this.markedIds.add(naddr)
this.emit()
console.log('[archive] mark naddr via a-tag:', naddr.slice(0, 24), '...')
return
}
} catch { /* ignore malformed a-tag */ }
}
const eTag = evt.tags.find(t => t[0] === 'e')?.[1]
if (!eTag) return
this.pendingEventIds.add(eTag)
console.log('[archive] pending event id:', eTag)
}
try {
// Stream kind:17 and kind:7 in parallel
const [kind17, kind7] = await Promise.all([
queryEvents(relayPool, { kinds: [17], authors: [pubkey] }, { relayUrls: RELAYS, onEvent: handleUrlReaction }),
queryEvents(relayPool, { kinds: [7], authors: [pubkey] }, { relayUrls: RELAYS, onEvent: handleEventReaction })
])
if (startGen !== this.generation) return
// Include EOSE events
kind17.forEach(handleUrlReaction)
kind7.forEach(handleEventReaction)
console.log('[archive] EOSE sizes kind17:', kind17.length, 'kind7:', kind7.length, 'pendingEventIds:', this.pendingEventIds.size)
if (this.pendingEventIds.size > 0) {
// Fetch referenced articles (kind:30023) and map event IDs to naddr
const ids = Array.from(this.pendingEventIds)
const articleEvents = await queryEvents(relayPool, { kinds: [KINDS.BlogPost], ids }, { relayUrls: RELAYS })
console.log('[archive] fetched articles for mapping:', articleEvents.length)
for (const article of articleEvents) {
const dTag = article.tags.find(t => t[0] === 'd')?.[1]
if (!dTag) continue
try {
const naddr = nip19.naddrEncode({ kind: KINDS.BlogPost, pubkey: article.pubkey, identifier: dTag })
this.markedIds.add(naddr)
console.log('[archive] mark naddr:', naddr.slice(0, 24), '...')
} catch {
// skip invalid
}
}
this.emit()
}
console.log('[archive] total marked ids:', this.markedIds.size)
// Try immediate mapping via eventStore for any still-pending e-ids
if (this.pendingEventIds.size > 0) {
const stillPending = new Set<string>()
for (const eId of this.pendingEventIds) {
try {
const store = eventStore as unknown as { getEvent?: (id: string) => NostrEvent | undefined }
const evt: NostrEvent | undefined = typeof store.getEvent === 'function' ? store.getEvent(eId) : undefined
if (evt && evt.kind === KINDS.BlogPost) {
const dTag = evt.tags.find(t => t[0] === 'd')?.[1]
if (dTag) {
const naddr = nip19.naddrEncode({ kind: KINDS.BlogPost, pubkey: evt.pubkey, identifier: dTag })
this.markedIds.add(naddr)
console.log('[archive] map via eventStore naddr:', naddr.slice(0, 24), '...')
}
} else {
stillPending.add(eId)
}
} catch (e) { stillPending.add(eId) }
}
this.pendingEventIds = stillPending
if (stillPending.size > 0) {
// Subscribe to future 30023 arrivals to finalize mapping
if (this.timelineSubscription) {
try { this.timelineSubscription.unsubscribe() } catch (e) { console.warn('[archive] timeline unsub error', e) }
this.timelineSubscription = null
}
const sub$ = eventStore.timeline({ kinds: [KINDS.BlogPost] })
const genAtSub = this.generation
this.timelineSubscription = sub$.subscribe((events: NostrEvent[]) => {
if (genAtSub !== this.generation) return
for (const evt of events) {
if (!this.pendingEventIds.has(evt.id)) continue
const dTag = evt.tags.find(t => t[0] === 'd')?.[1]
if (!dTag) continue
try {
const naddr = nip19.naddrEncode({ kind: KINDS.BlogPost, pubkey: evt.pubkey, identifier: dTag })
this.markedIds.add(naddr)
this.pendingEventIds.delete(evt.id)
console.log('[archive] map via timeline naddr:', naddr.slice(0, 24), '...')
this.emit()
} catch (e) { console.warn('[archive] map via timeline encode error', e) }
}
})
}
}
} catch (err) {
// Non-blocking fetch; ignore errors here
console.warn('[archive] start() error:', err)
}
}
}
export const archiveController = new ArchiveController()

View File

@@ -62,48 +62,53 @@ export { dedupeNip51Events } from './bookmarkEvents'
export const processApplesauceBookmarks = (
bookmarks: unknown,
activeAccount: ActiveAccount,
isPrivate: boolean
isPrivate: boolean,
parentCreatedAt?: number
): IndividualBookmark[] => {
if (!bookmarks) return []
console.log('[BOOKMARK_TS] processApplesauceBookmarks called with parentCreatedAt:', parentCreatedAt, 'isPrivate:', isPrivate)
if (typeof bookmarks === 'object' && bookmarks !== null && !Array.isArray(bookmarks)) {
const applesauceBookmarks = bookmarks as ApplesauceBookmarks
const allItems: IndividualBookmark[] = []
// Process notes (EventPointer[])
if (applesauceBookmarks.notes) {
console.log('[BOOKMARK_TS] Processing', applesauceBookmarks.notes.length, 'notes with timestamp:', parentCreatedAt || 0)
applesauceBookmarks.notes.forEach((note: EventPointer) => {
allItems.push({
id: note.id,
content: '',
created_at: Math.floor(Date.now() / 1000),
created_at: parentCreatedAt || 0,
pubkey: note.author || activeAccount.pubkey,
kind: 1, // Short note kind
tags: [],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: Math.floor(Date.now() / 1000)
added_at: parentCreatedAt || 0
})
})
}
// Process articles (AddressPointer[])
if (applesauceBookmarks.articles) {
console.log('[BOOKMARK_TS] Processing', applesauceBookmarks.articles.length, 'articles with timestamp:', parentCreatedAt || 0)
applesauceBookmarks.articles.forEach((article: AddressPointer) => {
// Convert AddressPointer to coordinate format: kind:pubkey:identifier
const coordinate = `${article.kind}:${article.pubkey}:${article.identifier || ''}`
allItems.push({
id: coordinate,
content: '',
created_at: Math.floor(Date.now() / 1000),
created_at: parentCreatedAt || 0,
pubkey: article.pubkey,
kind: article.kind, // Usually 30023 for long-form articles
tags: [],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: Math.floor(Date.now() / 1000)
added_at: parentCreatedAt || 0
})
})
}
@@ -114,32 +119,33 @@ export const processApplesauceBookmarks = (
allItems.push({
id: `hashtag-${hashtag}`,
content: `#${hashtag}`,
created_at: Math.floor(Date.now() / 1000),
created_at: parentCreatedAt || 0,
pubkey: activeAccount.pubkey,
kind: 1,
tags: [['t', hashtag]],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: Math.floor(Date.now() / 1000)
added_at: parentCreatedAt || 0
})
})
}
// Process URLs (string[])
if (applesauceBookmarks.urls) {
console.log('[BOOKMARK_TS] Processing', applesauceBookmarks.urls.length, 'URLs with timestamp:', parentCreatedAt || 0)
applesauceBookmarks.urls.forEach((url: string) => {
allItems.push({
id: `url-${url}`,
content: url,
created_at: Math.floor(Date.now() / 1000),
created_at: parentCreatedAt || 0,
pubkey: activeAccount.pubkey,
kind: 1,
tags: [['r', url]],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: Math.floor(Date.now() / 1000)
added_at: parentCreatedAt || 0
})
})
}
@@ -153,14 +159,14 @@ export const processApplesauceBookmarks = (
.map((bookmark: BookmarkData) => ({
id: bookmark.id!,
content: bookmark.content || '',
created_at: bookmark.created_at || Math.floor(Date.now() / 1000),
created_at: bookmark.created_at || parentCreatedAt || 0,
pubkey: activeAccount.pubkey,
kind: bookmark.kind || 30001,
tags: bookmark.tags || [],
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
type: 'event' as const,
isPrivate,
added_at: bookmark.created_at || Math.floor(Date.now() / 1000)
added_at: bookmark.created_at || parentCreatedAt || 0
}))
}
@@ -169,29 +175,36 @@ export function hydrateItems(
items: IndividualBookmark[],
idToEvent: Map<string, NostrEvent>
): IndividualBookmark[] {
return items.map(item => {
const ev = idToEvent.get(item.id)
if (!ev) return item
// For long-form articles (kind:30023), use the article title as content
let content = ev.content || item.content || ''
if (ev.kind === 30023) {
const articleTitle = getArticleTitle(ev)
if (articleTitle) {
content = articleTitle
return items
.map(item => {
const ev = idToEvent.get(item.id)
if (!ev) return item
// For long-form articles (kind:30023), use the article title as content
let content = ev.content || item.content || ''
if (ev.kind === 30023) {
const articleTitle = getArticleTitle(ev)
if (articleTitle) {
content = articleTitle
}
}
}
return {
...item,
pubkey: ev.pubkey || item.pubkey,
content,
created_at: ev.created_at || item.created_at,
kind: ev.kind || item.kind,
tags: ev.tags || item.tags,
parsedContent: ev.content ? (getParsedContent(content) as ParsedContent) : item.parsedContent
}
})
return {
...item,
pubkey: ev.pubkey || item.pubkey,
content,
created_at: ev.created_at || item.created_at,
kind: ev.kind || item.kind,
tags: ev.tags || item.tags,
parsedContent: ev.content ? (getParsedContent(content) as ParsedContent) : item.parsedContent
}
})
.filter(item => {
// Filter out bookmark list events (they're containers, not content)
const isBookmarkListEvent = item.kind === 10003 || item.kind === 30003 || item.kind === 30001
console.log('[BOOKMARK_TS] After hydration - id:', item.id, 'kind:', item.kind, 'isBookmarkListEvent:', isBookmarkListEvent, 'content:', item.content?.substring(0, 50))
return !isBookmarkListEvent
})
}
// Note: event decryption/collection lives in `bookmarkProcessing.ts`

View File

@@ -64,7 +64,7 @@ async function decryptEvent(
const hiddenTags = JSON.parse(decryptedContent) as string[][]
const manualPrivate = Helpers.parseBookmarkTags(hiddenTags)
privateItems.push(
...processApplesauceBookmarks(manualPrivate, activeAccount, true).map(i => ({
...processApplesauceBookmarks(manualPrivate, activeAccount, true, evt.created_at).map(i => ({
...i,
sourceKind: evt.kind,
setName: dTag,
@@ -84,7 +84,7 @@ async function decryptEvent(
const priv = Helpers.getHiddenBookmarks(evt)
if (priv) {
privateItems.push(
...processApplesauceBookmarks(priv, activeAccount, true).map(i => ({
...processApplesauceBookmarks(priv, activeAccount, true, evt.created_at).map(i => ({
...i,
sourceKind: evt.kind,
setName: dTag,
@@ -121,6 +121,7 @@ export async function collectBookmarksFromEvents(
const decryptJobs: Array<{ evt: NostrEvent; metadata: { dTag?: string; setTitle?: string; setDescription?: string; setImage?: string } }> = []
for (const evt of bookmarkListEvents) {
console.log('[BOOKMARK_TS] Processing bookmark event', evt.id, 'kind:', evt.kind, 'created_at:', evt.created_at)
newestCreatedAt = Math.max(newestCreatedAt, evt.created_at || 0)
if (!latestContent && evt.content && !Helpers.hasHiddenContent(evt)) latestContent = evt.content
if (Array.isArray(evt.tags)) allTags = allTags.concat(evt.tags)
@@ -155,7 +156,7 @@ export async function collectBookmarksFromEvents(
const pub = Helpers.getPublicBookmarks(evt)
publicItemsAll.push(
...processApplesauceBookmarks(pub, activeAccount, false).map(i => ({
...processApplesauceBookmarks(pub, activeAccount, false, evt.created_at).map(i => ({
...i,
sourceKind: evt.kind,
setName: dTag,
@@ -181,7 +182,7 @@ export async function collectBookmarksFromEvents(
const priv = Helpers.getHiddenBookmarks(evt)
if (priv) {
publicItemsAll.push(
...processApplesauceBookmarks(priv, activeAccount, true).map(i => ({
...processApplesauceBookmarks(priv, activeAccount, true, evt.created_at).map(i => ({
...i,
sourceKind: evt.kind,
setName: dTag,

View File

@@ -3,7 +3,7 @@ import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { RELAYS } from '../config/relays'
import { KINDS } from '../config/kinds'
import { MARK_AS_READ_EMOJI } from './reactionService'
import { ARCHIVE_EMOJI } from './reactionService'
import { BlogPostPreview } from './exploreService'
import { queryEvents } from './dataFetch'
@@ -38,7 +38,7 @@ export async function fetchReadArticles(
// Process kind:7 reactions (nostr-native articles)
for (const event of kind7Events) {
if (event.content === MARK_AS_READ_EMOJI) {
if (event.content === ARCHIVE_EMOJI) {
const eTag = event.tags.find((t) => t[0] === 'e')
const pTag = event.tags.find((t) => t[0] === 'p')
const kTag = event.tags.find((t) => t[0] === 'k')
@@ -58,7 +58,7 @@ export async function fetchReadArticles(
// Process kind:17 reactions (external URLs)
for (const event of kind17Events) {
if (event.content === MARK_AS_READ_EMOJI) {
if (event.content === ARCHIVE_EMOJI) {
const rTag = event.tags.find((t) => t[0] === 'r')
if (rTag && rTag[1]) {

View File

@@ -1,13 +1,13 @@
import { EventFactory } from 'applesauce-factory'
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
import { RELAYS } from '../config/relays'
import { EventFactory } from 'applesauce-factory'
const MARK_AS_READ_EMOJI = '📚'
const ARCHIVE_EMOJI = '📚'
export { MARK_AS_READ_EMOJI }
export { ARCHIVE_EMOJI }
/**
* Creates a kind:7 reaction to a nostr event (for nostr-native articles)
@@ -23,7 +23,8 @@ export async function createEventReaction(
eventAuthor: string,
eventKind: number,
account: IAccount,
relayPool: RelayPool
relayPool: RelayPool,
options?: { aCoord?: string }
): Promise<NostrEvent> {
const factory = new EventFactory({ signer: account })
@@ -32,10 +33,14 @@ export async function createEventReaction(
['p', eventAuthor],
['k', eventKind.toString()]
]
if (options?.aCoord) {
tags.push(['a', options.aCoord])
console.log('[archive] createEventReaction add a-tag:', options.aCoord)
}
const draft = await factory.create(async () => ({
kind: 7, // Reaction
content: MARK_AS_READ_EMOJI,
content: ARCHIVE_EMOJI,
tags,
created_at: Math.floor(Date.now() / 1000)
}))
@@ -85,7 +90,7 @@ export async function createWebsiteReaction(
const draft = await factory.create(async () => ({
kind: 17, // Reaction to a website
content: MARK_AS_READ_EMOJI,
content: ARCHIVE_EMOJI,
tags,
created_at: Math.floor(Date.now() / 1000)
}))
@@ -100,6 +105,27 @@ export async function createWebsiteReaction(
return signed
}
/**
* Sends a deletion request (NIP-09) for a reaction event to effectively un-archive.
* The caller must know the reaction event id to delete.
*/
export async function deleteReaction(
reactionEventId: string,
account: IAccount,
relayPool: RelayPool
): Promise<NostrEvent> {
const factory = new EventFactory({ signer: account })
const draft = await factory.create(async () => ({
kind: 5, // Deletion per NIP-09
content: 'unarchive',
tags: [['e', reactionEventId]],
created_at: Math.floor(Date.now() / 1000)
}))
const signed = await factory.sign(draft)
await relayPool.publish(RELAYS, signed)
return signed
}
/**
* Checks if the user has already marked a nostr event as read
* @param eventId The ID of the event to check
@@ -130,8 +156,8 @@ export async function hasMarkedEventAsRead(
const events: NostrEvent[] = await lastValueFrom(events$)
// Check if any reaction has our mark-as-read emoji
const hasReadReaction = events.some((event: NostrEvent) => event.content === MARK_AS_READ_EMOJI)
// Check if any reaction has our archive emoji
const hasReadReaction = events.some((event: NostrEvent) => event.content === ARCHIVE_EMOJI)
return hasReadReaction
} catch (error) {
@@ -183,8 +209,8 @@ export async function hasMarkedWebsiteAsRead(
const events: NostrEvent[] = await lastValueFrom(events$)
// Check if any reaction has our mark-as-read emoji
const hasReadReaction = events.some((event: NostrEvent) => event.content === MARK_AS_READ_EMOJI)
// Check if any reaction has our archive emoji
const hasReadReaction = events.some((event: NostrEvent) => event.content === ARCHIVE_EMOJI)
return hasReadReaction
} catch (error) {

View File

@@ -1,11 +1,15 @@
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { Filter, NostrEvent } from 'nostr-tools'
import { NostrEvent } from 'nostr-tools'
import { queryEvents } from './dataFetch'
import { KINDS } from '../config/kinds'
import { RELAYS } from '../config/relays'
import { processReadingProgress } from './readingDataProcessor'
import { ReadItem } from './readsService'
import { ARCHIVE_EMOJI } from './reactionService'
import { nip19 } from 'nostr-tools'
console.log('[readingProgress] Module loaded')
type ProgressMapCallback = (progressMap: Map<string, number>) => void
type LoadingCallback = (loading: boolean) => void
@@ -20,11 +24,14 @@ const PROGRESS_CACHE_KEY = 'reading_progress_cache_v1'
class ReadingProgressController {
private progressListeners: ProgressMapCallback[] = []
private loadingListeners: LoadingCallback[] = []
private markedAsReadListeners: (() => void)[] = []
private currentProgressMap: Map<string, number> = new Map()
private markedAsReadIds: Set<string> = new Set()
private lastLoadedPubkey: string | null = null
private generation = 0
private timelineSubscription: { unsubscribe: () => void } | null = null
private isLoading = false
onProgress(cb: ProgressMapCallback): () => void {
this.progressListeners.push(cb)
@@ -40,6 +47,13 @@ class ReadingProgressController {
}
}
onMarkedAsReadChanged(cb: () => void): () => void {
this.markedAsReadListeners.push(cb)
return () => {
this.markedAsReadListeners = this.markedAsReadListeners.filter(l => l !== cb)
}
}
private setLoading(loading: boolean): void {
this.loadingListeners.forEach(cb => cb(loading))
}
@@ -48,6 +62,10 @@ class ReadingProgressController {
this.progressListeners.forEach(cb => cb(new Map(progressMap)))
}
private emitMarkedAsReadChanged(): void {
this.markedAsReadListeners.forEach(cb => cb())
}
/**
* Get current reading progress map without triggering a reload
*/
@@ -91,6 +109,20 @@ class ReadingProgressController {
return this.currentProgressMap.get(naddr)
}
/**
* Check if article is marked as read
*/
isMarkedAsRead(naddr: string): boolean {
return this.markedAsReadIds.has(naddr)
}
/**
* Get all marked as read IDs (for debugging)
*/
getMarkedAsReadIds(): string[] {
return Array.from(this.markedAsReadIds)
}
/**
* Check if reading progress is loaded for a specific pubkey
*/
@@ -113,24 +145,11 @@ class ReadingProgressController {
this.timelineSubscription = null
}
this.currentProgressMap = new Map()
this.markedAsReadIds = new Set()
this.lastLoadedPubkey = null
this.emitProgress(this.currentProgressMap)
}
/**
* Get last synced timestamp for incremental loading
*/
private getLastSyncedAt(pubkey: string): number | null {
try {
const data = localStorage.getItem(LAST_SYNCED_KEY)
if (!data) return null
const parsed = JSON.parse(data)
return parsed[pubkey] || null
} catch {
return null
}
}
/**
* Update last synced timestamp
*/
@@ -157,13 +176,22 @@ class ReadingProgressController {
const { relayPool, eventStore, pubkey, force = false } = params
const startGeneration = this.generation
console.log('[readingProgress] start() called for pubkey:', pubkey.slice(0, 16), '...', 'force:', force)
// Skip if already loaded for this pubkey and not forcing
if (!force && this.isLoadedFor(pubkey)) {
console.log('[readingProgress] Already loaded for pubkey, skipping')
return
}
// Prevent concurrent starts
if (this.isLoading) {
console.log('[readingProgress] Already loading, skipping concurrent start')
return
}
this.setLoading(true)
this.lastLoadedPubkey = pubkey
this.isLoading = true
try {
// Seed from local cache immediately (survives refresh/flight mode)
@@ -173,8 +201,8 @@ class ReadingProgressController {
this.emitProgress(this.currentProgressMap)
}
// Subscribe to local timeline for immediate and reactive updates
// Clean up any previous subscription first
// Subscribe to local eventStore timeline for immediate and reactive updates
// This handles both local writes and synced events from relays
if (this.timelineSubscription) {
try {
this.timelineSubscription.unsubscribe()
@@ -184,55 +212,62 @@ class ReadingProgressController {
this.timelineSubscription = null
}
console.log('[readingProgress] Setting up eventStore subscription...')
const timeline$ = eventStore.timeline({
kinds: [KINDS.ReadingProgress],
authors: [pubkey]
})
const generationAtSubscribe = this.generation
this.timelineSubscription = timeline$.subscribe((localEvents: NostrEvent[]) => {
// Ignore if controller generation has changed (e.g., logout/login)
if (generationAtSubscribe !== this.generation) return
if (!Array.isArray(localEvents) || localEvents.length === 0) return
this.processEvents(localEvents)
})
console.log('[readingProgress] EventStore subscription ready - updates streaming')
// Query events from relays
// Force full sync if map is empty (first load) or if explicitly forced
const needsFullSync = force || this.currentProgressMap.size === 0
const lastSynced = needsFullSync ? null : this.getLastSyncedAt(pubkey)
const filter: Filter = {
// Mark as loaded immediately - queries run in background non-blocking
this.lastLoadedPubkey = pubkey
// Query reading progress from relays in background (non-blocking, fire-and-forget)
console.log('[readingProgress] Starting background relay query for reading progress...')
queryEvents(relayPool, {
kinds: [KINDS.ReadingProgress],
authors: [pubkey]
}
if (lastSynced && !needsFullSync) {
filter.since = lastSynced
}
}, { relayUrls: RELAYS })
.then((relayEvents) => {
if (startGeneration !== this.generation) return
console.log('[readingProgress] Got reading progress from relays:', relayEvents.length)
if (relayEvents.length > 0) {
relayEvents.forEach(e => eventStore.add(e))
this.processEvents(relayEvents)
const now = Math.floor(Date.now() / 1000)
this.updateLastSyncedAt(pubkey, now)
}
})
.catch((err) => {
console.warn('[readingProgress] Background reading progress query failed:', err)
})
const relayEvents = await queryEvents(relayPool, filter, { relayUrls: RELAYS })
if (startGeneration !== this.generation) {
return
}
// Load mark-as-read reactions in background (non-blocking, streaming)
console.log('[readingProgress] Starting background relay query for mark-as-read reactions...')
this.loadMarkAsReadReactions(relayPool, eventStore, pubkey, startGeneration)
.then(() => {
console.log('[readingProgress] Mark-as-read reactions loading complete')
})
.catch((err) => {
console.warn('[readingProgress] Mark-as-read reactions loading failed:', err)
})
if (relayEvents.length > 0) {
// Add to event store
relayEvents.forEach(e => eventStore.add(e))
// Process and emit (merge with existing)
this.processEvents(relayEvents)
// Update last synced
const now = Math.floor(Date.now() / 1000)
this.updateLastSyncedAt(pubkey, now)
}
} catch (err) {
console.error('📊 [ReadingProgress] Failed to load:', err)
console.error('📊 [ReadingProgress] Failed to setup:', err)
} finally {
if (startGeneration === this.generation) {
this.setLoading(false)
}
this.isLoading = false
console.log('[readingProgress] === LOADED ===')
console.log('[readingProgress] progressMap keys:', Array.from(this.currentProgressMap.keys()))
console.log('[readingProgress] markedAsReadIds:', Array.from(this.markedAsReadIds))
}
}
@@ -271,6 +306,84 @@ class ReadingProgressController {
this.persistProgress(this.lastLoadedPubkey, this.currentProgressMap)
}
}
/**
* Load mark-as-read reactions in background (non-blocking)
*/
private async loadMarkAsReadReactions(
relayPool: RelayPool,
_eventStore: IEventStore,
pubkey: string,
generation: number
): Promise<void> {
try {
// Stream kind:17 (URL reactions) and kind:7 (event reactions) in parallel
console.log('[readingProgress] Querying kind:17 and kind:7 reactions (streaming)...')
const seenReactionIds = new Set<string>()
const handleUrlReaction = (evt: NostrEvent) => {
if (seenReactionIds.has(evt.id)) return
seenReactionIds.add(evt.id)
if (evt.content !== ARCHIVE_EMOJI) return
const rTag = evt.tags.find(t => t[0] === 'r')?.[1]
if (!rTag) return
this.markedAsReadIds.add(rTag)
this.emitMarkedAsReadChanged()
}
const pendingEventIds = new Set<string>()
const handleEventReaction = (evt: NostrEvent) => {
if (seenReactionIds.has(evt.id)) return
seenReactionIds.add(evt.id)
if (evt.content !== ARCHIVE_EMOJI) return
const eTag = evt.tags.find(t => t[0] === 'e')?.[1]
if (!eTag) return
pendingEventIds.add(eTag)
}
// Fire queries with onEvent callbacks for streaming behavior
const [kind17Events, kind7Events] = await Promise.all([
queryEvents(relayPool, { kinds: [17], authors: [pubkey] }, { relayUrls: RELAYS, onEvent: handleUrlReaction }),
queryEvents(relayPool, { kinds: [7], authors: [pubkey] }, { relayUrls: RELAYS, onEvent: handleEventReaction })
])
if (generation !== this.generation) return
// Include any reactions that arrived only at EOSE
kind17Events.forEach(handleUrlReaction)
kind7Events.forEach(handleEventReaction)
if (pendingEventIds.size > 0) {
// Fetch referenced 30023 events, streaming not required here
const ids = Array.from(pendingEventIds)
const articleEvents = await queryEvents(relayPool, { kinds: [KINDS.BlogPost], ids }, { relayUrls: RELAYS })
const eventIdToNaddr = new Map<string, string>()
for (const article of articleEvents) {
const dTag = article.tags.find(t => t[0] === 'd')?.[1]
if (!dTag) continue
try {
const naddr = nip19.naddrEncode({ kind: KINDS.BlogPost, pubkey: article.pubkey, identifier: dTag })
eventIdToNaddr.set(article.id, naddr)
} catch (e) {
console.warn('[readingProgress] Failed to encode naddr for article:', article.id)
}
}
// Map pending event IDs to naddrs and emit
for (const eId of pendingEventIds) {
const naddr = eventIdToNaddr.get(eId)
if (naddr) {
this.markedAsReadIds.add(naddr)
}
}
this.emitMarkedAsReadChanged()
}
console.log('[readingProgress] Mark-as-read reactions complete. Total:', Array.from(this.markedAsReadIds).length)
} catch (err) {
console.warn('[readingProgress] Failed to load mark-as-read reactions:', err)
}
}
}
export const readingProgressController = new ReadingProgressController()

View File

@@ -0,0 +1,276 @@
import { RelayPool } from 'applesauce-relay'
import { Helpers, IEventStore } from 'applesauce-core'
import { createAddressLoader } from 'applesauce-loaders/loaders'
import { NostrEvent } from 'nostr-tools'
import { nip19 } from 'nostr-tools'
import { merge } from 'rxjs'
import { KINDS } from '../config/kinds'
import { RELAYS } from '../config/relays'
import { readingProgressController } from './readingProgressController'
import { archiveController } from './archiveController'
const { getArticleTitle, getArticleSummary, getArticleImage, getArticlePublished } = Helpers
export interface ReadItem {
id: string // naddr coordinate
source: 'reading-progress' | 'marked-as-read' | 'bookmark'
type: 'article' | 'external'
// Article data
event?: NostrEvent
url?: string
title?: string
summary?: string
image?: string
published?: number
author?: string
// Reading metadata
readingProgress?: number // 0-1
readingTimestamp?: number // Unix timestamp of last reading activity
markedAsRead?: boolean
markedAt?: number
}
type ReadsCallback = (reads: ReadItem[]) => void
type LoadingCallback = (loading: boolean) => void
/**
* Reads controller - manages read articles with progressive hydration
* Follows the same pattern as bookmarkController
*/
class ReadsController {
private readsListeners: ReadsCallback[] = []
private loadingListeners: LoadingCallback[] = []
private currentReads: Map<string, ReadItem> = new Map()
private isLoading = false
private hydrationGeneration = 0
// Address loader for efficient batching
private addressLoader: ReturnType<typeof createAddressLoader> | null = null
private eventStore: IEventStore | null = null
onReads(cb: ReadsCallback): () => void {
this.readsListeners.push(cb)
return () => {
this.readsListeners = this.readsListeners.filter(l => l !== cb)
}
}
onLoading(cb: LoadingCallback): () => void {
this.loadingListeners.push(cb)
return () => {
this.loadingListeners = this.loadingListeners.filter(l => l !== cb)
}
}
reset(): void {
this.hydrationGeneration++
this.currentReads.clear()
this.setLoading(false)
}
private setLoading(loading: boolean): void {
if (this.isLoading !== loading) {
this.isLoading = loading
this.loadingListeners.forEach(cb => cb(loading))
}
}
getReads(): ReadItem[] {
return Array.from(this.currentReads.values())
}
/**
* Hydrate article events by coordinates using AddressLoader (auto-batching, streaming)
*/
private hydrateArticles(
coordinates: string[],
onProgress: () => void,
generation: number
): void {
if (!this.addressLoader) {
return
}
if (coordinates.length === 0) return
// Parse coordinates into pointers
const pointers: Array<{ kind: number; pubkey: string; identifier: string }> = []
for (const coord of coordinates) {
try {
// Decode naddr to get article coordinates
if (coord.startsWith('naddr1')) {
const decoded = nip19.decode(coord)
if (decoded.type === 'naddr' && decoded.data.kind === KINDS.BlogPost) {
pointers.push({
kind: decoded.data.kind,
pubkey: decoded.data.pubkey,
identifier: decoded.data.identifier || ''
})
}
}
} catch (e) {
console.warn('Failed to decode article coordinate:', coord)
}
}
if (pointers.length === 0) return
// Use AddressLoader - it auto-batches and streams results
merge(...pointers.map(this.addressLoader)).subscribe({
next: (event) => {
// Check if hydration was cancelled
if (this.hydrationGeneration !== generation) return
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
// Build naddr from event
try {
const naddr = nip19.naddrEncode({
kind: event.kind,
pubkey: event.pubkey,
identifier: dTag
})
const item = this.currentReads.get(naddr)
if (item) {
// Enrich the item with article data
item.event = event
item.title = getArticleTitle(event) || 'Untitled'
item.summary = getArticleSummary(event)
item.image = getArticleImage(event)
item.published = getArticlePublished(event)
item.author = event.pubkey
// Store in event store if available
if (this.eventStore) {
this.eventStore.add(event)
}
onProgress()
}
} catch (e) {
console.warn('Failed to encode naddr for event:', event.id)
}
},
error: () => {
// Silent error - AddressLoader handles retries
}
})
}
/**
* Build ReadItems from reading progress and emit them
*/
private buildAndEmitReads(): void {
const progressMap = readingProgressController.getProgressMap()
const markedIds = Array.from(new Set([
...readingProgressController.getMarkedAsReadIds(),
...archiveController.getMarkedIds()
]))
// Build read items from progress map
const readItems: ReadItem[] = []
for (const [id, progress] of progressMap.entries()) {
const existing = this.currentReads.get(id)
const item: ReadItem = existing || {
id,
source: 'reading-progress',
type: 'article',
readingProgress: progress,
readingTimestamp: Math.floor(Date.now() / 1000)
}
// Update progress
item.readingProgress = progress
item.markedAsRead = markedIds.includes(id)
readItems.push(item)
this.currentReads.set(id, item)
}
// Include items that are only marked-as-read (no progress event yet)
for (const id of markedIds) {
if (!this.currentReads.has(id) && id.startsWith('naddr1')) {
const item: ReadItem = {
id,
source: 'marked-as-read',
type: 'article',
markedAsRead: true,
readingTimestamp: Math.floor(Date.now() / 1000)
}
readItems.push(item)
this.currentReads.set(id, item)
}
}
// Emit current state (items without article data yet)
this.readsListeners.forEach(cb => cb(Array.from(this.currentReads.values())))
// Fetch missing articles in background (progressive hydration)
const generation = this.hydrationGeneration
const onProgress = () => {
this.readsListeners.forEach(cb => cb(Array.from(this.currentReads.values())))
}
const coordinatesToFetch = readItems
.filter(item => !item.event && item.type === 'article')
.map(item => item.id)
this.hydrateArticles(coordinatesToFetch, onProgress, generation)
}
async start(options: {
relayPool: RelayPool
eventStore: IEventStore
pubkey: string
}): Promise<void> {
const { relayPool, eventStore } = options
// Increment generation to cancel any in-flight hydration
this.hydrationGeneration++
this.eventStore = eventStore
// Initialize loader for this session
this.addressLoader = createAddressLoader(relayPool, {
eventStore,
extraRelays: RELAYS
})
this.setLoading(true)
try {
// Subscribe to reading progress changes
const unsubProgress = readingProgressController.onProgress(() => {
this.buildAndEmitReads()
})
const unsubMarked = archiveController.onMarked(() => {
this.buildAndEmitReads()
})
// Build initial reads
this.buildAndEmitReads()
// Cleanup subscriptions on next start
setTimeout(() => {
unsubProgress()
unsubMarked()
}, 0)
} catch (error) {
console.error('Failed to load reads:', error)
this.readsListeners.forEach(cb => cb([]))
} finally {
this.setLoading(false)
}
}
}
// Singleton instance
export const readsController = new ReadsController()

View File

@@ -1,5 +1,4 @@
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { Bookmark } from '../types/bookmarks'
import { fetchReadArticles } from './libraryService'
@@ -8,31 +7,15 @@ import { RELAYS } from '../config/relays'
import { KINDS } from '../config/kinds'
import { classifyBookmarkType } from '../utils/bookmarkTypeClassifier'
import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19'
import { processReadingProgress, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor'
import { mergeReadItem } from '../utils/readItemMerge'
import type { ReadItem } from './readsController'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
export interface ReadItem {
id: string // event ID or URL or coordinate
source: 'bookmark' | 'reading-progress' | 'marked-as-read'
type: 'article' | 'external' // article=kind:30023, external=URL
// Article data
event?: NostrEvent
url?: string
title?: string
summary?: string
image?: string
published?: number
author?: string
// Reading metadata
readingProgress?: number // 0-1
readingTimestamp?: number // Unix timestamp of last reading activity
markedAsRead?: boolean
markedAt?: number
}
// Re-export ReadItem from readsController for consistency
export type { ReadItem } from './readsController'
/**
* Fetches all reads from multiple sources:
@@ -72,7 +55,7 @@ export async function fetchAllReads(
processMarkedAsRead(markedAsReadArticles, readsMap)
if (onItem) {
readsMap.forEach(item => {
if (item.type === 'article') onItem(item)
onItem(item)
})
}
@@ -117,11 +100,14 @@ export async function fetchAllReads(
// Try to decode as naddr
if (coord.startsWith('naddr1')) {
const decoded = nip19.decode(coord)
if (decoded.type === 'naddr' && decoded.data.kind === KINDS.BlogPost) {
articlesToFetch.push({
pubkey: decoded.data.pubkey,
identifier: decoded.data.identifier || ''
})
if (decoded.type === 'naddr') {
const data = decoded.data as AddressPointer
if (data.kind === KINDS.BlogPost) {
articlesToFetch.push({
pubkey: data.pubkey,
identifier: data.identifier || ''
})
}
}
} else {
// Try coordinate format (kind:pubkey:identifier)

View File

@@ -0,0 +1,121 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
import { RELAYS } from '../config/relays'
import { ARCHIVE_EMOJI, deleteReaction } from './reactionService'
/**
* Returns the user's archive reactions (kind:7) for a given event id.
*/
export async function findArchiveReactionsForEvent(
eventId: string,
userPubkey: string,
relayPool: RelayPool
): Promise<NostrEvent[]> {
try {
const filter = {
kinds: [7],
authors: [userPubkey],
'#e': [eventId]
}
const events$ = relayPool
.req(RELAYS, filter)
.pipe(
onlyEvents(),
completeOnEose(),
takeUntil(timer(2000)),
toArray()
)
const events: NostrEvent[] = await lastValueFrom(events$)
return events.filter(evt => evt.content === ARCHIVE_EMOJI)
} catch (error) {
console.error('[unarchive] findArchiveReactionsForEvent error:', error)
return []
}
}
/**
* Returns the user's archive reactions (kind:17) for a given website URL.
*/
export async function findArchiveReactionsForWebsite(
url: string,
userPubkey: string,
relayPool: RelayPool
): Promise<NostrEvent[]> {
try {
// Normalize URL same as creation
let normalizedUrl = url
try {
const parsed = new URL(url)
parsed.hash = ''
normalizedUrl = parsed.toString()
if (normalizedUrl.endsWith('/')) normalizedUrl = normalizedUrl.slice(0, -1)
} catch (e) {
console.warn('[unarchive] URL normalize failed:', e)
}
const filter = {
kinds: [17],
authors: [userPubkey],
'#r': [normalizedUrl]
}
const events$ = relayPool
.req(RELAYS, filter)
.pipe(
onlyEvents(),
completeOnEose(),
takeUntil(timer(2000)),
toArray()
)
const events: NostrEvent[] = await lastValueFrom(events$)
return events.filter(evt => evt.content === ARCHIVE_EMOJI)
} catch (error) {
console.error('[unarchive] findArchiveReactionsForWebsite error:', error)
return []
}
}
/**
* Sends deletion requests for all of the user's archive reactions to an event.
* Returns the number of deletion requests published.
*/
export async function unarchiveEvent(
eventId: string,
account: IAccount,
relayPool: RelayPool
): Promise<number> {
try {
const reactions = await findArchiveReactionsForEvent(eventId, account.pubkey, relayPool)
await Promise.all(reactions.map(r => deleteReaction(r.id, account, relayPool)))
return reactions.length
} catch (error) {
console.error('[unarchive] unarchiveEvent error:', error)
return 0
}
}
/**
* Sends deletion requests for all of the user's archive reactions to a website URL.
* Returns the number of deletion requests published.
*/
export async function unarchiveWebsite(
url: string,
account: IAccount,
relayPool: RelayPool
): Promise<number> {
try {
const reactions = await findArchiveReactionsForWebsite(url, account.pubkey, relayPool)
await Promise.all(reactions.map(r => deleteReaction(r.id, account, relayPool)))
return reactions.length
} catch (error) {
console.error('[unarchive] unarchiveWebsite error:', error)
return 0
}
}

View File

@@ -14,6 +14,31 @@
50% { opacity: 1; transform: scale(1.1); }
}
/* Subtle success burst used for archive action */
@keyframes success-burst {
0% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.45), 0 0 0 0 rgba(16, 185, 129, 0.25);
transform: scale(1);
}
60% {
box-shadow: 0 0 0 10px rgba(16, 185, 129, 0), 0 0 0 0 rgba(16, 185, 129, 0);
transform: scale(1.05);
}
100% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0), 0 0 0 0 rgba(16, 185, 129, 0);
transform: scale(1);
}
}
/* Apply archive animation when button enters animating state */
.mark-as-read-btn.animating {
animation: success-burst 600ms ease-out 1;
}
.mark-as-read-btn.animating svg {
animation: pulse 600ms ease-in-out 1;
}
@keyframes highlight-pulse-animation {
0%, 100% { box-shadow: 0 0 8px rgba(var(--highlight-rgb, 255, 255, 0), 0.2); transform: scale(1); }
25% { box-shadow: 0 0 20px rgba(var(--highlight-rgb, 255, 255, 0), 0.6); transform: scale(1.02); }

View File

@@ -2,7 +2,7 @@ import React from 'react'
import { formatDistanceToNow, differenceInSeconds, differenceInMinutes, differenceInHours, differenceInDays, differenceInMonths, differenceInYears } from 'date-fns'
import { ParsedContent, ParsedNode, IndividualBookmark } from '../types/bookmarks'
import ResolvedMention from '../components/ResolvedMention'
// Note: ContentWithResolvedProfiles 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) => {
const date = new Date(timestamp * 1000)

View File

@@ -50,19 +50,23 @@ export function filterByReadingProgress(
return items.filter((item) => {
const progress = item.readingProgress || 0
const isMarked = item.markedAsRead || false
// Reading progress filters MUST ignore emoji/archive reactions
const hasHighlights = (articleHighlightCount.get(item.id) || 0) > 0 ||
(item.url && (articleHighlightCount.get(item.url) || 0) > 0)
switch (filter) {
case 'unopened':
return progress === 0 && !isMarked
return progress === 0
case 'started':
return progress > 0 && progress <= 0.10 && !isMarked
return progress > 0 && progress <= 0.10
case 'reading':
return progress > 0.10 && progress <= 0.94 && !isMarked
return progress > 0.10 && progress <= 0.94
case 'completed':
return progress >= 0.95 || isMarked
// Completed is 95%+ progress only (no emoji fallback)
return progress >= 0.95
case 'archive':
// Archive filter handled upstream; keep fallback as false to avoid mixing
return false
case 'highlighted':
return hasHighlights
case 'all':