Compare commits

..

112 Commits

Author SHA1 Message Date
Gigi
49c5f0c3ad chore: bump version to 0.8.6 2025-10-20 18:07:24 +02:00
Gigi
dbed4ad253 fix: revert to inline mount tracking with useRef
- Replace useMountedState custom hook with inline useRef approach
- Set mountedRef.current = true at start of each effect run
- Ensures proper reset when navigating between articles
- Simpler and more reliable than custom hook approach
2025-10-20 18:05:02 +02:00
Gigi
b117b1e6cf fix: remove isMounted from useEffect dependencies
- isMounted is a stable function from useMountedState and shouldn't be in deps
- Including it was preventing effects from running correctly
- Fixes issue where articles wouldn't load (stuck on spinner)
2025-10-20 17:46:41 +02:00
Gigi
627ffd6c5d fix: resolve React Hooks violation in NostrMentionLink component
- Move useEventModel hook call to top level (Rules of Hooks)
- Extract pubkey before calling the hook
- Profile resolution now works correctly for npub and nprofile mentions
- Fixes issue where profiles weren't being fetched and displayed
2025-10-20 16:36:52 +02:00
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
Gigi
84b0339505 chore: bump version to 0.8.2 2025-10-19 22:54:46 +02:00
Gigi
12fa1db0db style: adjust progress bar margin in compact cards
- Reduce left margin from 1.75rem to 1.5rem for better visual balance
2025-10-19 22:54:19 +02:00
Gigi
0919091f19 style: align reading progress bar with text in compact cards
- Add left margin of 1.75rem to progress bar to start where text begins
- Prevents progress bar from looking like a separator
- Creates visual association between progress indicator and the specific bookmark item
2025-10-19 22:53:49 +02:00
Gigi
e1c04b4e7f fix: align progress bar to start at title position
- Add padding-left to progress bar container to offset it to title position
- Remove margin from inner fill
- Progress bar now visually starts where the title starts, not at the icon
2025-10-19 22:52:32 +02:00
Gigi
b9642067a1 fix: use margin instead of padding for reading progress bar alignment
- Move left offset from outer container padding to inner progress fill margin
- Background bar now spans full width while progress fill starts at text position
- Creates cleaner visual alignment without distorting the bar appearance
2025-10-19 22:51:36 +02:00
Gigi
ceca37df08 style: align reading progress bar with title text in compact cards
- Add left padding (1.85rem) to progress bar to align with bookmark title
- Progress bar now starts at the same position as the text content
2025-10-19 22:50:48 +02:00
Gigi
dfdc5d0946 style: make reading progress bar thinner in compact cards
- Reduce reading progress bar height from 2px to 1px
- Creates a more subtle, minimal progress indicator for compact bookmarks
2025-10-19 22:49:46 +02:00
Gigi
3619cd2585 fix: remove borders from compact bookmarks in sidebar
- Add explicit CSS rule to remove border from compact bookmarks in .bookmarks-list
- Override the border styling from me.css that was applying to all .individual-bookmark elements
- Ensure compact cards remain borderless and transparent
2025-10-19 22:49:01 +02:00
Gigi
f93e52611e style: make compact cards even more compact
- Reduce padding from 0.5rem to 0.25rem vertically
- Reduce compact row height from 28px to 24px
- Reduce gap between compact cards from 0.5rem to 0.25rem
- Creates a tighter, more space-efficient list layout
2025-10-19 22:48:17 +02:00
Gigi
ecb81cb151 feat: show reading progress in compact cards in bookmarks sidebar
- Add reading progress state and subscription to BookmarkList component
- Create helper function to get reading progress for both articles (using naddr) and web bookmarks (using URL)
- Update CompactView to display reading progress indicator for all bookmark types
- Progress indicator now shows for any bookmark with reading data, not just articles
2025-10-19 22:46:34 +02:00
Gigi
adf73cb9d1 fix: resolve all linting and type errors
- Fix empty catch blocks by adding explanatory comments
- Remove unused variables or prefix with underscore
- Remove orphaned object literals from removed console.log statements
- Fix unnecessary dependency array entries
- Ensure all empty code blocks have comments to satisfy eslint no-empty rule
2025-10-19 22:41:35 +02:00
Gigi
4202807777 refactor: remove all console.log debug output 2025-10-19 22:35:45 +02:00
Gigi
1c21615103 chore: bump version to 0.8.1 2025-10-19 22:31:06 +02:00
Gigi
732070e89b fix: re-derive reads/links when bookmarks change
- Add bookmarks to useEffect dependencies that load tab data
- Reads tab now updates when bookmarks are loaded/updated
- Fixes 'No articles ready yet' disappearing when switching tabs
- Ensures reads are always derived from current bookmark state
- Re-renders Reads tab whenever bookmarks change
2025-10-19 22:29:02 +02:00
Gigi
d9a00dd157 fix: merge reading progress from controller into reads/links before filtering
- Enrich reads and links arrays with reading progress from readingProgressMap
- Use item.id to lookup progress for articles
- Use item.url to lookup progress for links
- Now 'started' and 'reading' filters show correct articles
- Filters respond in real-time as reading progress updates from controller
2025-10-19 22:25:55 +02:00
Gigi
103be75f6e feat: auto-load reading progress on login and app start
- Add readingProgressController.start() to App.tsx
- Follows same pattern as highlightsController and writingsController
- Checks isLoadedFor(pubkey) to prevent duplicate loading
- Automatically fetches reading progress when user logs in
- Loads progress from cache first, then streams from relays
- Reading progress now available immediately for filters and indicators
2025-10-19 22:23:03 +02:00
Gigi
8dd4e358b4 fix: normalize highlight article references to naddr format for proper matching
- Convert coordinate-format eventReferences (30023:pubkey:identifier) to naddr
- ReadItems use naddr format for IDs, but highlights store coordinates
- Properly match highlights to articles by normalizing both formats
- Fixes 'highlighted' filter showing no results
- Handles conversion errors gracefully by falling back to original format
2025-10-19 22:21:43 +02:00
Gigi
2e8dfaee09 refactor: reorder reading progress filters - highlighted before completed
- Move highlighted filter before completed in button order
- Reading filters now appear in logical order:
  All → Unopened → Started → Reading → Highlighted → Completed
2025-10-19 22:20:31 +02:00
Gigi
db3084b373 fix: use ES6 import instead of require in helpers.ts
- Replace require() call with ES6 import for READING_PROGRESS constant
- Fixes linter error: 'require' is not defined (no-undef)
- All linter checks now pass with no warnings or errors
2025-10-19 22:19:06 +02:00
Gigi
83e4a2ad4c refactor: rename Amethyst bookmark sections to simpler names
- Rename 'Amethyst Lists' to 'My Lists'
- Rename 'Amethyst Private' to 'Private Lists'
- Clearer and more intuitive names without referencing the Amethyst client
- Applied in both Me.tsx and BookmarkList.tsx

These sections contain kind:30001 bookmarks (replaceable list events).
2025-10-19 22:18:16 +02:00
Gigi
c1d23fac7b feat: show reading progress in compact and card view bookmarks
- Add readingProgress prop to BookmarkItem component
- Display reading progress in CompactView with 2px indicator
- Display reading progress in CardView with 3px indicator
- Progress color matches main app: blue (reading), green (completed), neutral (started)
- Add getBookmarkReadingProgress helper in Me.tsx
- Show progress only for kind:30023 articles with progress > 0
- Reading progress now visible across all bookmark view modes
2025-10-19 22:17:35 +02:00
Gigi
de32310801 feat: add highlights filter button to reading progress filters
- Add 'highlighted' filter type to ReadingProgressFilterType
- New filter button with yellow highlighter icon
- Filter shows only articles that have highlights
- Highlights filter checks both eventReference and urlReference tags
- Color-coded: green for completed, yellow for highlighted, blue for others
- Applies to reads and links tabs in /me page
2025-10-19 22:15:13 +02:00
Gigi
5c82dff8df feat: only track reading progress for articles above minimum length
- Add MIN_CONTENT_LENGTH constant (1000 chars ≈ 150 words) to config/kinds
- Create shouldTrackReadingProgress helper to validate content length
- Strip HTML tags when calculating character count
- Only save reading progress for articles meeting the threshold
- Log when content is too short to track

This prevents noisy tracking of very short articles or excerpts.
2025-10-19 22:13:37 +02:00
Gigi
abe2d6528a feat: add setting to hide bookmarks missing creation date
- Add hideBookmarksWithoutCreationDate to UserSettings
- New checkbox in Layout & Behavior settings
- Bookmarks without valid creation dates shown as 'Now'
- Setting disabled by default to maintain current behavior
2025-10-19 22:11:47 +02:00
Gigi
8b56fe3d6e ux: update Flight Mode notification text to say 'Local relays only' 2025-10-19 22:10:32 +02:00
Gigi
bdce7c9358 docs: update CHANGELOG.md for v0.8.0 release 2025-10-19 22:09:56 +02:00
87 changed files with 2369 additions and 929 deletions

View File

@@ -7,6 +7,96 @@ 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
- Centralized reading progress controller for non-blocking reading position sync
- Progressive loading with caching from event store
- Streaming updates from relays with proper merging
- 2-second completion hold at 100% reading position to prevent UI jitter
- Configurable auto-mark-as-read at 100% reading progress
- Reading progress indicators on blog post cards
- Visual progress bars on article cards in Explore and bookmarks sidebar
- Persistent reading position synced across devices via NIP-85
### Changed
- Reading position sync now enabled by default in runtime paths
- Improved auto-mark-as-read behavior with reliable completion detection
- Reading progress events use proper NIP-85 specification (kind 39802)
### Fixed
- Reading position saves with proper validation and event store integration
- Profile page writings loading now fetches all writings without limits
- Consistent reading progress calculation and event publishing
### Performance
- Non-blocking reading progress controller with streaming updates
- Cache-first loading strategy with local event store before relay queries
- Efficient progress merging and deduplication
## [0.7.4] - 2025-10-18
### Added
@@ -2013,7 +2103,10 @@ 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.7.4...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
[0.7.2]: https://github.com/dergigi/boris/compare/v0.7.0...v0.7.2

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'
@@ -215,12 +214,6 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
const debugEnabled = req.query.debug === '1' || req.headers['x-boris-debug'] === '1'
if (debugEnabled) {
console.log('[article-og] request', JSON.stringify({
naddr,
ua: userAgent || null,
isCrawlerRequest,
path: req.url || null
}))
res.setHeader('X-Boris-Debug', '1')
}
@@ -257,7 +250,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
if (debugEnabled) {
console.log('[article-og] response', JSON.stringify({ mode: 'browser', naddr }))
// Debug mode enabled
}
return res.status(200).send(html)
}
@@ -268,7 +261,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
if (cached && cached.expires > now) {
setCacheHeaders(res)
if (debugEnabled) {
console.log('[article-og] response', JSON.stringify({ mode: 'bot', naddr, cache: true }))
// Debug mode enabled
}
return res.status(200).send(cached.html)
}
@@ -286,7 +279,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
// Send response
setCacheHeaders(res)
if (debugEnabled) {
console.log('[article-og] response', JSON.stringify({ mode: 'bot', naddr, cache: false }))
// Debug mode enabled
}
return res.status(200).send(html)
} catch (err) {
@@ -296,7 +289,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
const html = generateHtml(naddr, null)
setCacheHeaders(res, 3600)
if (debugEnabled) {
console.log('[article-og] response', JSON.stringify({ mode: 'bot-fallback', naddr }))
// Debug mode enabled
}
return res.status(200).send(html)
}

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.0",
"version": "0.8.6",
"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'
@@ -70,18 +71,14 @@ function AppRoutes({
// Subscribe to contacts controller
useEffect(() => {
console.log('[contacts] 🎧 Subscribing to contacts controller')
const unsubContacts = contactsController.onContacts((contacts) => {
console.log('[contacts] 📥 Received contacts:', contacts.size)
setContacts(contacts)
})
const unsubLoading = contactsController.onLoading((loading) => {
console.log('[contacts] 📥 Loading state:', loading)
setContactsLoading(loading)
})
return () => {
console.log('[contacts] 🔇 Unsubscribing from contacts controller')
unsubContacts()
unsubLoading()
}
@@ -100,22 +97,29 @@ function AppRoutes({
// Load contacts
if (pubkey && contacts.size === 0 && !contactsLoading) {
console.log('[contacts] 🚀 Auto-loading contacts on mount/login')
contactsController.start({ relayPool, pubkey })
}
// Load highlights (controller manages its own state)
if (pubkey && eventStore && !highlightsController.isLoadedFor(pubkey)) {
console.log('[highlights] 🚀 Auto-loading highlights on mount/login')
highlightsController.start({ relayPool, eventStore, pubkey })
}
// Load writings (controller manages its own state)
if (pubkey && eventStore && !writingsController.isLoadedFor(pubkey)) {
console.log('[writings] 🚀 Auto-loading writings on mount/login')
writingsController.start({ relayPool, eventStore, pubkey })
}
// Load reading progress (controller manages its own state)
if (pubkey && eventStore && !readingProgressController.isLoadedFor(pubkey)) {
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 })
@@ -147,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')
}
@@ -288,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={
@@ -380,40 +397,31 @@ function App() {
// Return an already-resolved promise so upstream await finishes immediately
return Promise.resolve()
}
console.log('[bunker] ✅ Wired NostrConnectSigner to RelayPool publish/subscription (before account load)')
// Create a relay group for better event deduplication and management
pool.group(RELAYS)
console.log('[bunker] Created relay group with', RELAYS.length, 'relays (including local)')
// Load persisted accounts from localStorage
try {
const accountsJson = localStorage.getItem('accounts')
console.log('[bunker] Raw accounts from localStorage:', accountsJson)
const json = JSON.parse(accountsJson || '[]')
console.log('[bunker] Parsed accounts:', json.length, 'accounts')
await accounts.fromJSON(json)
console.log('[bunker] Loaded', accounts.accounts.length, 'accounts from storage')
console.log('[bunker] Account types:', accounts.accounts.map(a => ({ id: a.id, type: a.type })))
// Load active account from storage
const activeId = localStorage.getItem('active')
console.log('[bunker] Active ID from localStorage:', activeId)
if (activeId) {
const account = accounts.getAccount(activeId)
console.log('[bunker] Found account for ID?', !!account, account?.type)
if (account) {
accounts.setActive(activeId)
console.log('[bunker] ✅ Restored active account:', activeId, 'type:', account.type)
} else {
console.warn('[bunker] ⚠️ Active ID found but account not in list')
}
} else {
console.log('[bunker] No active account ID in localStorage')
// No active account ID in localStorage
}
} catch (err) {
console.error('[bunker] ❌ Failed to load accounts from storage:', err)
@@ -438,11 +446,6 @@ function App() {
const reconnectedAccounts = new Set<string>()
const bunkerReconnectSub = accounts.active$.subscribe(async (account) => {
console.log('[bunker] Active account changed:', {
hasAccount: !!account,
type: account?.type,
id: account?.id
})
if (account && account.type === 'nostr-connect') {
const nostrConnectAccount = account as Accounts.NostrConnectAccount<unknown>
@@ -450,23 +453,17 @@ function App() {
try {
if (!(nostrConnectAccount as unknown as { disableQueue?: boolean }).disableQueue) {
(nostrConnectAccount as unknown as { disableQueue?: boolean }).disableQueue = true
console.log('[bunker] ⚙️ Disabled account request queueing for nostr-connect')
}
} catch (err) { console.warn('[bunker] failed to disable queue', err) }
} catch (err) {
// Ignore queue disable errors
}
// Note: for Amber bunker, the remote signer pubkey is the user's pubkey. This is expected.
// Skip if we've already reconnected this account
if (reconnectedAccounts.has(account.id)) {
console.log('[bunker] ⏭️ Already reconnected this account, skipping')
return
}
console.log('[bunker] Account detected. Status:', {
listening: nostrConnectAccount.signer.listening,
isConnected: nostrConnectAccount.signer.isConnected,
hasRemote: !!nostrConnectAccount.signer.remote,
bunkerRelays: nostrConnectAccount.signer.relays
})
try {
// For restored signers, ensure they have the pool's subscription methods
@@ -480,10 +477,9 @@ function App() {
const newBunkerRelays = bunkerRelays.filter(url => !existingRelayUrls.has(url))
if (newBunkerRelays.length > 0) {
console.log('[bunker] Adding bunker relays to pool BEFORE signer recreation:', newBunkerRelays)
pool.group(newBunkerRelays)
} else {
console.log('[bunker] Bunker relays already in pool')
// Bunker relays already in pool
}
const recreatedSigner = new NostrConnectSigner({
@@ -497,13 +493,11 @@ function App() {
try {
const mergedRelays = Array.from(new Set([...(signerData.relays || []), ...RELAYS]))
recreatedSigner.relays = mergedRelays
console.log('[bunker] 🔗 Signer relays merged with app RELAYS:', mergedRelays)
} catch (err) { console.warn('[bunker] failed to merge signer relays', err) }
// Replace the signer on the account
nostrConnectAccount.signer = recreatedSigner
console.log('[bunker] ✅ Signer recreated with pool context')
// Debug: log publish/subscription calls made by signer (decrypt/sign requests)
// IMPORTANT: bind originals to preserve `this` context used internally by the signer
const originalPublish = (recreatedSigner as unknown as { publishMethod: (relays: string[], event: unknown) => unknown }).publishMethod.bind(recreatedSigner)
@@ -525,7 +519,6 @@ function App() {
tags: (event as { tags?: unknown })?.tags,
contentLength: typeof content === 'string' ? content.length : undefined
}
console.log('[bunker] publish via signer:', summary)
try { DebugBus.info('bunker', 'publish', summary) } catch (err) { console.warn('[bunker] failed to log to DebugBus', err) }
} catch (err) { console.warn('[bunker] failed to log publish summary', err) }
// Fire-and-forget publish: trigger the publish but do not return the
@@ -543,7 +536,6 @@ function App() {
const originalSubscribe = (recreatedSigner as unknown as { subscriptionMethod: (relays: string[], filters: unknown[]) => unknown }).subscriptionMethod.bind(recreatedSigner)
;(recreatedSigner as unknown as { subscriptionMethod: (relays: string[], filters: unknown[]) => unknown }).subscriptionMethod = (relays: string[], filters: unknown[]) => {
try {
console.log('[bunker] subscribe via signer:', { relays, filters })
try { DebugBus.info('bunker', 'subscribe', { relays, filters }) } catch (err) { console.warn('[bunker] failed to log subscribe to DebugBus', err) }
} catch (err) { console.warn('[bunker] failed to log subscribe summary', err) }
return originalSubscribe(relays, filters)
@@ -553,20 +545,16 @@ function App() {
// Just ensure the signer is listening for responses - don't call connect() again
// The fromBunkerURI already connected with permissions during login
if (!nostrConnectAccount.signer.listening) {
console.log('[bunker] Opening signer subscription...')
await nostrConnectAccount.signer.open()
console.log('[bunker] ✅ Signer subscription opened')
} else {
console.log('[bunker] ✅ Signer already listening')
// Signer already listening
}
// Attempt a guarded reconnect to ensure Amber authorizes decrypt operations
try {
if (nostrConnectAccount.signer.remote && !reconnectedAccounts.has(account.id)) {
const permissions = getDefaultBunkerPermissions()
console.log('[bunker] Attempting guarded connect() with permissions to ensure decrypt perms', { count: permissions.length })
await nostrConnectAccount.signer.connect(undefined, permissions)
console.log('[bunker] ✅ Guarded connect() succeeded with permissions')
}
} catch (e) {
console.warn('[bunker] ⚠️ Guarded connect() failed:', e)
@@ -575,7 +563,6 @@ function App() {
// Give the subscription a moment to fully establish before allowing decrypt operations
// This ensures the signer is ready to handle and receive responses
await new Promise(resolve => setTimeout(resolve, 100))
console.log("[bunker] Subscription ready after startup delay")
// Fire-and-forget: probe decrypt path to verify Amber responds to NIP-46 decrypt
try {
const withTimeout = async <T,>(p: Promise<T>, ms = 10000): Promise<T> => {
@@ -588,38 +575,27 @@ function App() {
const self = nostrConnectAccount.pubkey
// Try a roundtrip so the bunker can respond successfully
try {
console.log('[bunker] 🔎 Probe nip44 roundtrip (encrypt→decrypt)…')
const cipher44 = await withTimeout(nostrConnectAccount.signer.nip44!.encrypt(self, 'probe-nip44'))
const plain44 = await withTimeout(nostrConnectAccount.signer.nip44!.decrypt(self, cipher44))
console.log('[bunker] 🔎 Probe nip44 responded:', typeof plain44 === 'string' ? plain44 : typeof plain44)
} catch (err) {
console.log('[bunker] 🔎 Probe nip44 result:', err instanceof Error ? err.message : err)
await withTimeout(nostrConnectAccount.signer.nip44!.encrypt(self, 'probe-nip44'))
await withTimeout(nostrConnectAccount.signer.nip44!.decrypt(self, ''))
} catch (_err) {
// Ignore probe errors
}
try {
console.log('[bunker] 🔎 Probe nip04 roundtrip (encrypt→decrypt)…')
const cipher04 = await withTimeout(nostrConnectAccount.signer.nip04!.encrypt(self, 'probe-nip04'))
const plain04 = await withTimeout(nostrConnectAccount.signer.nip04!.decrypt(self, cipher04))
console.log('[bunker] 🔎 Probe nip04 responded:', typeof plain04 === 'string' ? plain04 : typeof plain04)
} catch (err) {
console.log('[bunker] 🔎 Probe nip04 result:', err instanceof Error ? err.message : err)
await withTimeout(nostrConnectAccount.signer.nip04!.encrypt(self, 'probe-nip04'))
await withTimeout(nostrConnectAccount.signer.nip04!.decrypt(self, ''))
} catch (_err) {
// Ignore probe errors
}
}, 0)
} catch (err) {
console.log('[bunker] 🔎 Probe setup failed:', err)
} catch (_err) {
// Ignore signer setup errors
}
// The bunker remembers the permissions from the initial connection
nostrConnectAccount.signer.isConnected = true
console.log('[bunker] Final signer status:', {
listening: nostrConnectAccount.signer.listening,
isConnected: nostrConnectAccount.signer.isConnected,
remote: nostrConnectAccount.signer.remote,
relays: nostrConnectAccount.signer.relays
})
// Mark this account as reconnected
reconnectedAccounts.add(account.id)
console.log('[bunker] 🎉 Signer ready for signing')
} catch (error) {
console.error('[bunker] ❌ Failed to open signer:', error)
}
@@ -633,7 +609,6 @@ function App() {
next: () => {}, // No-op, we don't care about events
error: (err) => console.warn('Keep-alive subscription error:', err)
})
console.log('🔗 Created keep-alive subscription for', RELAYS.length, 'relay(s)')
// Store subscription for cleanup
;(pool as unknown as { _keepAliveSubscription: typeof keepAliveSub })._keepAliveSubscription = keepAliveSub

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

@@ -34,9 +34,9 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
progressColor = 'var(--color-text)' // Neutral text color (started)
}
// Debug log
// Debug log - reading progress shown as visual indicator
if (readingProgress !== undefined) {
console.log('[progress] 🎴 Card render:', post.title.slice(0, 30), '=> progress:', progressPercent + '%', 'color:', progressColor)
// Reading progress display
}
return (

View File

@@ -19,9 +19,10 @@ interface BookmarkItemProps {
index: number
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
viewMode?: ViewMode
readingProgress?: number
}
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards' }) => {
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards', readingProgress }) => {
const [ogImage, setOgImage] = useState<string | null>(null)
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
@@ -139,7 +140,8 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
handleReadNow,
articleImage,
articleSummary,
contentTypeIcon: getContentTypeIcon()
contentTypeIcon: getContentTypeIcon(),
readingProgress
}
if (viewMode === 'compact') {

View File

@@ -13,7 +13,7 @@ import { ViewMode } from './Bookmarks'
import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator'
import { BookmarkSkeleton } from './Skeletons'
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet } from '../utils/bookmarkUtils'
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet, hasCreationDate } from '../utils/bookmarkUtils'
import { UserSettings } from '../services/settingsService'
import AddBookmarkModal from './AddBookmarkModal'
import { createWebBookmark } from '../services/webBookmarkService'
@@ -22,6 +22,10 @@ import { Hooks } from 'applesauce-react'
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
import LoginOptions from './LoginOptions'
import { useEffect } from 'react'
import { readingProgressController } from '../services/readingProgressController'
import { nip19 } from 'nostr-tools'
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
interface BookmarkListProps {
bookmarks: Bookmark[]
@@ -70,6 +74,45 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
return saved === 'flat' ? 'flat' : 'grouped'
})
const activeAccount = Hooks.useActiveAccount()
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
// Subscribe to reading progress updates
useEffect(() => {
// Get initial progress map
setReadingProgressMap(readingProgressController.getProgressMap())
// Subscribe to updates
const unsubProgress = readingProgressController.onProgress(setReadingProgressMap)
return () => {
unsubProgress()
}
}, [])
// Helper to get reading progress for a bookmark
const getBookmarkReadingProgress = (bookmark: IndividualBookmark): number | undefined => {
if (bookmark.kind === 30023) {
// For articles, use naddr as key
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
if (!dTag) return undefined
try {
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: bookmark.pubkey,
identifier: dTag
})
return readingProgressMap.get(naddr)
} catch (err) {
return undefined
}
}
// For web bookmarks and other types, try to use URL if available
const urls = extractUrlsFromContent(bookmark.content)
if (urls.length > 0) {
return readingProgressMap.get(urls[0])
}
return undefined
}
const toggleGroupingMode = () => {
const newMode = groupingMode === 'grouped' ? 'flat' : 'grouped'
@@ -100,6 +143,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
// Merge and flatten all individual bookmarks from all lists
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
.filter(hasContent)
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b))
// Apply filter
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
@@ -116,8 +160,8 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
: [
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
{ key: 'amethyst-private', title: 'Amethyst Private', items: groups.amethystPrivate },
{ key: 'amethyst-public', title: 'Amethyst Lists', items: groups.amethystPublic },
{ key: 'amethyst-private', title: 'Private Lists', items: groups.amethystPrivate },
{ key: 'amethyst-public', title: 'My Lists', items: groups.amethystPublic },
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb }
]
@@ -220,6 +264,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
index={index}
onSelectUrl={onSelectUrl}
viewMode={viewMode}
readingProgress={getBookmarkReadingProgress(individualBookmark)}
/>
))}
</div>

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'
@@ -24,6 +24,7 @@ interface CardViewProps {
articleImage?: string
articleSummary?: string
contentTypeIcon: IconDefinition
readingProgress?: number
}
export const CardView: React.FC<CardViewProps> = ({
@@ -38,7 +39,8 @@ export const CardView: React.FC<CardViewProps> = ({
handleReadNow,
articleImage,
articleSummary,
contentTypeIcon
contentTypeIcon,
readingProgress
}) => {
const firstUrl = hasUrls ? extractedUrls[0] : null
const firstUrlClassificationType = firstUrl ? classifyUrl(firstUrl)?.type : null
@@ -52,6 +54,14 @@ export const CardView: React.FC<CardViewProps> = ({
const shouldTruncate = !expanded && contentLength > 210
const isArticle = bookmark.kind === 30023
// Calculate progress color (matching BlogPostCard logic)
let progressColor = '#6366f1' // Default blue (reading)
if (readingProgress && readingProgress >= 0.95) {
progressColor = '#10b981' // Green (completed)
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
progressColor = 'var(--color-text)' // Neutral text color (started)
}
// Determine which image to use (article image, instant preview, or OG image)
const previewImage = articleImage || instantPreview || ogImage
const cachedImage = useImageCache(previewImage || undefined)
@@ -137,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 && (
@@ -163,6 +169,28 @@ export const CardView: React.FC<CardViewProps> = ({
</button>
)}
{/* Reading progress indicator for articles */}
{isArticle && readingProgress !== undefined && readingProgress > 0 && (
<div
style={{
height: '3px',
width: '100%',
background: 'var(--color-border)',
overflow: 'hidden',
marginTop: '0.75rem'
}}
>
<div
style={{
height: '100%',
width: `${Math.round(readingProgress * 100)}%`,
background: progressColor,
transition: 'width 0.3s ease, background 0.3s ease'
}}
/>
</div>
)}
<div className="bookmark-footer">
<div className="bookmark-meta-minimal">
<Link

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
@@ -13,6 +13,7 @@ interface CompactViewProps {
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
articleSummary?: string
contentTypeIcon: IconDefinition
readingProgress?: number
}
export const CompactView: React.FC<CompactViewProps> = ({
@@ -22,12 +23,21 @@ export const CompactView: React.FC<CompactViewProps> = ({
extractedUrls,
onSelectUrl,
articleSummary,
contentTypeIcon
contentTypeIcon,
readingProgress
}) => {
const isArticle = bookmark.kind === 30023
const isWebBookmark = bookmark.kind === 39701
const isClickable = hasUrls || isArticle || isWebBookmark
// Calculate progress color (matching BlogPostCard logic)
let progressColor = '#6366f1' // Default blue (reading)
if (readingProgress && readingProgress >= 0.95) {
progressColor = '#10b981' // Green (completed)
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
progressColor = 'var(--color-text)' // Neutral text color (started)
}
const handleCompactClick = () => {
if (!onSelectUrl) return
@@ -56,12 +66,35 @@ 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>
{/* CTA removed */}
</div>
{/* Reading progress indicator for all bookmark types with reading data */}
{readingProgress !== undefined && readingProgress > 0 && (
<div
style={{
height: '1px',
width: '100%',
background: 'var(--color-border)',
overflow: 'hidden',
margin: '0',
marginLeft: '1.5rem'
}}
>
<div
style={{
height: '100%',
width: `${Math.round(readingProgress * 100)}%`,
background: progressColor,
transition: 'width 0.3s ease, background 0.3s ease'
}}
/>
</div>
)}
</div>
)
}

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,10 +29,12 @@ 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'
import { classifyUrl } from '../utils/helpers'
import { classifyUrl, shouldTrackReadingProgress } from '../utils/helpers'
import { buildNativeVideoUrl } from '../utils/videoHelpers'
import { useReadingPosition } from '../hooks/useReadingPosition'
import { ReadingProgressIndicator } from './ReadingProgressIndicator'
@@ -151,27 +153,18 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
// Callback to save reading position
const handleSavePosition = useCallback(async (position: number) => {
if (!activeAccount || !relayPool || !eventStore || !articleIdentifier) {
console.log('[progress] ⏭️ ContentPanel: Skipping save - missing requirements:', {
hasAccount: !!activeAccount,
hasRelayPool: !!relayPool,
hasEventStore: !!eventStore,
hasIdentifier: !!articleIdentifier
})
return
}
if (!settings?.syncReadingPosition) {
console.log('[progress] ⏭️ ContentPanel: Sync disabled in settings')
return
}
// Check if content is long enough to track reading progress
if (!shouldTrackReadingProgress(html, markdown)) {
return
}
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
console.log('[progress] 💾 ContentPanel: Saving position:', {
position,
percentage: Math.round(position * 100) + '%',
scrollTop,
articleIdentifier: articleIdentifier.slice(0, 50) + '...',
url: selectedUrl?.slice(0, 50)
})
try {
const factory = new EventFactory({ signer: activeAccount })
@@ -186,57 +179,41 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
scrollTop
}
)
console.log('[progress] ✅ ContentPanel: Save completed successfully')
} catch (error) {
console.error('[progress] ❌ ContentPanel: Failed to save reading position:', error)
}
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
}, [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) {
console.log('[progress] 📖 Auto-marking as read on completion')
if (!settings?.autoMarkAsReadOnCompletion || !activeAccount) return
if (!isMarkedAsRead) {
handleMarkAsRead()
} else {
// Already archived: still show the success animation for feedback
setShowCheckAnimation(true)
setTimeout(() => setShowCheckAnimation(false), 600)
}
}
})
// Log sync status when it changes
useEffect(() => {
console.log('[progress] 📊 ContentPanel reading position sync status:', {
enabled: isTextContent,
syncEnabled: settings?.syncReadingPosition !== false,
hasAccount: !!activeAccount,
hasRelayPool: !!relayPool,
hasEventStore: !!eventStore,
hasArticleIdentifier: !!articleIdentifier,
currentProgress: progressPercentage + '%'
})
}, [isTextContent, settings?.syncReadingPosition, activeAccount, relayPool, eventStore, articleIdentifier, progressPercentage])
// Load saved reading position when article loads
useEffect(() => {
if (!isTextContent || !activeAccount || !relayPool || !eventStore || !articleIdentifier) {
console.log('⏭️ [ContentPanel] Skipping position restore - missing requirements:', {
isTextContent,
hasAccount: !!activeAccount,
hasRelayPool: !!relayPool,
hasEventStore: !!eventStore,
hasIdentifier: !!articleIdentifier
})
return
}
if (settings?.syncReadingPosition === false) {
console.log('⏭️ [ContentPanel] Sync disabled in settings - not restoring position')
return
}
console.log('📖 [ContentPanel] Loading position for article:', selectedUrl?.slice(0, 50))
const loadPosition = async () => {
try {
const savedPosition = await loadReadingPosition(
@@ -247,7 +224,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
)
if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) {
console.log('🎯 [ContentPanel] Restoring position:', Math.round(savedPosition.position * 100) + '%')
// Wait for content to be fully rendered before scrolling
setTimeout(() => {
const documentHeight = document.documentElement.scrollHeight
@@ -258,14 +234,12 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
top: scrollTop,
behavior: 'smooth'
})
console.log('✅ [ContentPanel] Restored to position:', Math.round(savedPosition.position * 100) + '%', 'scrollTop:', scrollTop)
}, 500) // Give content time to render
} else if (savedPosition) {
if (savedPosition.position === 1) {
console.log('✅ [ContentPanel] Article completed (100%), starting from top')
// Article was completed, start from top
} else {
console.log('⏭️ [ContentPanel] Position too early (<5%):', Math.round(savedPosition.position * 100) + '%')
// Position was too early, skip restore
}
}
} catch (error) {
@@ -599,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) {
@@ -618,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
}
@@ -640,16 +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 }
})()
}
)
console.log('✅ Marked nostr article as read')
// 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
)
console.log('✅ Marked website as read')
archiveController.mark(selectedUrl)
console.log('[archive][content] optimistic mark url', selectedUrl)
}
} catch (error) {
console.error('Failed to mark as read:', error)
@@ -683,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}
@@ -789,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}
@@ -952,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
@@ -310,10 +328,6 @@ const Debug: React.FC<DebugProps> = ({
// Subscribe to decrypt complete events for Debug UI display
const unsubscribeDecrypt = bookmarkController.onDecryptComplete((eventId, publicCount, privateCount) => {
console.log('[bunker] ✅ Auto-decrypted:', eventId.slice(0, 8), {
public: publicCount,
private: privateCount
})
setDecryptedEvents(prev => new Map(prev).set(eventId, {
public: publicCount,
private: privateCount
@@ -728,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')
@@ -742,7 +900,6 @@ const Debug: React.FC<DebugProps> = ({
// Subscribe to controller updates to see streaming
const unsubscribe = contactsController.onContacts((contacts) => {
console.log('[debug] Received contacts update:', contacts.size)
setFriendsPubkeys(new Set(contacts))
})
@@ -1353,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

@@ -178,12 +178,10 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
useEffect(() => {
// Get initial state immediately
const initialMap = readingProgressController.getProgressMap()
console.log('[progress] 🎯 Explore: Initial progress map size:', initialMap.size)
setReadingProgressMap(initialMap)
// Subscribe to updates
const unsubProgress = readingProgressController.onProgress((newMap) => {
console.log('[progress] 🎯 Explore: Received progress update, size:', newMap.size)
setReadingProgressMap(newMap)
})
@@ -612,7 +610,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
const getReadingProgress = useCallback((post: BlogPostPreview): number | undefined => {
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1]
if (!dTag) {
console.log('[progress] ⚠️ No d-tag for post:', post.title)
return undefined
}
@@ -624,16 +621,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
})
const progress = readingProgressMap.get(naddr)
// Only log first lookup to avoid spam, or when found
if (progress || readingProgressMap.size === 0) {
console.log('[progress] 🔍 Looking up:', {
title: post.title.slice(0, 30),
naddr: naddr.slice(0, 80),
mapSize: readingProgressMap.size,
mapKeys: readingProgressMap.size > 0 ? Array.from(readingProgressMap.keys()).slice(0, 3).map(k => k.slice(0, 80)) : [],
progress: progress ? Math.round(progress * 100) + '%' : 'not found'
})
}
return progress
} catch (err) {
console.error('[progress] ❌ Error encoding naddr:', err)

View File

@@ -27,7 +27,6 @@ export const HighlightCitation: React.FC<HighlightCitationProps> = ({
// Fallback: extract directly from p tag
const pTag = highlight.tags.find(t => t[0] === 'p')
if (pTag && pTag[1]) {
console.log('📝 Found author from p tag:', pTag[1])
return pTag[1]
}
@@ -45,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
@@ -348,11 +262,9 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
// Publish to all configured relays - let the relay pool handle connection state
const targetRelays = RELAYS
console.log('📡 Rebroadcasting highlight to', targetRelays.length, 'relay(s):', targetRelays)
await relayPool.publish(targetRelays, event)
console.log('✅ Rebroadcast successful!')
// Update the highlight with new relay info
const isLocalOnly = areAllRelaysLocal(targetRelays)
@@ -449,7 +361,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
relayPool
)
console.log('✅ Highlight deletion request published')
// Notify parent to remove this highlight from the list
if (onHighlightDelete) {

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']
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,42 +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) => {
console.log('📈 [Reads] Enrichment item received:', {
id: item.id.slice(0, 20) + '...',
progress: item.readingProgress,
hasProgress: item.readingProgress !== undefined && item.readingProgress > 0
})
setReadsMap(prevMap => {
// Only update if item exists in our current map
if (!prevMap.has(item.id)) {
console.log('⚠️ [Reads] Item not in map, skipping:', item.id.slice(0, 20) + '...')
return prevMap
}
const newMap = new Map(prevMap)
const merged = mergeReadItem(newMap, item)
if (merged) {
console.log('✅ [Reads] Merged progress:', item.id.slice(0, 20) + '...', item.readingProgress)
// 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)
@@ -298,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))
@@ -348,7 +347,7 @@ const Me: React.FC<MeProps> = ({
break
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab, viewingPubkey, refreshTrigger])
}, [activeTab, viewingPubkey, refreshTrigger, bookmarks])
// Sync myHighlights from controller
useEffect(() => {
@@ -471,26 +470,129 @@ const Me: React.FC<MeProps> = ({
}
}
// Helper to get reading progress for a bookmark
const getBookmarkReadingProgress = (bookmark: IndividualBookmark): number | undefined => {
if (bookmark.kind === 30023) {
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
if (!dTag) return undefined
try {
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: bookmark.pubkey,
identifier: dTag
})
return readingProgressMap.get(naddr)
} catch (err) {
return undefined
}
}
return undefined
}
// 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)
// Apply reading progress filter
const filteredReads = filterByReadingProgress(reads, readingProgressFilter)
const filteredLinks = filterByReadingProgress(links, readingProgressFilter)
// 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)
if (progress !== undefined) {
return { ...item, readingProgress: progress }
}
}
return item
})
// 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 }]
: [
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
{ key: 'amethyst-private', title: 'Amethyst Private', items: groups.amethystPrivate },
{ key: 'amethyst-public', title: 'Amethyst Lists', items: groups.amethystPublic },
{ key: 'amethyst-private', title: 'Private Lists', items: groups.amethystPrivate },
{ key: 'amethyst-public', title: 'My Lists', items: groups.amethystPublic },
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb }
]
@@ -567,6 +669,7 @@ const Me: React.FC<MeProps> = ({
index={index}
viewMode="cards"
onSelectUrl={handleSelectUrl}
readingProgress={getBookmarkReadingProgress(individualBookmark)}
/>
))}
</div>
@@ -619,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>
)
)}
</>
)
@@ -666,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,134 @@
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'
}) => {
// Decode the nostr URI first
let decoded: ReturnType<typeof nip19.decode> | null = null
let pubkey: string | undefined
try {
const identifier = nostrUri.replace(/^nostr:/, '')
decoded = nip19.decode(identifier)
// Extract pubkey for profile fetching (works for npub and nprofile)
if (decoded.type === 'npub') {
pubkey = decoded.data
} else if (decoded.type === 'nprofile') {
pubkey = decoded.data.pubkey
}
} catch (error) {
// Decoding failed, will fallback to shortened identifier
}
// Fetch profile at top level (Rules of Hooks)
const profile = useEventModel(Models.ProfileModel, pubkey ? [pubkey] : null)
// If decoding failed, show shortened identifier
if (!decoded) {
const identifier = nostrUri.replace(/^nostr:/, '')
return (
<span className="highlight-comment-nostr-id">
{identifier.slice(0, 20)}...
</span>
)
}
// Render based on decoded type
switch (decoded.type) {
case 'npub': {
const pk = decoded.data
const displayName = profile?.name || profile?.display_name || profile?.nip05 || `${pk.slice(0, 8)}...`
return (
<a
href={`/p/${nip19.npubEncode(pk)}`}
className={className}
onClick={onClick}
>
@{displayName}
</a>
)
}
case 'nprofile': {
const { pubkey: pk } = decoded.data
const displayName = profile?.name || profile?.display_name || profile?.nip05 || `${pk.slice(0, 8)}...`
const npub = nip19.npubEncode(pk)
return (
<a
href={`/p/${npub}`}
className={className}
onClick={onClick}
>
@{displayName}
</a>
)
}
case 'naddr': {
const { kind, pubkey: pk, identifier: addrIdentifier } = decoded.data
// Check if it's a blog post (kind:30023)
if (kind === 30023) {
const naddr = nip19.naddrEncode({ kind, pubkey: pk, 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
const identifier = nostrUri.replace(/^nostr:/, '')
return (
<span className="highlight-comment-nostr-id">
{identifier.slice(0, 20)}...
</span>
)
}
}
}
export default NostrMentionLink

View File

@@ -68,12 +68,10 @@ const Profile: React.FC<ProfileProps> = ({
useEffect(() => {
// Get initial state immediately
const initialMap = readingProgressController.getProgressMap()
console.log('[progress] 🎯 Profile: Initial progress map size:', initialMap.size)
setReadingProgressMap(initialMap)
// Subscribe to updates
const unsubProgress = readingProgressController.onProgress((newMap) => {
console.log('[progress] 🎯 Profile: Received progress update, size:', newMap.size)
setReadingProgressMap(newMap)
})
@@ -100,12 +98,11 @@ const Profile: React.FC<ProfileProps> = ({
useEffect(() => {
if (!pubkey || !relayPool || !eventStore) return
console.log('🔄 [Profile] Background fetching highlights and writings for', pubkey.slice(0, 8))
// Fetch highlights in background
fetchHighlights(relayPool, pubkey, undefined, undefined, false, eventStore)
.then(highlights => {
console.log('✅ [Profile] Fetched', highlights.length, 'highlights')
.then(() => {
// Highlights fetched
})
.catch(err => {
console.warn('⚠️ [Profile] Failed to fetch highlights:', err)
@@ -115,7 +112,6 @@ const Profile: React.FC<ProfileProps> = ({
fetchBlogPostsFromAuthors(relayPool, [pubkey], RELAYS, undefined, null)
.then(writings => {
writings.forEach(w => eventStore.add(w.event))
console.log('✅ [Profile] Fetched', writings.length, 'writings')
})
.catch(err => {
console.warn('⚠️ [Profile] Failed to fetch writings:', err)
@@ -157,14 +153,9 @@ const Profile: React.FC<ProfileProps> = ({
// Only log when found or map is empty
if (progress || readingProgressMap.size === 0) {
console.log('[progress] 🔍 Profile lookup:', {
title: post.title?.slice(0, 30),
naddr: naddr.slice(0, 80),
mapSize: readingProgressMap.size,
mapKeys: readingProgressMap.size > 0 ? Array.from(readingProgressMap.keys()).slice(0, 3).map(k => k.slice(0, 80)) : [],
progress: progress ? Math.round(progress * 100) + '%' : 'not found'
})
// Progress found or map is empty
}
return progress
} catch (err) {
return undefined

View File

@@ -1,9 +1,10 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBookOpen, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
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'
export type ReadingProgressFilterType = 'all' | 'unopened' | 'started' | 'reading' | 'completed' | 'highlighted' | 'archive'
interface ReadingProgressFiltersProps {
selectedFilter: ReadingProgressFilterType
@@ -13,18 +14,30 @@ 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: '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 (
<div className="bookmark-filters">
{filters.map(filter => {
const isActive = selectedFilter === filter.type
// Only "completed" gets green color, everything else uses default blue
const activeStyle = isActive && filter.type === 'completed' ? { color: '#10b981' } : undefined
// Only "completed" gets green color, "highlighted" gets yellow, everything else uses default blue
let activeStyle: Record<string, string> | undefined = undefined
if (isActive) {
if (filter.type === 'completed') {
activeStyle = { color: '#10b981' } // green
} else if (filter.type === 'highlighted') {
activeStyle = { color: '#fde047' } // yellow
} else if (filter.type === 'archive') {
activeStyle = { color: '#60a5fa' } // blue accent
}
}
return (
<button

View File

@@ -50,16 +50,8 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({
// Debug logging
useEffect(() => {
console.log('🔌 Relay Status Indicator:', {
mode: isConnecting ? 'CONNECTING' : offlineMode ? 'OFFLINE' : localOnlyMode ? 'LOCAL_ONLY' : 'ONLINE',
totalStatuses: relayStatuses.length,
connectedCount: connectedUrls.length,
connectedUrls: connectedUrls.map(u => u.replace(/^wss?:\/\//, '')),
hasLocalRelay,
hasRemoteRelay,
isConnecting
})
}, [offlineMode, localOnlyMode, connectedUrls, relayStatuses.length, hasLocalRelay, hasRemoteRelay, isConnecting])
// Mode and relay status determined
}, [isConnecting, offlineMode, localOnlyMode, relayStatuses, hasLocalRelay, hasRemoteRelay])
// Don't show indicator when fully connected (but show when connecting)
if (!localOnlyMode && !offlineMode && !isConnecting) return null
@@ -156,7 +148,7 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({
fontWeight: 400
}}
>
{connectedUrls.length} local relay{connectedUrls.length !== 1 ? 's' : ''}
Local relays only
</span>
</>
)}

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,6 +41,7 @@ const DEFAULT_SETTINGS: UserSettings = {
paragraphAlignment: 'justify',
syncReadingPosition: true,
autoMarkAsReadOnCompletion: false,
hideBookmarksWithoutCreationDate: true,
}
interface SettingsProps {

View File

@@ -127,7 +127,20 @@ 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>
<div className="setting-group">
<label htmlFor="hideBookmarksWithoutCreationDate" className="checkbox-label">
<input
id="hideBookmarksWithoutCreationDate"
type="checkbox"
checked={settings.hideBookmarksWithoutCreationDate ?? false}
onChange={(e) => onUpdate({ hideBookmarksWithoutCreationDate: e.target.checked })}
className="setting-checkbox"
/>
<span>Hide bookmarks missing a creation date</span>
</label>
</div>
</div>

View File

@@ -27,7 +27,7 @@ const PWASettings: React.FC<PWASettingsProps> = ({ settings, onUpdate, onClose }
if (isInstalled) return
const success = await installApp()
if (success) {
console.log('App installed successfully')
// Installation successful
}
}

View File

@@ -14,3 +14,9 @@ export const KINDS = {
export type KindValue = typeof KINDS[keyof typeof KINDS]
// Reading progress tracking configuration
export const READING_PROGRESS = {
// Minimum character count to track reading progress (roughly 150 words)
MIN_CONTENT_LENGTH: 1000
} as const

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

@@ -43,21 +43,14 @@ export function useAdaptiveTextColor(imageUrl: string | undefined): AdaptiveText
height: Math.floor(height * 0.25)
})
console.log('Adaptive color detected:', {
hex: color.hex,
rgb: color.rgb,
isLight: color.isLight,
isDark: color.isDark
})
// Color analysis complete
// Use library's built-in isLight check for optimal contrast
if (color.isLight) {
console.log('Light background detected, using black text')
setColors({
textColor: '#000000'
})
} else {
console.log('Dark background detected, using white text')
setColors({
textColor: '#ffffff'
})

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react'
import { useEffect, useRef, Dispatch, SetStateAction } from 'react'
import { RelayPool } from 'applesauce-relay'
import { fetchArticleByNaddr } from '../services/articleService'
import { fetchHighlightsForArticle } from '../services/highlightService'
@@ -14,7 +14,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 +36,26 @@ export function useArticleLoader({
setCurrentArticle,
settings
}: UseArticleLoaderProps) {
const mountedRef = useRef(true)
useEffect(() => {
mountedRef.current = true
if (!relayPool || !naddr) return
const loadArticle = async () => {
if (!mountedRef.current) 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 (!mountedRef.current) return
setReaderContent({
title: article.title,
markdown: article.markdown,
@@ -63,52 +71,56 @@ export function useArticleLoader({
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(article.event.id)
setCurrentArticle?.(article.event)
console.log('📰 Article loaded:', article.title)
console.log('📍 Coordinate:', articleCoordinate)
// 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 (!mountedRef.current) 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 (!mountedRef.current) return
setHighlights((prev: Highlight[]) => {
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
const next = [highlight, ...prev]
return next.sort((a, b) => b.created_at - a.created_at)
})
},
settings
)
console.log(`📌 Found ${highlightsMap.size} highlights`)
} catch (err) {
console.error('Failed to fetch highlights:', err)
} finally {
setHighlightsLoading(false)
if (mountedRef.current) {
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 (mountedRef.current) {
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])
return () => {
mountedRef.current = false
}
// Intentionally excluding setter functions from dependencies to prevent race conditions
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [naddr, relayPool, settings])
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo } from 'react'
import { useEffect, useRef, useMemo } from 'react'
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { fetchReadableContent, ReadableContent } from '../services/readerService'
@@ -48,6 +48,8 @@ export function useExternalUrlLoader({
setCurrentArticleCoordinate,
setCurrentArticleEventId
}: UseExternalUrlLoaderProps) {
const mountedRef = useRef(true)
// Load cached URL-specific highlights from event store
const urlFilter = useMemo(() => {
if (!url) return null
@@ -61,80 +63,114 @@ export function useExternalUrlLoader({
[url]
)
// Load content and start streaming highlights when URL changes
useEffect(() => {
mountedRef.current = true
if (!relayPool || !url) return
const loadExternalUrl = async () => {
if (!mountedRef.current) return
setReaderLoading(true)
setReaderContent(undefined)
setSelectedUrl(url)
setIsCollapsed(true)
// Clear article-specific state
setCurrentArticleCoordinate(undefined)
setCurrentArticleEventId(undefined)
try {
const content = await fetchReadableContent(url)
if (!mountedRef.current) return
setReaderContent(content)
console.log('🌐 External URL loaded:', content.title)
// Set reader loading to false immediately after content is ready
setReaderLoading(false)
// Fetch highlights for this URL asynchronously
try {
if (!mountedRef.current) 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 (!mountedRef.current) 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 (mountedRef.current) {
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 (mountedRef.current) {
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])
return () => {
mountedRef.current = false
}
// Intentionally excluding setter functions from dependencies to prevent race conditions
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url, relayPool, eventStore, cachedUrlHighlights])
// 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

@@ -60,7 +60,6 @@ export const useHighlightCreation = ({
? currentArticle.content
: readerContent?.markdown || readerContent?.html
console.log('🎯 Creating highlight...', { text: text.substring(0, 50) + '...' })
const newHighlight = await createHighlight(
text,
@@ -73,12 +72,7 @@ export const useHighlightCreation = ({
settings
)
console.log('✅ Highlight created successfully!', {
id: newHighlight.id,
isLocalOnly: newHighlight.isLocalOnly,
isOfflineCreated: newHighlight.isOfflineCreated,
publishedRelays: newHighlight.publishedRelays
})
// Highlight created successfully
// Clear the browser's text selection immediately to allow DOM update
const selection = window.getSelection()

View File

@@ -32,14 +32,7 @@ export const useHighlightedContent = ({
}: UseHighlightedContentParams) => {
// Filter highlights by URL and visibility settings
const relevantHighlights = useMemo(() => {
console.log('🔍 ContentPanel: Processing highlights', {
totalHighlights: highlights.length,
selectedUrl,
showHighlights
})
const urlFiltered = filterHighlightsByUrl(highlights, selectedUrl)
console.log('📌 URL filtered highlights:', urlFiltered.length)
// Apply visibility filtering
const classified = classifyHighlights(urlFiltered, currentUserPubkey, followedPubkeys)
@@ -49,37 +42,25 @@ export const useHighlightedContent = ({
return highlightVisibility.nostrverse
})
console.log('✅ Relevant highlights after filtering:', filtered.length, filtered.map(h => h.content.substring(0, 30)))
return filtered
}, [selectedUrl, highlights, highlightVisibility, currentUserPubkey, followedPubkeys, showHighlights])
}, [selectedUrl, highlights, highlightVisibility, currentUserPubkey, followedPubkeys])
// Prepare the final HTML with highlights applied
const finalHtml = useMemo(() => {
const sourceHtml = markdown ? renderedMarkdownHtml : html
console.log('🎨 Preparing final HTML:', {
hasMarkdown: !!markdown,
hasHtml: !!html,
renderedHtmlLength: renderedMarkdownHtml.length,
sourceHtmlLength: sourceHtml?.length || 0,
showHighlights,
relevantHighlightsCount: relevantHighlights.length
})
// Prepare final HTML
if (!sourceHtml) {
console.warn('⚠️ No source HTML available')
return ''
}
if (showHighlights && relevantHighlights.length > 0) {
console.log('✨ Applying', relevantHighlights.length, 'highlights to HTML')
const highlightedHtml = applyHighlightsToHTML(sourceHtml, relevantHighlights, highlightStyle)
console.log('✅ Highlights applied, result length:', highlightedHtml.length)
return highlightedHtml
}
console.log('📄 Returning source HTML without highlights')
return sourceHtml
}, [html, renderedMarkdownHtml, markdown, relevantHighlights, showHighlights, highlightStyle])
return { finalHtml, relevantHighlights }

View File

@@ -43,7 +43,6 @@ export const useMarkdownToHTML = (
// Replace nostr URIs with resolved titles
processed = replaceNostrUrisInMarkdownWithTitles(markdown, articleTitles)
console.log(`📚 Resolved ${articleTitles.size} article titles`)
} catch (error) {
console.warn('Failed to fetch article titles:', error)
// Fall back to basic replacement
@@ -58,12 +57,10 @@ export const useMarkdownToHTML = (
setProcessedMarkdown(processed)
console.log('📝 Converting markdown to HTML...')
const rafId = requestAnimationFrame(() => {
if (previewRef.current && !isCancelled) {
const html = previewRef.current.innerHTML
console.log('✅ Markdown converted to HTML:', html.length, 'chars')
setRenderedHtml(html)
} else if (!isCancelled) {
console.warn('⚠️ markdownPreviewRef.current is null')

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

@@ -50,16 +50,10 @@ export function useOfflineSync({
const isNowOnline = hasRemoteRelays
if (wasLocalOnly && isNowOnline) {
console.log('✈️ Detected transition: Flight Mode → Online')
console.log('📊 Relay state:', {
connectedRelays: connectedRelays.length,
remoteRelays: connectedRelays.filter(r => !isLocalRelay(r.url)).length,
localRelays: connectedRelays.filter(r => isLocalRelay(r.url)).length
})
// Coming back online, sync events
// Wait a moment for relays to fully establish connections
setTimeout(() => {
console.log('🚀 Starting sync after delay...')
syncLocalEventsToRemote(relayPool, eventStore)
}, 2000)
}

View File

@@ -5,12 +5,10 @@ export function useOnlineStatus() {
useEffect(() => {
const handleOnline = () => {
console.log('🌐 Back online')
setIsOnline(true)
}
const handleOffline = () => {
console.log('📴 Gone offline')
setIsOnline(false)
}

View File

@@ -51,12 +51,10 @@ export function usePWAInstall() {
const choiceResult = await deferredPrompt.userChoice
if (choiceResult.outcome === 'accepted') {
console.log('✅ PWA installed')
setIsInstallable(false)
setDeferredPrompt(null)
return true
} else {
console.log('❌ PWA installation dismissed')
return false
}
} catch (error) {

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)
@@ -32,7 +33,6 @@ export const useReadingPosition = ({
// Debounced save function
const scheduleSave = useCallback((currentPosition: number) => {
if (!syncEnabled || !onSave) {
console.log('[progress] ⏭️ scheduleSave skipped:', { syncEnabled, hasOnSave: !!onSave, position: Math.round(currentPosition * 100) + '%' })
return
}
@@ -43,12 +43,7 @@ export const useReadingPosition = ({
const isInitialSave = !hasSavedOnce.current
if (!hasSignificantChange && !hasReachedCompletion && !isInitialSave) {
console.log('[progress] ⏭️ No significant change:', {
current: Math.round(currentPosition * 100) + '%',
last: Math.round(lastSavedPosition.current * 100) + '%',
diff: Math.abs(currentPosition - lastSavedPosition.current),
isInitialSave
})
// Not significant enough to save
return
}
@@ -58,9 +53,7 @@ export const useReadingPosition = ({
}
// Schedule new save
console.log('[progress] ⏰ Scheduling save in', autoSaveInterval + 'ms for position:', Math.round(currentPosition * 100) + '%')
saveTimerRef.current = setTimeout(() => {
console.log('[progress] 💾 Auto-saving position:', Math.round(currentPosition * 100) + '%')
lastSavedPosition.current = currentPosition
hasSavedOnce.current = true
onSave(currentPosition)
@@ -78,7 +71,6 @@ export const useReadingPosition = ({
}
// Always allow immediate save (including 0%)
console.log('[progress] 💾 Immediate save triggered for position:', Math.round(position * 100) + '%')
lastSavedPosition.current = position
hasSavedOnce.current = true
onSave(position)
@@ -109,14 +101,11 @@ export const useReadingPosition = ({
const prevPercent = Math.floor(position * 20) // Groups by 5%
const newPercent = Math.floor(clampedProgress * 20)
if (prevPercent !== newPercent) {
console.log('[progress] 📏 useReadingPosition:', Math.round(clampedProgress * 100) + '%', {
scrollTop,
documentHeight,
isAtBottom
})
// Position threshold crossed
}
setPosition(clampedProgress)
positionRef.current = clampedProgress
onPositionChange?.(clampedProgress)
// Schedule auto-save if sync is enabled
@@ -128,15 +117,13 @@ 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
console.log('[progress] ✅ Completion hold satisfied (100% for', completionHoldMs, 'ms)')
onReadingComplete?.()
}
completionTimerRef.current = null
}, completionHoldMs)
console.log('[progress] ⏳ Completion hold started (waiting', completionHoldMs, 'ms)')
}
} else {
// If we moved off 100%, cancel any pending completion hold
@@ -147,7 +134,6 @@ export const useReadingPosition = ({
if (clampedProgress >= readingCompleteThreshold) {
setIsReadingComplete(true)
hasTriggeredComplete.current = true
console.log('[progress] ✅ Completion via threshold:', readingCompleteThreshold)
onReadingComplete?.()
}
}

View File

@@ -48,7 +48,6 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
const root = document.documentElement.style
const fontKey = settings.readingFont || 'system'
console.log('🎨 Applying settings styles:', { fontKey, fontSize: settings.fontSize, theme: settings.theme })
// Apply theme with color variants (defaults to 'system' if not set)
applyTheme(
@@ -59,9 +58,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
// Load font first and wait for it to be ready
if (fontKey !== 'system') {
console.log('⏳ Waiting for font to load...')
await loadFont(fontKey)
console.log('✅ Font loaded, applying styles')
}
// Apply font settings after font is loaded
@@ -76,7 +73,6 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
// Set paragraph alignment
root.setProperty('--paragraph-alignment', settings.paragraphAlignment || 'justify')
console.log('✅ All styles applied')
}
applyStyles()

View File

@@ -11,7 +11,6 @@ if ('serviceWorker' in navigator) {
navigator.serviceWorker
.register('/sw.js', { type: 'module' })
.then(registration => {
console.log('✅ Service Worker registered:', registration.scope)
// Check for updates periodically
setInterval(() => {
@@ -25,7 +24,6 @@ if ('serviceWorker' in navigator) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New service worker available
console.log('🔄 New version available! Reload to update.')
// Optionally show a toast notification
const updateAvailable = new CustomEvent('sw-update-available')

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

@@ -48,7 +48,6 @@ function getFromCache(naddr: string): ArticleContent | null {
return null
}
console.log('📦 Loaded article from cache:', naddr)
return content
} catch {
return null
@@ -63,7 +62,6 @@ function saveToCache(naddr: string, content: ArticleContent): void {
timestamp: Date.now()
}
localStorage.setItem(cacheKey, JSON.stringify(cached))
console.log('💾 Saved article to cache:', naddr)
} catch (err) {
console.warn('Failed to cache article:', err)
// Silently fail if storage is full or unavailable

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

@@ -30,8 +30,8 @@ async function decryptEvent(
} catch {
try {
await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner, 'nip44' as UnlockMode)
} catch (err) {
console.log("[bunker] ❌ nip44.decrypt failed:", err instanceof Error ? err.message : String(err))
} catch (_err) {
// Ignore unlock errors
}
}
} else if (evt.content && evt.content.length > 0) {
@@ -45,8 +45,8 @@ async function decryptEvent(
if (looksLikeNip44 && hasNip44Decrypt(signerCandidate)) {
try {
decryptedContent = await (signerCandidate as { nip44: { decrypt: DecryptFn } }).nip44.decrypt(evt.pubkey, evt.content)
} catch (err) {
console.log("[bunker] ❌ nip44.decrypt failed:", err instanceof Error ? err.message : String(err))
} catch (_err) {
// Ignore NIP-44 decryption errors
}
}
@@ -54,8 +54,8 @@ async function decryptEvent(
if (!decryptedContent && hasNip04Decrypt(signerCandidate)) {
try {
decryptedContent = await (signerCandidate as { nip04: { decrypt: DecryptFn } }).nip04.decrypt(evt.pubkey, evt.content)
} catch (err) {
console.log("[bunker] ❌ nip04.decrypt failed:", err instanceof Error ? err.message : String(err))
} catch (_err) {
// Ignore NIP-04 decryption errors
}
}
@@ -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

@@ -15,7 +15,6 @@ export const fetchContacts = async (
): Promise<Set<string>> => {
try {
const relayUrls = prioritizeLocalRelays(Array.from(relayPool.relays.values()).map(relay => relay.url))
console.log('🔍 Fetching contacts (kind 3) for user:', pubkey)
const partialFollowed = new Set<string>()
const events = await queryEvents(
@@ -51,9 +50,7 @@ export const fetchContacts = async (
}
// merged already via streams
console.log('📊 Contact events fetched:', events.length)
console.log('👥 Followed contacts:', followed.size)
return followed
} catch (error) {
console.error('Failed to fetch contacts:', error)

View File

@@ -73,13 +73,11 @@ class ContactsController {
// Skip if already loaded for this pubkey (unless forced)
if (!force && this.isLoadedFor(pubkey)) {
console.log('[contacts] ✅ Already loaded for', pubkey.slice(0, 8))
this.emitContacts(this.currentContacts)
return
}
this.setLoading(true)
console.log('[contacts] 🔍 Loading contacts for', pubkey.slice(0, 8))
try {
const contacts = await fetchContacts(
@@ -89,7 +87,6 @@ class ContactsController {
// Stream partial updates
this.currentContacts = new Set(partial)
this.emitContacts(this.currentContacts)
console.log('[contacts] 📥 Partial contacts:', partial.size)
}
)
@@ -98,7 +95,6 @@ class ContactsController {
this.lastLoadedPubkey = pubkey
this.emitContacts(this.currentContacts)
console.log('[contacts] ✅ Loaded', contacts.size, 'contacts')
} catch (error) {
console.error('[contacts] ❌ Failed to load contacts:', error)
this.currentContacts.clear()

View File

@@ -36,12 +36,10 @@ export async function createDeletionRequest(
const signed = await factory.sign(draft)
console.log('🗑️ Created kind:5 deletion request for event:', eventId.slice(0, 8))
// Publish to relays
await relayPool.publish(RELAYS, signed)
console.log('✅ Deletion request published to', RELAYS.length, 'relay(s)')
return signed
}

View File

@@ -33,11 +33,9 @@ export const fetchBlogPostsFromAuthors = async (
): Promise<BlogPostPreview[]> => {
try {
if (pubkeys.length === 0) {
console.log('⚠️ No pubkeys to fetch blog posts from')
return []
}
console.log('📚 Fetching blog posts (kind 30023) from', pubkeys.length, 'authors', limit ? `(limit: ${limit})` : '(no limit)')
// Deduplicate replaceable events by keeping the most recent version
// Group by author + d-tag identifier
@@ -75,7 +73,6 @@ export const fetchBlogPostsFromAuthors = async (
}
)
console.log('📊 Blog post events fetched (unique):', uniqueEvents.size)
// Convert to blog post previews and sort by published date (most recent first)
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
@@ -97,7 +94,6 @@ export const fetchBlogPostsFromAuthors = async (
return timeB - timeA // Most recent first
})
console.log('📰 Processed', blogPosts.length, 'unique blog posts')
return blogPosts
} catch (error) {

View File

@@ -46,7 +46,6 @@ export async function createHighlight(
}
// Create EventFactory with the account as signer
console.log("[bunker] Creating EventFactory with signer:", { signerType: account.signer?.constructor?.name })
const factory = new EventFactory({ signer: account.signer })
let blueprintSource: NostrEvent | AddressPointer | string
@@ -117,9 +116,7 @@ export async function createHighlight(
}
// Sign the event
console.log('[bunker] Signing highlight event...', { kind: highlightEvent.kind, tags: highlightEvent.tags.length })
const signedEvent = await factory.sign(highlightEvent)
console.log('[bunker] ✅ Highlight signed successfully!', { id: signedEvent.id.slice(0, 8) })
// Use unified write service to store and publish
await publishEvent(relayPool, eventStore, signedEvent)

View File

@@ -22,7 +22,6 @@ export const fetchHighlights = async (
const cacheKey = highlightCache.authorKey(pubkey)
const cached = highlightCache.get(cacheKey)
if (cached) {
console.log(`📌 Using cached highlights for author (${cached.length} items)`)
// Stream cached highlights if callback provided
if (onHighlight) {
cached.forEach(h => onHighlight(h))
@@ -50,7 +49,6 @@ export const fetchHighlights = async (
}
)
console.log(`📌 Fetched ${rawEvents.length} highlight events for author:`, pubkey.slice(0, 8))
// Store all events in event store if provided
if (eventStore) {

View File

@@ -23,7 +23,6 @@ export const fetchHighlightsForArticle = async (
const cacheKey = highlightCache.articleKey(articleCoordinate)
const cached = highlightCache.get(cacheKey)
if (cached) {
console.log(`📌 Using cached highlights for article (${cached.length} items)`)
// Stream cached highlights if callback provided
if (onHighlight) {
cached.forEach(h => onHighlight(h))
@@ -54,7 +53,6 @@ export const fetchHighlightsForArticle = async (
])
const rawEvents = [...aTagEvents, ...eTagEvents]
console.log(`📌 Fetched ${rawEvents.length} highlight events for article:`, articleCoordinate)
// Store all events in event store if provided
if (eventStore) {

View File

@@ -22,7 +22,6 @@ export const fetchHighlightsForUrl = async (
const cacheKey = highlightCache.urlKey(url)
const cached = highlightCache.get(cacheKey)
if (cached) {
console.log(`📌 Using cached highlights for URL (${cached.length} items)`)
// Stream cached highlights if callback provided
if (onHighlight) {
cached.forEach(h => onHighlight(h))
@@ -50,7 +49,6 @@ export const fetchHighlightsForUrl = async (
}
)
console.log(`📌 Fetched ${rawEvents.length} highlight events for URL:`, url)
// Store all events in event store if provided
if (eventStore) {

View File

@@ -21,11 +21,9 @@ export const fetchHighlightsFromAuthors = async (
): Promise<Highlight[]> => {
try {
if (pubkeys.length === 0) {
console.log('⚠️ No pubkeys to fetch highlights from')
return []
}
console.log('💡 Fetching highlights (kind 9802) from', pubkeys.length, 'authors')
const seenIds = new Set<string>()
const rawEvents = await queryEvents(
@@ -55,7 +53,6 @@ export const fetchHighlightsFromAuthors = async (
const uniqueEvents = dedupeHighlights(rawEvents)
const highlights = uniqueEvents.map(eventToHighlight)
console.log('💡 Processed', highlights.length, 'unique highlights')
return sortHighlights(highlights)
} catch (error) {

View File

@@ -110,7 +110,6 @@ class HighlightsController {
// Skip if already loaded for this pubkey (unless forced)
if (!force && this.isLoadedFor(pubkey)) {
console.log('[highlights] ✅ Already loaded for', pubkey.slice(0, 8))
this.emitHighlights(this.currentHighlights)
return
}
@@ -120,7 +119,6 @@ class HighlightsController {
const currentGeneration = this.generation
this.setLoading(true)
console.log('[highlights] 🔍 Loading highlights for', pubkey.slice(0, 8))
try {
const seenIds = new Set<string>()
@@ -134,7 +132,6 @@ class HighlightsController {
}
if (lastSyncedAt) {
filter.since = lastSyncedAt
console.log('[highlights] 📅 Incremental sync since', new Date(lastSyncedAt * 1000).toISOString())
}
const events = await queryEvents(
@@ -165,7 +162,6 @@ class HighlightsController {
// Check if still active after async operation
if (currentGeneration !== this.generation) {
console.log('[highlights] ⚠️ Load cancelled (generation mismatch)')
return
}
@@ -189,7 +185,6 @@ class HighlightsController {
this.setLastSyncedAt(pubkey, newestTimestamp)
}
console.log('[highlights] ✅ Loaded', sorted.length, 'highlights')
} catch (error) {
console.error('[highlights] ❌ Failed to load highlights:', error)
this.currentHighlights = []

View File

@@ -13,7 +13,6 @@ const CACHE_NAME = 'boris-image-cache-v1'
export async function clearImageCache(): Promise<void> {
try {
await caches.delete(CACHE_NAME)
console.log('🗑️ Cleared all cached images')
} catch (err) {
console.error('Failed to clear image cache:', err)
}

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

@@ -17,7 +17,6 @@ export async function fetchLinks(
userPubkey: string,
onItem?: (item: ReadItem) => void
): Promise<ReadItem[]> {
console.log('🔗 [Links] Fetching external links for user:', userPubkey.slice(0, 8))
const linksMap = new Map<string, ReadItem>()
@@ -37,11 +36,6 @@ export async function fetchLinks(
fetchReadArticles(relayPool, userPubkey)
])
console.log('📊 [Links] Data fetched:', {
readingProgress: progressEvents.length,
markedAsRead: markedAsReadArticles.length
})
// Process reading progress events (kind 39802)
processReadingProgress(progressEvents, linksMap)
if (onItem) {
@@ -79,7 +73,6 @@ export async function fetchLinks(
const validLinks = filterValidItems(links)
const sortedLinks = sortByReadingActivity(validLinks)
console.log('✅ [Links] Processed', sortedLinks.length, 'total links')
return sortedLinks
} catch (error) {

View File

@@ -24,7 +24,6 @@ export const fetchNostrverseBlogPosts = async (
onPost?: (post: BlogPostPreview) => void
): Promise<BlogPostPreview[]> => {
try {
console.log('[NOSTRVERSE] 📚 Fetching blog posts (kind 30023), limit:', limit)
// Deduplicate replaceable events by keeping the most recent version
const uniqueEvents = new Map<string, NostrEvent>()
@@ -63,7 +62,6 @@ export const fetchNostrverseBlogPosts = async (
}
)
console.log('[NOSTRVERSE] 📊 Blog post events fetched (unique):', uniqueEvents.size)
// Convert to blog post previews and sort by published date (most recent first)
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
@@ -81,7 +79,6 @@ export const fetchNostrverseBlogPosts = async (
return timeB - timeA // Most recent first
})
console.log('[NOSTRVERSE] 📰 Processed', blogPosts.length, 'unique blog posts')
return blogPosts
} catch (error) {
@@ -103,7 +100,6 @@ export const fetchNostrverseHighlights = async (
eventStore?: IEventStore
): Promise<Highlight[]> => {
try {
console.log('[NOSTRVERSE] 💡 Fetching highlights (kind 9802), limit:', limit)
const seenIds = new Set<string>()
// Collect but do not block callers awaiting network completion
@@ -133,7 +129,6 @@ export const fetchNostrverseHighlights = async (
const uniqueEvents = dedupeHighlights([...collected, ...rawEvents])
const highlights = uniqueEvents.map(eventToHighlight)
console.log('[NOSTRVERSE] 💡 Processed', highlights.length, 'unique highlights')
return sortHighlights(highlights)
} catch (error) {

View File

@@ -20,7 +20,6 @@ const syncStateListeners: Array<(eventId: string, isSyncing: boolean) => void> =
*/
export function markEventAsOfflineCreated(eventId: string): void {
offlineCreatedEvents.add(eventId)
console.log(`📝 Marked event ${eventId.slice(0, 8)} as offline-created. Total: ${offlineCreatedEvents.size}`)
}
/**
@@ -57,49 +56,35 @@ export async function syncLocalEventsToRemote(
eventStore: IEventStore
): Promise<void> {
if (isSyncing) {
console.log('⏳ Sync already in progress, skipping...')
return
}
console.log('🔄 Coming back online - syncing local events to remote relays...')
console.log(`📦 Offline events tracked: ${offlineCreatedEvents.size}`)
isSyncing = true
try {
const remoteRelays = RELAYS.filter(url => !isLocalRelay(url))
console.log(`📡 Remote relays: ${remoteRelays.length}`)
if (remoteRelays.length === 0) {
console.log('⚠️ No remote relays available for sync')
isSyncing = false
return
}
if (offlineCreatedEvents.size === 0) {
console.log('✅ No offline events to sync')
isSyncing = false
return
}
// Get events from EventStore using the tracked IDs
const eventsToSync: NostrEvent[] = []
console.log(`🔍 Querying EventStore for ${offlineCreatedEvents.size} offline events...`)
for (const eventId of offlineCreatedEvents) {
const event = eventStore.getEvent(eventId)
if (event) {
console.log(`📥 Found event ${eventId.slice(0, 8)} (kind ${event.kind}) in EventStore`)
eventsToSync.push(event)
} else {
console.warn(`⚠️ Event ${eventId.slice(0, 8)} not found in EventStore`)
}
}
console.log(`📊 Total events to sync: ${eventsToSync.length}`)
if (eventsToSync.length === 0) {
console.log('✅ No events found in EventStore to sync')
isSyncing = false
offlineCreatedEvents.clear()
return
@@ -110,8 +95,6 @@ export async function syncLocalEventsToRemote(
new Map(eventsToSync.map(e => [e.id, e])).values()
)
console.log(`📤 Syncing ${uniqueEvents.length} event(s) to remote relays...`)
// Mark all events as syncing
uniqueEvents.forEach(event => {
syncingEvents.add(event.id)
@@ -119,21 +102,16 @@ export async function syncLocalEventsToRemote(
})
// Publish to remote relays
let successCount = 0
const successfulIds: string[] = []
for (const event of uniqueEvents) {
try {
await relayPool.publish(remoteRelays, event)
successCount++
successfulIds.push(event.id)
console.log(`✅ Synced event ${event.id.slice(0, 8)}`)
} catch (error) {
console.warn(`⚠️ Failed to sync event ${event.id.slice(0, 8)}:`, error)
// Silently fail for individual events
}
}
console.log(`✅ Synced ${successCount}/${uniqueEvents.length} events to remote relays`)
// Clear syncing state and offline tracking for successful events
successfulIds.forEach(eventId => {
@@ -150,7 +128,7 @@ export async function syncLocalEventsToRemote(
}
})
} catch (error) {
console.error('❌ Error during offline sync:', error)
// Silently fail
} finally {
isSyncing = false
}

View File

@@ -22,7 +22,6 @@ export const fetchProfiles = async (
}
const uniquePubkeys = Array.from(new Set(pubkeys))
console.log('👤 Fetching profiles (kind:0) for', uniquePubkeys.length, 'authors')
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const prioritized = prioritizeLocalRelays(relayUrls)
@@ -65,7 +64,6 @@ export const fetchProfiles = async (
await lastValueFrom(merge(local$, remote$).pipe(toArray()))
const profiles = Array.from(profilesByPubkey.values())
console.log('✅ Fetched', profiles.length, 'unique profiles')
// Rebroadcast profiles to local/all relays based on settings
if (profiles.length > 0) {

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,22 +33,24 @@ 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)
}))
const signed = await factory.sign(draft)
console.log('📚 Created kind:7 reaction (mark as read) for event:', eventId.slice(0, 8))
// Publish to relays
await relayPool.publish(RELAYS, signed)
console.log('✅ Reaction published to', RELAYS.length, 'relay(s)')
return signed
}
@@ -87,23 +90,42 @@ 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)
}))
const signed = await factory.sign(draft)
console.log('📚 Created kind:17 reaction (mark as read) for URL:', normalizedUrl)
// Publish to relays
await relayPool.publish(RELAYS, signed)
console.log('✅ Website reaction published to', RELAYS.length, 'relay(s)')
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
@@ -134,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) {
@@ -187,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

@@ -27,31 +27,23 @@ export function processReadingProgress(
events: NostrEvent[],
readsMap: Map<string, ReadItem>
): void {
console.log('[progress] 🔧 processReadingProgress called with', events.length, 'events')
for (const event of events) {
if (event.kind !== READING_PROGRESS_KIND) {
console.log('[progress] ⏭️ Skipping event with wrong kind:', event.kind)
continue
}
const dTag = event.tags.find(t => t[0] === 'd')?.[1]
if (!dTag) {
console.log('[progress] ⚠️ Event missing d-tag:', event.id.slice(0, 8))
continue
}
console.log('[progress] 📝 Processing event:', event.id.slice(0, 8), 'd-tag:', dTag.slice(0, 50))
try {
const content = JSON.parse(event.content)
const position = content.progress || 0
console.log('[progress] 📊 Progress value:', position, '(' + Math.round(position * 100) + '%)')
// Validate progress is between 0 and 1 (NIP-85 requirement)
if (position < 0 || position > 1) {
console.warn('[progress] ❌ Invalid progress value (must be 0-1):', position, 'event:', event.id.slice(0, 8))
continue
}
@@ -76,13 +68,10 @@ export function processReadingProgress(
})
itemId = naddr
itemType = 'article'
console.log('[progress] ✅ Converted coordinate to naddr:', naddr.slice(0, 50))
} catch (e) {
console.warn('[progress] ❌ Failed to encode naddr from coordinate:', dTag)
continue
}
} else {
console.warn('[progress] ⚠️ Invalid coordinate format:', dTag)
continue
}
} else if (dTag.startsWith('url:')) {
@@ -92,13 +81,10 @@ export function processReadingProgress(
itemUrl = atob(encoded.replace(/-/g, '+').replace(/_/g, '/'))
itemId = itemUrl
itemType = 'external'
console.log('[progress] ✅ Decoded URL:', itemUrl.slice(0, 50))
} catch (e) {
console.warn('[progress] ❌ Failed to decode URL from d tag:', dTag)
continue
}
} else {
console.warn('[progress] ⚠️ Unknown d-tag format:', dTag)
continue
}
@@ -114,16 +100,11 @@ export function processReadingProgress(
readingProgress: position,
readingTimestamp: timestamp
})
console.log('[progress] ✅ Added/updated item in readsMap:', itemId.slice(0, 50), '=', Math.round(position * 100) + '%')
} else {
console.log('[progress] ⏭️ Skipping older event for:', itemId.slice(0, 50))
}
} catch (error) {
console.warn('[progress] ❌ Failed to parse reading progress event:', error)
// Silently fail
}
}
console.log('[progress] 🏁 processReadingProgress finished, readsMap size:', readsMap.size)
}
/**

View File

@@ -49,14 +49,10 @@ function generateDTag(naddrOrUrl: string): string {
const decoded = nip19.decode(naddrOrUrl)
if (decoded.type === 'naddr') {
const dTag = `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier || ''}`
console.log('[progress] 📋 Generated d-tag from naddr:', {
naddr: naddrOrUrl.slice(0, 50) + '...',
dTag: dTag.slice(0, 80) + '...'
})
return dTag
}
} catch (e) {
console.warn('Failed to decode naddr:', naddrOrUrl)
// Ignore decode errors
}
}
@@ -119,14 +115,6 @@ export async function saveReadingPosition(
articleIdentifier: string,
position: ReadingPosition
): Promise<void> {
console.log('[progress] 💾 saveReadingPosition: Starting save:', {
identifier: articleIdentifier.slice(0, 50) + '...',
position: position.position,
positionPercent: Math.round(position.position * 100) + '%',
timestamp: position.timestamp,
scrollTop: position.scrollTop
})
const now = Math.floor(Date.now() / 1000)
const progressContent: ReadingProgressContent = {
@@ -138,13 +126,6 @@ export async function saveReadingPosition(
const tags = generateProgressTags(articleIdentifier)
console.log('[progress] 📝 Creating event with:', {
kind: READING_PROGRESS_KIND,
content: progressContent,
tags: tags.map(t => `[${t.join(', ')}]`).join(', '),
created_at: now
})
const draft = await factory.create(async () => ({
kind: READING_PROGRESS_KIND,
content: JSON.stringify(progressContent),
@@ -152,20 +133,9 @@ export async function saveReadingPosition(
created_at: now
}))
console.log('[progress] ✍️ Signing event...')
const signed = await factory.sign(draft)
console.log('[progress] 📡 Publishing event:', {
id: signed.id,
kind: signed.kind,
pubkey: signed.pubkey.slice(0, 8) + '...',
content: signed.content,
tags: signed.tags
})
await publishEvent(relayPool, eventStore, signed)
console.log('[progress] ✅ Event published successfully, ID:', signed.id.slice(0, 16))
}
/**
@@ -179,12 +149,6 @@ export async function loadReadingPosition(
): Promise<ReadingPosition | null> {
const dTag = generateDTag(articleIdentifier)
console.log('📖 [ReadingProgress] Loading position:', {
pubkey: pubkey.slice(0, 8) + '...',
identifier: articleIdentifier.slice(0, 32) + '...',
dTag: dTag.slice(0, 50) + '...'
})
// Check local event store first
try {
const localEvent = await firstValueFrom(
@@ -193,12 +157,6 @@ export async function loadReadingPosition(
if (localEvent) {
const content = getReadingProgressContent(localEvent)
if (content) {
console.log('✅ [ReadingProgress] Loaded from local store:', {
position: content.position,
positionPercent: Math.round(content.position * 100) + '%',
timestamp: content.timestamp
})
// Fetch from relays in background to get any updates
relayPool
.subscription(RELAYS, {
@@ -213,7 +171,7 @@ export async function loadReadingPosition(
}
}
} catch (err) {
console.log('📭 No cached reading progress found, fetching from relays...')
// Ignore errors and fetch from relays
}
// Fetch from relays
@@ -226,13 +184,7 @@ export async function loadReadingPosition(
getReadingProgressContent
)
if (result) {
console.log('✅ [ReadingProgress] Loaded from relays')
return result
}
console.log('📭 No reading progress found')
return null
return result || null
}
// Helper function to fetch from relays with timeout

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,15 +47,25 @@ 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))
}
private emitProgress(progressMap: Map<string, number>): void {
console.log('[progress] 📡 Emitting to', this.progressListeners.length, 'listeners with', progressMap.size, 'items')
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
*/
@@ -81,7 +98,7 @@ class ReadingProgressController {
parsed[pubkey] = Object.fromEntries(progressMap.entries())
localStorage.setItem(PROGRESS_CACHE_KEY, JSON.stringify(parsed))
} catch (err) {
console.warn('[progress] ⚠️ Failed to persist reading progress cache:', err)
// Silently fail cache persistence
}
}
@@ -92,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
*/
@@ -109,29 +140,16 @@ class ReadingProgressController {
try {
this.timelineSubscription.unsubscribe()
} catch (err) {
console.warn('[progress] ⚠️ Failed to unsubscribe timeline on reset:', err)
// Silently fail on unsubscribe
}
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
*/
@@ -142,7 +160,7 @@ class ReadingProgressController {
parsed[pubkey] = timestamp
localStorage.setItem(LAST_SYNCED_KEY, JSON.stringify(parsed))
} catch (err) {
console.warn('Failed to update last synced timestamp:', err)
// Silently fail
}
}
@@ -158,94 +176,98 @@ 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.slice(0, 8))
console.log('[readingProgress] Already loaded for pubkey, skipping')
return
}
// Prevent concurrent starts
if (this.isLoading) {
console.log('[readingProgress] Already loading, skipping concurrent start')
return
}
console.log('📊 [ReadingProgress] Loading for', pubkey.slice(0, 8), force ? '(forced)' : '')
this.setLoading(true)
this.lastLoadedPubkey = pubkey
this.isLoading = true
try {
// Seed from local cache immediately (survives refresh/flight mode)
const cached = this.loadCachedProgress(pubkey)
if (cached.size > 0) {
console.log('📊 [ReadingProgress] Seeded from cache:', cached.size, 'items')
this.currentProgressMap = cached
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()
} catch (err) {
console.warn('[progress] ⚠️ Failed to unsubscribe previous timeline:', err)
// Silently fail
}
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
console.log('📊 [ReadingProgress] Timeline update with', localEvents.length, 'event(s)')
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
console.log('📊 [ReadingProgress] Incremental sync since', new Date(lastSynced * 1000).toISOString())
} else {
console.log('📊 [ReadingProgress] Full sync (map size:', this.currentProgressMap.size + ')')
}
}, { 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) {
console.log('📊 [ReadingProgress] Cancelled (generation changed)')
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)
console.log('📊 [ReadingProgress] Loaded', relayEvents.length, 'events from relays')
// Update last synced
const now = Math.floor(Date.now() / 1000)
this.updateLastSyncedAt(pubkey, now)
} else {
console.log('📊 [ReadingProgress] No new events from relays')
}
} 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))
}
}
@@ -253,8 +275,6 @@ class ReadingProgressController {
* Process events and update progress map
*/
private processEvents(events: NostrEvent[]): void {
console.log('[progress] 🔄 Processing', events.length, 'events')
const readsMap = new Map<string, ReadItem>()
// Merge with existing progress
@@ -267,24 +287,17 @@ class ReadingProgressController {
})
}
console.log('[progress] 📦 Starting with', readsMap.size, 'existing items')
// Process new events
processReadingProgress(events, readsMap)
console.log('[progress] 📦 After processing:', readsMap.size, 'items')
// Convert back to progress map (naddr -> progress)
const newProgressMap = new Map<string, number>()
for (const [id, item] of readsMap.entries()) {
if (item.readingProgress !== undefined && item.type === 'article') {
newProgressMap.set(id, item.readingProgress)
console.log('[progress] ✅ Added:', id.slice(0, 50) + '...', '=', Math.round(item.readingProgress * 100) + '%')
}
}
console.log('[progress] 📊 Final progress map size:', newProgressMap.size)
this.currentProgressMap = newProgressMap
this.emitProgress(this.currentProgressMap)
@@ -293,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:
@@ -46,7 +29,6 @@ export async function fetchAllReads(
bookmarks: Bookmark[],
onItem?: (item: ReadItem) => void
): Promise<ReadItem[]> {
console.log('📚 [Reads] Fetching all reads for user:', userPubkey.slice(0, 8))
const readsMap = new Map<string, ReadItem>()
@@ -66,25 +48,14 @@ export async function fetchAllReads(
fetchReadArticles(relayPool, userPubkey)
])
console.log('📊 [Reads] Data fetched:', {
readingProgress: progressEvents.length,
markedAsRead: markedAsReadArticles.length,
bookmarks: bookmarks.length
})
// Process reading progress events (kind 39802)
processReadingProgress(progressEvents, readsMap)
if (onItem) {
readsMap.forEach(item => {
if (item.type === 'article') onItem(item)
})
}
// Process marked-as-read and emit items
processMarkedAsRead(markedAsReadArticles, readsMap)
if (onItem) {
readsMap.forEach(item => {
if (item.type === 'article') onItem(item)
onItem(item)
})
}
@@ -120,7 +91,6 @@ export async function fetchAllReads(
.map(item => item.id)
if (articleCoordinates.length > 0) {
console.log('📖 [Reads] Fetching article events for', articleCoordinates.length, 'articles')
// Parse coordinates and fetch events
const articlesToFetch: Array<{ pubkey: string; identifier: string }> = []
@@ -130,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)
@@ -187,7 +160,6 @@ export async function fetchAllReads(
const validArticles = filterValidItems(articles)
const sortedReads = sortByReadingActivity(validArticles)
console.log('✅ [Reads] Processed', sortedReads.length, 'total reads')
return sortedReads
} catch (error) {

View File

@@ -34,7 +34,6 @@ export async function rebroadcastEvents(
// If we're in flight mode (only local relays connected) and user wants to broadcast to all relays, skip
if (broadcastToAll && !hasRemoteConnection) {
console.log('✈️ Flight mode: skipping rebroadcast to remote relays')
return
}
@@ -50,7 +49,6 @@ export async function rebroadcastEvents(
}
if (targetRelays.length === 0) {
console.log('📡 No target relays for rebroadcast')
return
}
@@ -58,7 +56,6 @@ export async function rebroadcastEvents(
const rebroadcastPromises = events.map(async (event) => {
try {
await relayPool.publish(targetRelays, event)
console.log('📡 Rebroadcast event', event.id?.slice(0, 8), 'to', targetRelays.length, 'relay(s)')
} catch (error) {
console.warn('⚠️ Failed to rebroadcast event', event.id?.slice(0, 8), error)
}
@@ -68,11 +65,5 @@ export async function rebroadcastEvents(
Promise.all(rebroadcastPromises).catch((err) => {
console.warn('⚠️ Some rebroadcasts failed:', err)
})
console.log(`📡 Rebroadcasting ${events.length} event(s) to ${targetRelays.length} relay(s)`, {
broadcastToAll,
useLocalCache,
targetRelays
})
}

View File

@@ -40,11 +40,7 @@ export function updateAndGetRelayStatuses(relayPool: RelayPool): RelayStatus[] {
const connectedCount = statuses.filter(s => s.isInPool).length
const disconnectedCount = statuses.filter(s => !s.isInPool).length
if (connectedCount === 0 || disconnectedCount > 0) {
console.log(`🔌 Relay status: ${connectedCount} connected, ${disconnectedCount} disconnected`)
const connected = statuses.filter(s => s.isInPool).map(s => s.url.replace(/^wss?:\/\//, ''))
const disconnected = statuses.filter(s => !s.isInPool).map(s => s.url.replace(/^wss?:\/\//, ''))
if (connected.length > 0) console.log('✅ Connected:', connected.join(', '))
if (disconnected.length > 0) console.log('❌ Disconnected:', disconnected.join(', '))
// Debug: relay status changed, but we're not logging it
}
// Add recently seen relays that are no longer connected

View File

@@ -61,6 +61,8 @@ export interface UserSettings {
// Reading position sync
syncReadingPosition?: boolean // default: false (opt-in)
autoMarkAsReadOnCompletion?: boolean // default: false (opt-in)
// Bookmark filtering
hideBookmarksWithoutCreationDate?: boolean // default: false
}
export async function loadSettings(
@@ -69,7 +71,6 @@ export async function loadSettings(
pubkey: string,
relays: string[]
): Promise<UserSettings | null> {
console.log('⚙️ Loading settings from nostr...', { pubkey: pubkey.slice(0, 8) + '...', relays })
// First, check if we already have settings in the local event store
try {
@@ -78,7 +79,6 @@ export async function loadSettings(
)
if (localEvent) {
const content = getAppDataContent<UserSettings>(localEvent)
console.log('✅ Settings loaded from local store (cached):', content)
// Still fetch from relays in the background to get any updates
relayPool
@@ -92,8 +92,8 @@ export async function loadSettings(
return content || null
}
} catch (err) {
console.log('📭 No cached settings found, fetching from relays...')
} catch (_err) {
// Ignore local store errors
}
// If not in local store, fetch from relays
@@ -125,10 +125,8 @@ export async function loadSettings(
)
if (event) {
const content = getAppDataContent<UserSettings>(event)
console.log('✅ Settings loaded from relays:', content)
resolve(content || null)
} else {
console.log('📭 No settings event found - using defaults')
resolve(null)
}
} catch (err) {
@@ -159,7 +157,6 @@ export async function saveSettings(
factory: EventFactory,
settings: UserSettings
): Promise<void> {
console.log('💾 Saving settings to nostr:', settings)
// Create NIP-78 application data event manually
// Note: AppDataBlueprint is not available in the npm package
@@ -175,7 +172,6 @@ export async function saveSettings(
// Use unified write service
await publishEvent(relayPool, eventStore, signed)
console.log('✅ Settings published successfully')
}
export function watchSettings(

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

@@ -78,7 +78,6 @@ export async function createWebBookmark(
// Publish to relays in the background (don't block UI)
relayPool.publish(relays, signedEvent)
.then(() => {
console.log('✅ Web bookmark published to', relays.length, 'relays:', signedEvent)
})
.catch((err) => {
console.warn('⚠️ Some relays failed to publish bookmark:', err)

View File

@@ -19,7 +19,6 @@ export async function publishEvent(
// Store the event in the local EventStore FIRST for immediate UI display
eventStore.add(event)
console.log(`${logPrefix} 💾 Stored event in EventStore:`, event.id.slice(0, 8), `(kind ${event.kind})`)
// Check current connection status - are we online or in flight mode?
const connectedRelays = Array.from(relayPool.relays.values())
@@ -35,14 +34,7 @@ export async function publishEvent(
const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays)
console.log(`${logPrefix} 📍 Event relay status:`, {
targetRelays: RELAYS.length,
expectedSuccessRelays: expectedSuccessRelays.length,
isLocalOnly,
hasRemoteConnection,
eventId: event.id.slice(0, 8),
connectedRelays: connectedRelays.length
})
// Publishing event
// If we're in local-only mode, mark this event for later sync
if (isLocalOnly) {
@@ -50,10 +42,8 @@ export async function publishEvent(
}
// Publish to all configured relays in the background (non-blocking)
console.log(`${logPrefix} 📤 Publishing to relays:`, RELAYS)
relayPool.publish(RELAYS, event)
.then(() => {
console.log(`${logPrefix} ✅ Event published to`, RELAYS.length, 'relay(s):', event.id.slice(0, 8))
})
.catch((error) => {
console.warn(`${logPrefix} ⚠️ Failed to publish event to relays (event still saved locally):`, error)

View File

@@ -138,7 +138,6 @@ class WritingsController {
// Skip if already loaded for this pubkey (unless forced)
if (!force && this.isLoadedFor(pubkey)) {
console.log('[writings] ✅ Already loaded for', pubkey.slice(0, 8))
this.emitWritings(this.currentPosts)
return
}
@@ -148,7 +147,6 @@ class WritingsController {
const currentGeneration = this.generation
this.setLoading(true)
console.log('[writings] 🔍 Loading writings for', pubkey.slice(0, 8))
try {
const seenIds = new Set<string>()
@@ -162,7 +160,6 @@ class WritingsController {
}
if (lastSyncedAt) {
filter.since = lastSyncedAt
console.log('[writings] 📅 Incremental sync since', new Date(lastSyncedAt * 1000).toISOString())
}
const events = await queryEvents(
@@ -201,7 +198,6 @@ class WritingsController {
// Check if still active after async operation
if (currentGeneration !== this.generation) {
console.log('[writings] ⚠️ Load cancelled (generation mismatch)')
return
}
@@ -231,7 +227,6 @@ class WritingsController {
this.setLastSyncedAt(pubkey, newestTimestamp)
}
console.log('[writings] ✅ Loaded', sorted.length, 'writings')
} catch (error) {
console.error('[writings] ❌ Failed to load writings:', error)
this.currentPosts = []

View File

@@ -22,7 +22,6 @@ export async function fetchBorisZappers(
relayPool: RelayPool
): Promise<ZapSender[]> {
try {
console.log('⚡ Fetching zap receipts for Boris...', BORIS_PUBKEY)
// Use all configured relays plus specific zap-heavy relays
const zapRelays = [
@@ -63,23 +62,18 @@ export async function fetchBorisZappers(
merge(local$, remote$).pipe(toArray())
)
console.log(`📊 Fetched ${zapReceipts.length} raw zap receipts`)
// Dedupe by event ID and validate
const uniqueReceipts = new Map<string, NostrEvent>()
let invalidCount = 0
zapReceipts.forEach(receipt => {
if (!uniqueReceipts.has(receipt.id)) {
if (isValidZap(receipt)) {
uniqueReceipts.set(receipt.id, receipt)
} else {
invalidCount++
}
}
})
console.log(`${uniqueReceipts.size} valid zap receipts (${invalidCount} invalid)`)
// Aggregate by sender using applesauce helpers
const senderTotals = new Map<string, { totalSats: number; zapCount: number }>()
@@ -102,7 +96,6 @@ export async function fetchBorisZappers(
})
}
console.log(`👥 Found ${senderTotals.size} unique senders`)
// Filter >= 2100 sats, mark whales >= 69420 sats, sort by total desc
const zappers: ZapSender[] = Array.from(senderTotals.entries())
@@ -115,7 +108,6 @@ export async function fetchBorisZappers(
}))
.sort((a, b) => b.totalSats - a.totalSats)
console.log(`✅ Found ${zappers.length} supporters (${zappers.filter(z => z.isWhale).length} whales)`)
return zappers
} catch (error) {

View File

@@ -32,7 +32,7 @@
.individual-bookmarks h4 { margin: 0 0 1rem 0; font-size: 1rem; color: var(--color-text); }
.bookmarks-grid { display: flex; flex-direction: column; gap: 1rem; width: 100%; max-width: 100%; }
.bookmarks-grid.bookmarks-compact { gap: 0.5rem; }
.bookmarks-grid.bookmarks-compact { gap: 0.25rem; }
.bookmarks-grid.bookmarks-large { gap: 1.5rem; }
@media (max-width: 768px) {
.bookmarks-grid { gap: 0.75rem; }
@@ -44,9 +44,9 @@
.individual-bookmark:hover { border-color: var(--color-border); background: var(--color-bg-elevated); }
/* Compact view */
.individual-bookmark.compact { padding: 0.5rem 0.5rem; background: transparent; border: none !important; border-radius: 0; box-shadow: none; width: 100%; max-width: 100%; overflow: hidden; }
.individual-bookmark.compact { padding: 0.25rem 0.5rem; background: transparent; border: none !important; border-radius: 0; box-shadow: none; width: 100%; max-width: 100%; overflow: hidden; }
.individual-bookmark.compact:hover { background: var(--color-bg-elevated); transform: none; box-shadow: none; border: none !important; }
.compact-row { display: flex; align-items: center; gap: 0.5rem; height: 28px; width: 100%; min-width: 0; overflow: hidden; }
.compact-row { display: flex; align-items: center; gap: 0.5rem; height: 24px; width: 100%; min-width: 0; overflow: hidden; }
.compact-thumbnail { width: 24px; height: 24px; flex-shrink: 0; border-radius: 4px; overflow: hidden; background: var(--color-bg-elevated); display: flex; align-items: center; justify-content: center; }
.compact-thumbnail img { width: 100%; height: 100%; object-fit: cover; }
.compact-row.clickable { cursor: pointer; }

View File

@@ -109,6 +109,16 @@
background: var(--color-bg-elevated) !important;
}
.bookmarks-list .individual-bookmark.compact {
border: none !important;
background: transparent !important;
}
.bookmarks-list .individual-bookmark.compact:hover {
border-color: transparent !important;
background: var(--color-bg-elevated) !important;
}
.bookmark-item {
padding: 1rem;
background: var(--color-bg);

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

@@ -22,7 +22,6 @@ cleanupOutdatedCaches()
sw.skipWaiting()
clientsClaim()
console.log('[SW] Boris service worker loaded')
// Runtime cache: Cross-origin images
// This preserves the existing image caching behavior

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)
@@ -118,6 +118,16 @@ export function hasContent(bookmark: IndividualBookmark): boolean {
return hasValidContent || hasId
}
// Check if bookmark has a real creation date (not "Now" / current time)
export function hasCreationDate(bookmark: IndividualBookmark): boolean {
if (!bookmark.created_at) return false
// If timestamp is missing or equals current time (within 1 second), consider it invalid
const now = Math.floor(Date.now() / 1000)
const createdAt = Math.floor(bookmark.created_at)
// If created_at is within 1 second of now, it's likely missing/placeholder
return Math.abs(createdAt - now) > 1
}
// Bookmark sets helpers (kind 30003)
export interface BookmarkSet {
name: string

View File

@@ -16,18 +16,15 @@ const loadingFonts = new Map<string, Promise<void>>()
export async function loadFont(fontKey: string): Promise<void> {
if (fontKey === 'system') {
console.log('📝 Using system font')
return Promise.resolve()
}
if (loadedFonts.has(fontKey)) {
console.log('✅ Font already loaded:', fontKey)
return Promise.resolve()
}
// If font is currently loading, return the existing promise
if (loadingFonts.has(fontKey)) {
console.log('⏳ Font already loading:', fontKey)
return loadingFonts.get(fontKey)!
}
@@ -37,7 +34,6 @@ export async function loadFont(fontKey: string): Promise<void> {
return Promise.resolve()
}
console.log('🔤 Loading font:', fontFamily)
// Create a promise for this font loading
const loadPromise = new Promise<void>((resolve) => {
@@ -48,7 +44,6 @@ export async function loadFont(fontKey: string): Promise<void> {
// Wait for the stylesheet to load
link.onload = () => {
console.log('📄 Stylesheet loaded for:', fontFamily)
// Use Font Loading API to wait for the actual font to be ready
if ('fonts' in document) {
@@ -56,7 +51,6 @@ export async function loadFont(fontKey: string): Promise<void> {
document.fonts.load(`400 16px "${fontFamily}"`),
document.fonts.load(`700 16px "${fontFamily}"`)
]).then(() => {
console.log('✅ Font ready:', fontFamily)
loadedFonts.add(fontKey)
loadingFonts.delete(fontKey)
resolve()
@@ -69,7 +63,6 @@ export async function loadFont(fontKey: string): Promise<void> {
} else {
// Fallback: just wait a bit for older browsers
setTimeout(() => {
console.log('✅ Font assumed ready (no Font Loading API):', fontFamily)
loadedFonts.add(fontKey)
loadingFonts.delete(fontKey)
resolve()

View File

@@ -1,4 +1,6 @@
// Extract pubkeys from nprofile strings in content
import { READING_PROGRESS } from '../config/kinds'
export const extractNprofilePubkeys = (content: string): string[] => {
const nprofileRegex = /nprofile1[a-z0-9]+/gi
const matches = content.match(nprofileRegex) || []
@@ -123,3 +125,14 @@ export function createParallelReqStreams(
return { local$, remote$ }
}
/**
* Checks if content is long enough to track reading progress
* Minimum 1000 characters (roughly 150 words)
*/
export const shouldTrackReadingProgress = (html: string | undefined, markdown: string | undefined): boolean => {
const content = (html || markdown || '').trim()
// Strip HTML tags to get character count
const plainText = content.replace(/<[^>]*>/g, '').trim()
return plainText.length >= READING_PROGRESS.MIN_CONTENT_LENGTH
}

View File

@@ -10,14 +10,9 @@ export function applyHighlightsToHTML(
highlightStyle: 'marker' | 'underline' = 'marker'
): string {
if (!html || highlights.length === 0) {
console.log('⚠️ applyHighlightsToHTML: No HTML or highlights', {
htmlLength: html?.length,
highlightsCount: highlights.length
})
return html
}
console.log('🔨 applyHighlightsToHTML: Processing', highlights.length, 'highlights')
const tempDiv = document.createElement('div')
tempDiv.innerHTML = html
@@ -31,9 +26,6 @@ export function applyHighlightsToHTML(
mark.parentNode?.replaceChild(textNode, mark)
})
console.log('🧹 Removed', existingMarks.length, 'existing highlight marks')
let appliedCount = 0
for (const highlight of highlights) {
const searchText = highlight.content.trim()
@@ -42,7 +34,6 @@ export function applyHighlightsToHTML(
continue
}
console.log('🔍 Searching for highlight:', searchText.substring(0, 50) + '...')
// Collect all text nodes
const walker = document.createTreeWalker(tempDiv, NodeFilter.SHOW_TEXT, null)
@@ -50,21 +41,16 @@ export function applyHighlightsToHTML(
let node: Node | null
while ((node = walker.nextNode())) textNodes.push(node as Text)
console.log('📄 Found', textNodes.length, 'text nodes to search')
// Try exact match first, then normalized match
const found = tryMarkInTextNodes(textNodes, searchText, highlight, false, highlightStyle) ||
tryMarkInTextNodes(textNodes, searchText, highlight, true, highlightStyle)
if (found) {
appliedCount++
console.log('✅ Highlight applied successfully')
} else {
if (!found) {
console.warn('❌ Could not find match for highlight:', searchText.substring(0, 50))
}
}
console.log('🎉 Applied', appliedCount, '/', highlights.length, 'highlights')
return tempDiv.innerHTML
}

View File

@@ -1,26 +1,74 @@
import { ReadItem } from '../services/readsService'
import { ReadingProgressFilterType } from '../components/ReadingProgressFilters'
import { Highlight } from '../types/highlights'
import { nip19 } from 'nostr-tools'
/**
* Filters ReadItems by reading progress
*/
export function filterByReadingProgress(
items: ReadItem[],
filter: ReadingProgressFilterType
filter: ReadingProgressFilterType,
highlights?: Highlight[]
): ReadItem[] {
// Build a map of article references to highlight count
// Normalize both coordinate and naddr formats for matching
const articleHighlightCount = new Map<string, number>()
if (highlights) {
highlights.forEach(h => {
if (h.eventReference) {
// eventReference could be a hex ID or a coordinate (30023:pubkey:identifier)
let normalizedRef = h.eventReference
// If it's a coordinate, convert to naddr format for matching
if (h.eventReference.includes(':')) {
const parts = h.eventReference.split(':')
if (parts.length === 3) {
const [kind, pubkey, identifier] = parts
try {
normalizedRef = nip19.naddrEncode({
kind: parseInt(kind),
pubkey,
identifier
})
} catch {
// If conversion fails, use the original reference
normalizedRef = h.eventReference
}
}
}
const count = articleHighlightCount.get(normalizedRef) || 0
articleHighlightCount.set(normalizedRef, count + 1)
}
if (h.urlReference) {
const count = articleHighlightCount.get(h.urlReference) || 0
articleHighlightCount.set(h.urlReference, count + 1)
}
})
}
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':
default:
return true

View File

@@ -43,8 +43,7 @@ export function applyTheme(
root.classList.add(`light-${lightColorTheme}`)
// Listen for system theme changes
mediaQueryListener = (e: MediaQueryListEvent) => {
console.log('🎨 System theme changed to:', e.matches ? 'dark' : 'light')
mediaQueryListener = () => {
// The CSS media query handles the color changes automatically
}
@@ -59,5 +58,4 @@ export function applyTheme(
}
}
console.log('🎨 Applied theme:', theme, 'with colors:', { dark: darkColorTheme, light: lightColorTheme })
}

View File

@@ -11,26 +11,21 @@ export function normalizeUrl(url: string): string {
export function filterHighlightsByUrl(highlights: Highlight[], selectedUrl: string | undefined): Highlight[] {
if (!selectedUrl || highlights.length === 0) {
console.log('🔍 filterHighlightsByUrl: No URL or highlights', { selectedUrl, count: highlights.length })
return []
}
console.log('🔍 filterHighlightsByUrl:', { selectedUrl, totalHighlights: highlights.length })
// For Nostr articles, we already fetched highlights specifically for this article
// So we don't need to filter them - they're all relevant
if (selectedUrl.startsWith('nostr:')) {
console.log('📌 Nostr article - returning all', highlights.length, 'highlights')
return highlights
}
// For web URLs, filter by URL matching
const normalizedSelected = normalizeUrl(selectedUrl)
console.log('🔗 Normalized selected URL:', normalizedSelected)
const filtered = highlights.filter(h => {
if (!h.urlReference) {
console.log('⚠️ Highlight has no urlReference:', h.id, 'eventReference:', h.eventReference)
return false
}
const normalizedRef = normalizeUrl(h.urlReference)
@@ -39,14 +34,13 @@ export function filterHighlightsByUrl(highlights: Highlight[], selectedUrl: stri
normalizedRef.includes(normalizedSelected)
if (matches) {
console.log('✅ URL match:', normalizedRef)
// URLs match
} else {
console.log('❌ URL mismatch:', normalizedRef, 'vs', normalizedSelected)
// URLs do not match
}
return matches
})
console.log('📊 Filtered to', filtered.length, 'highlights')
return filtered
}