Compare commits

...

96 Commits

Author SHA1 Message Date
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
Gigi
81a4ae392f bump: release version 0.8.0 2025-10-19 22:09:13 +02:00
Gigi
6e438b8ee2 Merge pull request #21 from dergigi/reading-progress-nip
feat: implement NIP-85 reading progress tracking
2025-10-19 22:08:04 +02:00
Gigi
31974e7271 feat(reading): 2s completion hold at 100% + reliable auto mark-as-read
- Add completionHoldMs (default 2000ms) to useReadingPosition
- Start hold timer when position hits 100%; cancel if user scrolls up
- Fallback to threshold completion when configured
- Clears timers on unmount/disable
2025-10-19 16:17:17 +02:00
Gigi
676be1a932 feat: make reading position sync default-on in runtime paths
- Treat undefined as enabled in ContentPanel (only false disables)
- Keeps DEFAULT_SETTINGS at true; ensures consistent behavior even for users without the new setting persisted yet
2025-10-19 16:15:43 +02:00
Gigi
9883f2eb1a chore(settings): tweak label for auto mark-as-read (remove animation note) 2025-10-19 16:13:56 +02:00
Gigi
87e46be86f feat(settings): restore 'auto mark as read at 100%' option
- Added autoMarkAsReadOnCompletion to default settings (disabled by default)
- Added toggle in Layout & Behavior section
- Existing ContentPanel logic already hooks into this to trigger animation & mark-as-read
2025-10-19 16:07:59 +02:00
Gigi
b745a92a7e feat: allow saving 0% reading position and initial save
- Remove low-position guard; allow 0% saves
- One-time initial save even without significant change
- Always allow immediate save regardless of position
- Fix linter empty-catch warnings in readingProgressController
2025-10-19 16:03:34 +02:00
Gigi
5a79da4024 feat: persist reading progress in localStorage per pubkey
- Seed controller state from cache on start for instant display after refresh
- Persist updated progress map after processing events
- Keeps progress visible even without immediate relay responses
2025-10-19 15:59:01 +02:00
Gigi
a7d05a29f5 feat: process local reading progress via eventStore.timeline()
- Subscribe to timeline for immediate local events and reactive updates
- Clean up timeline subscription on reset/start to avoid leaks
- Keep relay sync for background augmentation
- Should populate progress map even without relay roundtrip
2025-10-19 12:29:44 +02:00
Gigi
0740d53d37 fix: resolve all linter warnings
- Add proper types (Filter, NostrEvent) to readingProgressController
- Add eslint-disable comment for position dependency in useReadingPosition
  (position is derived from scroll and including it would cause infinite re-renders)
- All lint warnings resolved
- TypeScript type checks pass
2025-10-19 12:27:19 +02:00
Gigi
914738abb4 fix: force full sync when map is empty
- If currentProgressMap is empty, do a full sync (no 'since' filter)
- This ensures first load gets all events, not just recent ones
- Incremental sync only happens when we already have data
- This was the bug: lastSynced was preventing initial load of events
2025-10-19 12:18:46 +02:00
Gigi
4fac5f42c9 fix: remove broken timeline subscription, rely on queryEvents
- Timeline subscription is async and emits empty array first
- queryEvents already checks local store then relays
- Simpler and actually works correctly
- This is how all other controllers work (highlights, bookmarks, etc.)
2025-10-19 12:17:38 +02:00
Gigi
16b3668e73 debug: add logs to trace why events aren't processed
- Log sample event to see format
- Log map size after processEvents to see if it worked
- This will show if processEvents is failing silently
2025-10-19 12:13:45 +02:00
Gigi
f3a83256a8 debug: improve timeline subscription and add more logs
- Capture events from timeline before unsubscribing
- Add log to show when timeline emits
- Add log after unsubscribe to show what we got
- This will help debug why processEvents isn't being called
2025-10-19 12:10:53 +02:00
Gigi
0e98ddeef4 fix: use eventStore.timeline() to query local events
- Subscribe to timeline to get initial cached events
- Unsubscribe immediately after reading initial value
- This works with IEventStore interface correctly
2025-10-19 12:04:49 +02:00
Gigi
1ba375e93e fix: load reading progress from event store first (non-blocking)
- Query local event store immediately for instant display
- Then augment with relay data in background
- This matches how bookmarks work: local-first, then sync
- Events saved locally now appear immediately without waiting for relay propagation
2025-10-19 12:03:36 +02:00
Gigi
5d14d25d0e debug: add detailed logging to Profile component
- Show initial map size and updates in Profile
- Log lookups with map contents in Profile
- Helps debug reading progress on profile pages
2025-10-19 12:02:22 +02:00
Gigi
616038a23a debug: reduce log spam and show map size in lookups
- Only log when progress found or map is empty
- Show map size to quickly diagnose empty map issue
- Show first 3 map keys as sample instead of all
2025-10-19 11:59:37 +02:00
Gigi
14fce2c3dc debug: add detailed naddr comparison logs
- Show all map keys when looking up reading progress
- Show d-tag generation from naddr in save flow
- This will help identify if naddr encoding/decoding is causing mismatch
2025-10-19 11:56:27 +02:00
Gigi
7c511de474 feat: enable reading position sync by default
- Changed syncReadingPosition default from false to true in Settings.tsx
- Users can still disable it in settings if they prefer
- This ensures reading progress tracking works out of the box
2025-10-19 11:52:05 +02:00
Gigi
3a10ac8691 debug: add logs to show why reading position saves are skipped
- Log when scheduleSave returns early (syncEnabled false, no onSave callback)
- Log when position is too low (<5%)
- Log when change is not significant enough (<1%)
- Log ContentPanel sync status (enabled, settings, requirements)
- This will help diagnose why no events are being created
2025-10-19 11:41:38 +02:00
Gigi
205879f948 debug: add comprehensive logging for reading position calculation and event publishing
- Add logs in useReadingPosition: scroll position calculation (throttled to 5% changes)
- Add logs for scheduling and triggering auto-save
- Add detailed logs in ContentPanel handleSavePosition
- Add logs in saveReadingPosition: event creation, signing, publishing
- Add logs in publishEvent: event store addition, relay status, publishing
- All logs prefixed with [progress] for easy filtering
- Shows complete flow from scroll → calculate → save → create event → publish to relays
2025-10-19 11:39:25 +02:00
Gigi
bff43f4a28 debug: add comprehensive [progress] logging throughout reading progress flow
- Add logs in readingProgressController: processing events, emitting to listeners
- Add logs in Explore component: receiving updates, looking up progress
- Add logs in BlogPostCard: rendering with progress
- Add detailed logs in processReadingProgress: event parsing, naddr conversion
- All logs prefixed with [progress] for easy filtering
2025-10-19 11:30:57 +02:00
Gigi
2a7fffd594 fix: remove invalid eventStore.list() call in reading progress controller
- EventStore doesn't have a list() method
- Follow same pattern as highlightsController and just fetch from relays
- Fixes TypeError: eventStore.list is not a function
2025-10-19 11:18:21 +02:00
Gigi
50a4161e16 feat: reset reading progress controller on logout
- Add readingProgressController.reset() to handleLogout in App.tsx
- Ensures reading progress data is cleared when user logs out
- Consistent with other controllers (bookmarks, contacts, highlights)
2025-10-19 11:08:33 +02:00
Gigi
5fd8976097 refactor: create centralized reading progress controller
- Add readingProgressController following the same pattern as highlightsController and writingsController
- Controller manages reading progress (kind:39802) centrally with subscriptions
- Remove duplicated reading progress loading logic from Explore, Profile, and Me components
- Components now subscribe to controller updates instead of loading data individually
- Supports incremental sync and force reload
- Improves efficiency and maintainability
2025-10-19 11:06:57 +02:00
Gigi
80b26abff2 feat: add reading progress indicators to blog post cards
- Add reading progress loading and display in Explore component
- Add reading progress loading and display in Profile component
- Add reading progress loading and display in Me writings tab
- Reading progress now shows as colored progress bar in all blog post cards
- Progress colors: gray (started 0-10%), blue (reading 10-95%), green (completed 95%+)
2025-10-19 11:02:20 +02:00
Gigi
c0638851c6 docs: simplify NIP-85 to match NIP-84 style and length
- Remove verbose rationale section
- Remove excessive querying examples
- Remove privacy considerations (obvious)
- Remove implementation notes fluff
- Remove references section
- Keep only essential: format, tags, content, examples
- Match NIP-84's concise, to-the-point style

From 190 lines down to ~75 lines - much more readable
2025-10-19 10:54:31 +02:00
Gigi
9b6b14cfe8 refactor: remove client tag from reading progress events
- Remove 'client' tag from NIP-85 specification
- Remove 'client' tag from code implementation
- Align with Nostr principles of client-agnostic data
- Follow NIP-84 pattern which doesn't include client tags

Events should be client-agnostic and not include branding/tracking.
2025-10-19 10:46:44 +02:00
Gigi
b6ad62a3ab refactor: rename to NIP-85 (kind 39802 for reading progress)
- Rename NIP-39802.md to NIP-85.md
- Update all references from NIP-39802 to NIP-85 in code comments
- Add Table of Contents to NIP document
- Update kinds.ts to reference NIP-85 and NIP-84 (highlights)
- Maintain kind number 39802 for the event type

NIP-85 is the specification number, 39802 is the event kind number.
2025-10-19 10:41:02 +02:00
Gigi
85d87bac29 docs: improve NIP-39802 with URL cleaning guidance from NIP-84
- Add recommendation to clean URLs from tracking parameters
- Add URL Handling subsection with best practices
- Ensure same article from different sources maps to same progress
- Inspired by NIP-84 (Highlights) URL handling guidelines
2025-10-19 10:38:28 +02:00
Gigi
3b31eceeab feat: improve reading progress with validation and auto-mark
- Add autoMarkAsReadOnCompletion setting (opt-in, default: false)
- Implement auto-mark as read when reaching 95%+ completion
- Add validation for progress bounds (0-1) per NIP-39802 spec
- Align completion threshold to 95% to match filter behavior
- Skip invalid progress events with warning log

Improvements ensure consistency between completion detection and
filtering, while adding safety validation per the NIP spec.
2025-10-19 10:34:53 +02:00
Gigi
442c138d6a refactor: simplify NIP-39802 implementation - remove migration complexity
- Remove dual-write logic: only write kind 39802
- Remove legacy kind 30078 read fallback
- Remove migration settings flags (useReadingProgressKind, writeLegacyReadingPosition)
- Simplify readingPositionService: single write/read path
- Remove processReadingPositions() legacy processor
- Update readsService and linksService to only query kind 39802
- Simplify NIP-39802 spec: remove migration section
- Delete READING_PROGRESS_MIGRATION.md (not needed for unreleased app)
- Clean up imports and comments

No backward compatibility needed since app hasn't been released yet.
2025-10-19 10:14:37 +02:00
Gigi
61e6027252 docs: add migration guide and test documentation for NIP-39802
- Create READING_PROGRESS_MIGRATION.md with detailed migration phases
- Document test scenarios inline in readingPositionService and readingDataProcessor
- Outline timeline for dual-write, prefer-new, and deprecation phases
- Add rollback plan and settings API documentation
- Include comparison table of legacy vs new event formats
2025-10-19 10:10:18 +02:00
Gigi
7d373015b4 feat: implement NIP-39802 reading progress with dual-write migration
- Add kind 39802 (ReadingProgress) as dedicated parameterized replaceable event
- Create NIP-39802 specification document in public/md/
- Implement dual-write: publish both kind 39802 and legacy kind 30078
- Implement dual-read: prefer kind 39802, fall back to kind 30078
- Add migration flags to settings (useReadingProgressKind, writeLegacyReadingPosition)
- Update readingPositionService with new d-tag generation and tag helpers
- Add processReadingProgress() for kind 39802 events in readingDataProcessor
- Update readsService and linksService to query and process both kinds
- Use event.created_at as authoritative timestamp per NIP-39802 spec
- ContentPanel respects migration flags from settings
- Maintain backward compatibility during migration phase
2025-10-19 10:09:09 +02:00
Gigi
32b1286079 chore: remove [bookmark] debug logs
- Remove all console.log statements with [bookmark] prefix from App.tsx
- Remove all console.log statements with [bookmark] prefix from bookmarkController.ts
- Replace verbose error logging with simple error messages
- Keep code clean and reduce console clutter
2025-10-19 01:43:25 +02:00
Gigi
17fdd92827 fix(profile): fetch all writings for profile pages by removing limit
- Make limit parameter configurable in fetchBlogPostsFromAuthors
- Default limit is 100 for Explore page (multiple authors)
- Pass null limit for Profile pages to fetch all writings
- Fixes issue where only 1 writing was shown instead of all
2025-10-19 01:35:00 +02:00
Gigi
aa6aeb2723 refactor: split Me into Me and Profile components for simpler /p/ pages
- Create Profile.tsx for viewing other users (highlights + writings only)
- Profile uses useStoreTimeline for instant cache-first display
- Background fetches populate event store non-blocking
- Extract toBlogPostPreview helper for reuse
- Simplify Me.tsx to only handle own profile (/me routes)
- Remove isOwnProfile branching and cached data logic from Me
- Update Bookmarks.tsx to render Profile for /p/ routes
- Keep code DRY and files under 210 lines
2025-10-19 01:28:22 +02:00
Gigi
4b0f275f57 docs: update CHANGELOG.md for v0.7.4 release 2025-10-19 01:21:38 +02:00
Gigi
73e2e060e3 chore: bump version to 0.7.4 2025-10-19 01:19:10 +02:00
Gigi
3007ae83c2 fix(profile): display cached highlights and writings instantly, fetch fresh in background 2025-10-19 01:17:35 +02:00
Gigi
a862eb880e feat(profile): preload all highlights and writings into event store 2025-10-19 01:15:01 +02:00
Gigi
016e369fb1 feat(highlights): only show nostrverse filter when logged out 2025-10-19 01:09:39 +02:00
Gigi
4f21982c48 feat(me): show bookmarks in cards view on /me/bookmarks tab 2025-10-19 01:08:42 +02:00
Gigi
f6d3fe9aba docs: update CHANGELOG.md for v0.7.3 release 2025-10-19 01:06:19 +02:00
Gigi
fc60e6b80a chore: bump version to 0.7.3 2025-10-19 01:04:48 +02:00
Gigi
d9cdbb7279 Merge pull request #20 from dergigi/writings-controller
Make Explore non-blocking; centralize nostrverse highlights & writings controllers
2025-10-19 01:03:25 +02:00
Gigi
401d333e0f fix(explore): logged-out mode relies solely on centralized nostrverse controllers; start controllers even when logged out 2025-10-19 00:58:07 +02:00
Gigi
d32a47e3c3 perf(explore): make loading fully non-blocking; seed caches then stream and merge results progressively 2025-10-19 00:55:24 +02:00
Gigi
35efdb6d3f feat(nostrverse): add nostrverseWritingsController and subscribe in Explore; start controller at app init 2025-10-19 00:52:32 +02:00
Gigi
c7f7792d73 feat(highlights): add centralized nostrverseHighlightsController; start at app init; Explore subscribes to controller stream 2025-10-19 00:50:12 +02:00
Gigi
8aa26caae0 feat(explore): show skeletons instead of spinner; keep nostrverse non-blocking and stream into view 2025-10-19 00:48:24 +02:00
Gigi
6c00904bd5 fix(explore,nostrverse): never block explore highlights on nostrverse; show empty state instead of spinner and stream results into store immediately 2025-10-19 00:46:16 +02:00
Gigi
23526954ea fix(explore): reflect settings default scope immediately and avoid blank lists; preload/merge nostrverse from event store and keep fetches non-blocking 2025-10-19 00:42:39 +02:00
Gigi
9a437dd97b fix(explore): ensure nostrverse highlights are loaded and merged; preload nostrverse highlights at app start for instant Explore toggle 2025-10-19 00:38:05 +02:00
Gigi
0baf75462c refactor(explore): use writingsController for 'mine' posts; keep fetches non-blocking and centralized 2025-10-19 00:34:21 +02:00
Gigi
30b8f1af92 feat(writings): auto-load user writings at login so Explore 'mine' tab has local data 2025-10-19 00:30:07 +02:00
Gigi
07aea9d35f fix(explore): prevent disabling all explore scopes; ensure at least one filter remains active 2025-10-19 00:28:55 +02:00
Gigi
41a4abff37 fix(highlights): scope highlights to current article on /a and /r by deriving coordinate from naddr for early filtering, and ensure sidebar/content only show scoped highlights 2025-10-19 00:24:37 +02:00
Gigi
c9998984c3 feat(explore): include and stream my writings when enabled\n\n- Load my own writings in parallel with friends/nostrverse\n- Lazy-load on 'mine' toggle when logged in\n- Keep dedupe/sort consistent 2025-10-19 00:16:01 +02:00
Gigi
a799709e62 fix(explore): ensure writings are deduped by replaceable before visibility filtering and render 2025-10-19 00:14:20 +02:00
Gigi
18c6c3e68a fix(content): show only article-specific highlights in ContentPanel for nostr articles 2025-10-19 00:12:49 +02:00
Gigi
5e7395652f feat(explore): stream nostrverse writings when toggled on while logged in\n\n- Lazy-load nostrverse via onPost callback when filter is enabled\n- Avoid reloading twice using hasLoadedNostrverse guard\n- Keep DRY dedupe/sort behavior 2025-10-19 00:08:06 +02:00
Gigi
83076e7b01 feat(explore): stream nostrverse writings to paint instantly\n\n- Add onPost streaming callback to fetchNostrverseBlogPosts\n- Stream posts in Explore when logged out and logged in\n- Keep final deduped/sorted list after stream completes 2025-10-19 00:04:53 +02:00
Gigi
c79f4122da feat(debug): add Writings Loading section to debug page
- Add handlers for loading my writings, friends writings, and nostrverse writings
- Display writings with title, summary, author, and d-tag
- Show timing metrics (total load time and first event time)
- Use writingsController for own writings to test controller functionality
2025-10-18 23:57:46 +02:00
Gigi
179fe0bbc2 fix(explore): prevent infinite loop when loading nostrverse content
- Remove cachedHighlights, cachedWritings, myHighlights from useEffect deps
- These are derived from eventStore and caused infinite refetch loop
- Content is still seeded from cache but doesn't trigger re-fetches
2025-10-18 23:54:02 +02:00
Gigi
20b4f2b1b2 fix(explore): fetch nostrverse content when logged out
- Allow exploring nostrverse writings and highlights without account
- Default to nostrverse visibility when logged out
- Update visibility settings when login state changes
2025-10-18 23:50:12 +02:00
Gigi
936f9093cf fix(me): use myWritingsLoading state in writings tab rendering 2025-10-18 23:45:16 +02:00
Gigi
3149e5b824 feat(services): add centralized writingsController for kind 30023 2025-10-18 23:43:16 +02:00
Gigi
8619cecaf3 docs: update CHANGELOG.md for v0.7.2 2025-10-18 23:32:10 +02:00
82 changed files with 2885 additions and 837 deletions

View File

@@ -7,6 +7,172 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [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
- Profile page data preloading for instant tab switching
- Automatically preloads all highlights and writings when viewing a profile (`/p/` pages)
- Non-blocking background fetch stores all events in event store
- Tab switching becomes instant after initial preload
### Changed
- `/me/bookmarks` tab now displays in cards view only
- Removed view mode toggle buttons (compact, large) from bookmarks tab
- Cards view provides optimal bookmark browsing experience
- Grouping toggle (grouped/flat) still available
- Highlights sidebar filters simplified when logged out
- Only nostrverse filter button shown when not logged in
- Friends and personal highlight filters hidden when logged out
- Cleaner UX showing only available options
### Fixed
- Profile page tabs now display cached content instantly
- Highlights and writings show immediately from event store cache
- Network fetches happen in background without blocking UI
- Matches Explore and Debug page non-blocking loading pattern
- Eliminated loading delays when switching between tabs
### Performance
- Cache-first profile loading strategy
- Instant display of cached highlights and writings from event store
- Background refresh updates data without blocking
- Tab switches show content immediately without loading states
## [0.7.3] - 2025-10-18
### Added
- Centralized nostrverse writings controller for kind 30023 content
- Automatically starts at app initialization
- Streams nostrverse blog posts progressively to Explore page
- Provides non-blocking, cache-first loading strategy
- Centralized nostrverse highlights controller
- Pre-loads nostrverse highlights at app start for instant toggling
- Streams highlights progressively to Explore page
- Integrated with EventStore for caching
- Writings loading debug section on `/debug` page
- Diagnostics for writings controller and loading states
### Changed
- Explore page now uses centralized `writingsController` for user's own writings
- Auto-loads user writings at login for instant availability
- Non-blocking fetch with progressive streaming
- Explore page loading strategy optimized
- Shows skeleton placeholders instead of blocking spinners
- Seeds from cache, then streams and merges results progressively
- Keeps nostrverse fetches non-blocking
- User's own writings now included in Explore when enabled
- Lazy-loads on 'mine' toggle when logged in
- Streams in parallel with friends/nostrverse content
### Fixed
- Explore page works correctly in logged-out mode
- Relies solely on centralized nostrverse controllers
- Controllers start even when logged out
- Fetches nostrverse content properly without authentication
- Explore page no longer allows disabling all scope filters
- Ensures at least one filter (mine/friends/nostrverse) remains active
- Prevents blank content state
- Explore page reflects default scope setting immediately
- No more blank lists on initial load
- Pre-loads and merges nostrverse from event store
- Explore page highlights properly scoped
- Nostrverse highlights never block the page
- Shows empty state instead of spinner
- Streams results into store immediately
- Highlights are merged and loaded correctly
- Article-specific highlights properly filtered
- Highlights scoped to current article on `/a/` and `/r/` routes
- Derives coordinate from naddr for early filtering
- Sidebar and content only show relevant highlights
- ContentPanel shows only article-specific highlights for nostr articles
- Explore writings properly deduplicated
- Deduplication by replaceable event (author:d-tag) happens before visibility filtering
- Consistent dedupe/sort behavior across all loading scenarios
- Debug page writings loading section added
- No infinite loop when loading nostrverse content
### Performance
- Non-blocking explore page loading
- Fully non-blocking loading strategy
- Seeds caches then streams and merges results progressively
- Lazy-loading for content filters
- Nostrverse writings lazy-load when toggled on while logged in
- Avoids redundant loading with guard flags
- Streaming callbacks for progressive updates
- Writings stream to UI via onPost callback
- Posts appear instantly as they arrive from cache or network
## [0.7.2] - 2025-01-27
### Added
- Cached-first loading with EventStore across the app
- Instant display of cached highlights and writings from local event store
- Progressive loading with streaming updates from relays
- Centralized event storage for improved performance and offline support
- Default explore scope setting for controlling content visibility
- Configurable default scope for explore page content
- Dedicated Explore section in settings for better organization
### Changed
- Highlights and writings now load from cache first, then stream from relays
- Explore page shows cached content instantly before network updates
- Article-specific highlights stored in centralized event store for faster access
- Nostrverse content cached locally for improved performance
### Fixed
- Prevent "No highlights yet" flash on `/me/highlights` page
- Force React to remount tab content when switching tabs for proper state management
- Deduplicate blog posts by author:d-tag instead of event ID for better accuracy
- Show skeleton placeholders while highlights are loading for better UX
### Performance
- Local-first loading strategy reduces perceived loading times
- Cached content displays immediately while background sync occurs
- Centralized event storage eliminates redundant network requests
## [0.7.0] - 2025-10-18 ## [0.7.0] - 2025-10-18
### Added ### Added
@@ -1878,7 +2044,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Optimize relay usage following applesauce-relay best practices - Optimize relay usage following applesauce-relay best practices
- Use applesauce-react event models for better profile handling - Use applesauce-react event models for better profile handling
[Unreleased]: https://github.com/dergigi/boris/compare/v0.7.0...HEAD [Unreleased]: https://github.com/dergigi/boris/compare/v0.8.0...HEAD
[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
[0.7.0]: https://github.com/dergigi/boris/compare/v0.6.24...v0.7.0 [0.7.0]: https://github.com/dergigi/boris/compare/v0.6.24...v0.7.0
[0.6.24]: https://github.com/dergigi/boris/compare/v0.6.23...v0.6.24 [0.6.24]: https://github.com/dergigi/boris/compare/v0.6.23...v0.6.24
[0.6.23]: https://github.com/dergigi/boris/compare/v0.6.22...v0.6.23 [0.6.23]: https://github.com/dergigi/boris/compare/v0.6.22...v0.6.23

View File

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

View File

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

75
public/md/NIP-85.md Normal file
View File

@@ -0,0 +1,75 @@
# NIP-85
## Reading Progress
`draft` `optional`
This NIP defines kind `39802`, a parameterized replaceable event for tracking reading progress across articles and web content.
## Table of Contents
* [Format](#format)
* [Tags](#tags)
* [Content](#content)
* [Examples](#examples)
## Format
Reading progress events use NIP-33 parameterized replaceable semantics. The `d` tag serves as the unique identifier per author and target content.
### Tags
Events SHOULD tag the source of the reading progress, whether nostr-native or not. `a` tags should be used for nostr events and `r` tags for URLs.
When tagging a URL, clients generating these events SHOULD do a best effort of cleaning the URL from trackers or obvious non-useful information from the query string.
- `d` (required): Unique identifier for the target content
- For Nostr articles: `30023:<pubkey>:<identifier>` (matching the article's coordinate)
- For external URLs: `url:<base64url-encoded-url>`
- `a` (optional but recommended for Nostr articles): Article coordinate `30023:<pubkey>:<identifier>`
- `r` (optional but recommended for URLs): Raw URL of the external content
### Content
The content is a JSON object with the following fields:
- `progress` (required): Number between 0 and 1 representing reading progress (0 = not started, 1 = completed)
- `loc` (optional): Number representing a location marker (e.g., pixel scroll position, page number, etc.)
- `ts` (optional): Unix timestamp (seconds) when the progress was recorded
- `ver` (optional): Schema version string
The latest event by `created_at` per (`pubkey`, `d`) pair is authoritative (NIP-33 semantics).
Clients SHOULD implement rate limiting to avoid excessive relay traffic (debounce writes, only save significant changes).
## Examples
### Nostr Article
```json
{
"kind": 39802,
"pubkey": "<user-pubkey>",
"created_at": 1734635012,
"content": "{\"progress\":0.66,\"loc\":1432,\"ts\":1734635012,\"ver\":\"1\"}",
"tags": [
["d", "30023:<author-pubkey>:<article-identifier>"],
["a", "30023:<author-pubkey>:<article-identifier>"]
]
}
```
### External URL
```json
{
"kind": 39802,
"pubkey": "<user-pubkey>",
"created_at": 1734635999,
"content": "{\"progress\":1,\"ts\":1734635999,\"ver\":\"1\"}",
"tags": [
["d", "url:aHR0cHM6Ly9leGFtcGxlLmNvbS9wb3N0"],
["r", "https://example.com/post"]
]
}
```

View File

@@ -23,6 +23,11 @@ import { Bookmark } from './types/bookmarks'
import { bookmarkController } from './services/bookmarkController' import { bookmarkController } from './services/bookmarkController'
import { contactsController } from './services/contactsController' import { contactsController } from './services/contactsController'
import { highlightsController } from './services/highlightsController' import { highlightsController } from './services/highlightsController'
import { writingsController } from './services/writingsController'
import { readingProgressController } from './services/readingProgressController'
// import { fetchNostrverseHighlights } from './services/nostrverseService'
import { nostrverseHighlightsController } from './services/nostrverseHighlightsController'
import { nostrverseWritingsController } from './services/nostrverseWritingsController'
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR || const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew' 'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
@@ -50,18 +55,14 @@ function AppRoutes({
// Subscribe to bookmark controller // Subscribe to bookmark controller
useEffect(() => { useEffect(() => {
console.log('[bookmark] 🎧 Subscribing to bookmark controller')
const unsubBookmarks = bookmarkController.onBookmarks((bookmarks) => { const unsubBookmarks = bookmarkController.onBookmarks((bookmarks) => {
console.log('[bookmark] 📥 Received bookmarks:', bookmarks.length)
setBookmarks(bookmarks) setBookmarks(bookmarks)
}) })
const unsubLoading = bookmarkController.onLoading((loading) => { const unsubLoading = bookmarkController.onLoading((loading) => {
console.log('[bookmark] 📥 Loading state:', loading)
setBookmarksLoading(loading) setBookmarksLoading(loading)
}) })
return () => { return () => {
console.log('[bookmark] 🔇 Unsubscribing from bookmark controller')
unsubBookmarks() unsubBookmarks()
unsubLoading() unsubLoading()
} }
@@ -69,18 +70,14 @@ function AppRoutes({
// Subscribe to contacts controller // Subscribe to contacts controller
useEffect(() => { useEffect(() => {
console.log('[contacts] 🎧 Subscribing to contacts controller')
const unsubContacts = contactsController.onContacts((contacts) => { const unsubContacts = contactsController.onContacts((contacts) => {
console.log('[contacts] 📥 Received contacts:', contacts.size)
setContacts(contacts) setContacts(contacts)
}) })
const unsubLoading = contactsController.onLoading((loading) => { const unsubLoading = contactsController.onLoading((loading) => {
console.log('[contacts] 📥 Loading state:', loading)
setContactsLoading(loading) setContactsLoading(loading)
}) })
return () => { return () => {
console.log('[contacts] 🔇 Unsubscribing from contacts controller')
unsubContacts() unsubContacts()
unsubLoading() unsubLoading()
} }
@@ -94,31 +91,50 @@ function AppRoutes({
// Load bookmarks // Load bookmarks
if (bookmarks.length === 0 && !bookmarksLoading) { if (bookmarks.length === 0 && !bookmarksLoading) {
console.log('[bookmark] 🚀 Auto-loading bookmarks on mount/login')
bookmarkController.start({ relayPool, activeAccount, accountManager }) bookmarkController.start({ relayPool, activeAccount, accountManager })
} }
// Load contacts // Load contacts
if (pubkey && contacts.size === 0 && !contactsLoading) { if (pubkey && contacts.size === 0 && !contactsLoading) {
console.log('[contacts] 🚀 Auto-loading contacts on mount/login')
contactsController.start({ relayPool, pubkey }) contactsController.start({ relayPool, pubkey })
} }
// Load highlights (controller manages its own state) // Load highlights (controller manages its own state)
if (pubkey && eventStore && !highlightsController.isLoadedFor(pubkey)) { if (pubkey && eventStore && !highlightsController.isLoadedFor(pubkey)) {
console.log('[highlights] 🚀 Auto-loading highlights on mount/login')
highlightsController.start({ relayPool, eventStore, pubkey }) highlightsController.start({ relayPool, eventStore, pubkey })
} }
// Load writings (controller manages its own state)
if (pubkey && eventStore && !writingsController.isLoadedFor(pubkey)) {
writingsController.start({ relayPool, eventStore, pubkey })
}
// Load reading progress (controller manages its own state)
if (pubkey && eventStore && !readingProgressController.isLoadedFor(pubkey)) {
readingProgressController.start({ relayPool, eventStore, pubkey })
}
// Start centralized nostrverse highlights controller (non-blocking)
if (eventStore) {
nostrverseHighlightsController.start({ relayPool, eventStore })
nostrverseWritingsController.start({ relayPool, eventStore })
}
} }
}, [activeAccount, relayPool, eventStore, bookmarks.length, bookmarksLoading, contacts.size, contactsLoading, accountManager]) }, [activeAccount, relayPool, eventStore, bookmarks.length, bookmarksLoading, contacts.size, contactsLoading, accountManager])
// Ensure nostrverse controllers run even when logged out
useEffect(() => {
if (relayPool && eventStore) {
nostrverseHighlightsController.start({ relayPool, eventStore })
nostrverseWritingsController.start({ relayPool, eventStore })
}
}, [relayPool, eventStore])
// Manual refresh (for sidebar button) // Manual refresh (for sidebar button)
const handleRefreshBookmarks = useCallback(async () => { const handleRefreshBookmarks = useCallback(async () => {
if (!relayPool || !activeAccount) { if (!relayPool || !activeAccount) {
console.warn('[bookmark] Cannot refresh: missing relayPool or activeAccount')
return return
} }
console.log('[bookmark] 🔄 Manual refresh triggered')
bookmarkController.reset() bookmarkController.reset()
await bookmarkController.start({ relayPool, activeAccount, accountManager }) await bookmarkController.start({ relayPool, activeAccount, accountManager })
}, [relayPool, activeAccount, accountManager]) }, [relayPool, activeAccount, accountManager])
@@ -128,6 +144,7 @@ function AppRoutes({
bookmarkController.reset() // Clear bookmarks via controller bookmarkController.reset() // Clear bookmarks via controller
contactsController.reset() // Clear contacts via controller contactsController.reset() // Clear contacts via controller
highlightsController.reset() // Clear highlights via controller highlightsController.reset() // Clear highlights via controller
readingProgressController.reset() // Clear reading progress via controller
showToast('Logged out successfully') showToast('Logged out successfully')
} }
@@ -361,40 +378,31 @@ function App() {
// Return an already-resolved promise so upstream await finishes immediately // Return an already-resolved promise so upstream await finishes immediately
return Promise.resolve() 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 // Create a relay group for better event deduplication and management
pool.group(RELAYS) pool.group(RELAYS)
console.log('[bunker] Created relay group with', RELAYS.length, 'relays (including local)')
// Load persisted accounts from localStorage // Load persisted accounts from localStorage
try { try {
const accountsJson = localStorage.getItem('accounts') const accountsJson = localStorage.getItem('accounts')
console.log('[bunker] Raw accounts from localStorage:', accountsJson)
const json = JSON.parse(accountsJson || '[]') const json = JSON.parse(accountsJson || '[]')
console.log('[bunker] Parsed accounts:', json.length, 'accounts')
await accounts.fromJSON(json) 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 // Load active account from storage
const activeId = localStorage.getItem('active') const activeId = localStorage.getItem('active')
console.log('[bunker] Active ID from localStorage:', activeId)
if (activeId) { if (activeId) {
const account = accounts.getAccount(activeId) const account = accounts.getAccount(activeId)
console.log('[bunker] Found account for ID?', !!account, account?.type)
if (account) { if (account) {
accounts.setActive(activeId) accounts.setActive(activeId)
console.log('[bunker] ✅ Restored active account:', activeId, 'type:', account.type)
} else { } else {
console.warn('[bunker] ⚠️ Active ID found but account not in list') console.warn('[bunker] ⚠️ Active ID found but account not in list')
} }
} else { } else {
console.log('[bunker] No active account ID in localStorage') // No active account ID in localStorage
} }
} catch (err) { } catch (err) {
console.error('[bunker] ❌ Failed to load accounts from storage:', err) console.error('[bunker] ❌ Failed to load accounts from storage:', err)
@@ -419,11 +427,6 @@ function App() {
const reconnectedAccounts = new Set<string>() const reconnectedAccounts = new Set<string>()
const bunkerReconnectSub = accounts.active$.subscribe(async (account) => { 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') { if (account && account.type === 'nostr-connect') {
const nostrConnectAccount = account as Accounts.NostrConnectAccount<unknown> const nostrConnectAccount = account as Accounts.NostrConnectAccount<unknown>
@@ -431,23 +434,17 @@ function App() {
try { try {
if (!(nostrConnectAccount as unknown as { disableQueue?: boolean }).disableQueue) { if (!(nostrConnectAccount as unknown as { disableQueue?: boolean }).disableQueue) {
(nostrConnectAccount as unknown as { disableQueue?: boolean }).disableQueue = true (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. // Note: for Amber bunker, the remote signer pubkey is the user's pubkey. This is expected.
// Skip if we've already reconnected this account // Skip if we've already reconnected this account
if (reconnectedAccounts.has(account.id)) { if (reconnectedAccounts.has(account.id)) {
console.log('[bunker] ⏭️ Already reconnected this account, skipping')
return return
} }
console.log('[bunker] Account detected. Status:', {
listening: nostrConnectAccount.signer.listening,
isConnected: nostrConnectAccount.signer.isConnected,
hasRemote: !!nostrConnectAccount.signer.remote,
bunkerRelays: nostrConnectAccount.signer.relays
})
try { try {
// For restored signers, ensure they have the pool's subscription methods // For restored signers, ensure they have the pool's subscription methods
@@ -461,10 +458,9 @@ function App() {
const newBunkerRelays = bunkerRelays.filter(url => !existingRelayUrls.has(url)) const newBunkerRelays = bunkerRelays.filter(url => !existingRelayUrls.has(url))
if (newBunkerRelays.length > 0) { if (newBunkerRelays.length > 0) {
console.log('[bunker] Adding bunker relays to pool BEFORE signer recreation:', newBunkerRelays)
pool.group(newBunkerRelays) pool.group(newBunkerRelays)
} else { } else {
console.log('[bunker] Bunker relays already in pool') // Bunker relays already in pool
} }
const recreatedSigner = new NostrConnectSigner({ const recreatedSigner = new NostrConnectSigner({
@@ -478,13 +474,11 @@ function App() {
try { try {
const mergedRelays = Array.from(new Set([...(signerData.relays || []), ...RELAYS])) const mergedRelays = Array.from(new Set([...(signerData.relays || []), ...RELAYS]))
recreatedSigner.relays = mergedRelays recreatedSigner.relays = mergedRelays
console.log('[bunker] 🔗 Signer relays merged with app RELAYS:', mergedRelays)
} catch (err) { console.warn('[bunker] failed to merge signer relays', err) } } catch (err) { console.warn('[bunker] failed to merge signer relays', err) }
// Replace the signer on the account // Replace the signer on the account
nostrConnectAccount.signer = recreatedSigner nostrConnectAccount.signer = recreatedSigner
console.log('[bunker] ✅ Signer recreated with pool context')
// Debug: log publish/subscription calls made by signer (decrypt/sign requests) // Debug: log publish/subscription calls made by signer (decrypt/sign requests)
// IMPORTANT: bind originals to preserve `this` context used internally by the signer // 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) const originalPublish = (recreatedSigner as unknown as { publishMethod: (relays: string[], event: unknown) => unknown }).publishMethod.bind(recreatedSigner)
@@ -506,7 +500,6 @@ function App() {
tags: (event as { tags?: unknown })?.tags, tags: (event as { tags?: unknown })?.tags,
contentLength: typeof content === 'string' ? content.length : undefined 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) } 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) } } catch (err) { console.warn('[bunker] failed to log publish summary', err) }
// Fire-and-forget publish: trigger the publish but do not return the // Fire-and-forget publish: trigger the publish but do not return the
@@ -524,7 +517,6 @@ function App() {
const originalSubscribe = (recreatedSigner as unknown as { subscriptionMethod: (relays: string[], filters: unknown[]) => unknown }).subscriptionMethod.bind(recreatedSigner) 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[]) => { ;(recreatedSigner as unknown as { subscriptionMethod: (relays: string[], filters: unknown[]) => unknown }).subscriptionMethod = (relays: string[], filters: unknown[]) => {
try { 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) } 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) } } catch (err) { console.warn('[bunker] failed to log subscribe summary', err) }
return originalSubscribe(relays, filters) return originalSubscribe(relays, filters)
@@ -534,20 +526,16 @@ function App() {
// Just ensure the signer is listening for responses - don't call connect() again // Just ensure the signer is listening for responses - don't call connect() again
// The fromBunkerURI already connected with permissions during login // The fromBunkerURI already connected with permissions during login
if (!nostrConnectAccount.signer.listening) { if (!nostrConnectAccount.signer.listening) {
console.log('[bunker] Opening signer subscription...')
await nostrConnectAccount.signer.open() await nostrConnectAccount.signer.open()
console.log('[bunker] ✅ Signer subscription opened')
} else { } else {
console.log('[bunker] ✅ Signer already listening') // Signer already listening
} }
// Attempt a guarded reconnect to ensure Amber authorizes decrypt operations // Attempt a guarded reconnect to ensure Amber authorizes decrypt operations
try { try {
if (nostrConnectAccount.signer.remote && !reconnectedAccounts.has(account.id)) { if (nostrConnectAccount.signer.remote && !reconnectedAccounts.has(account.id)) {
const permissions = getDefaultBunkerPermissions() const permissions = getDefaultBunkerPermissions()
console.log('[bunker] Attempting guarded connect() with permissions to ensure decrypt perms', { count: permissions.length })
await nostrConnectAccount.signer.connect(undefined, permissions) await nostrConnectAccount.signer.connect(undefined, permissions)
console.log('[bunker] ✅ Guarded connect() succeeded with permissions')
} }
} catch (e) { } catch (e) {
console.warn('[bunker] ⚠️ Guarded connect() failed:', e) console.warn('[bunker] ⚠️ Guarded connect() failed:', e)
@@ -556,7 +544,6 @@ function App() {
// Give the subscription a moment to fully establish before allowing decrypt operations // Give the subscription a moment to fully establish before allowing decrypt operations
// This ensures the signer is ready to handle and receive responses // This ensures the signer is ready to handle and receive responses
await new Promise(resolve => setTimeout(resolve, 100)) 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 // Fire-and-forget: probe decrypt path to verify Amber responds to NIP-46 decrypt
try { try {
const withTimeout = async <T,>(p: Promise<T>, ms = 10000): Promise<T> => { const withTimeout = async <T,>(p: Promise<T>, ms = 10000): Promise<T> => {
@@ -569,38 +556,27 @@ function App() {
const self = nostrConnectAccount.pubkey const self = nostrConnectAccount.pubkey
// Try a roundtrip so the bunker can respond successfully // Try a roundtrip so the bunker can respond successfully
try { try {
console.log('[bunker] 🔎 Probe nip44 roundtrip (encrypt→decrypt)…') await withTimeout(nostrConnectAccount.signer.nip44!.encrypt(self, 'probe-nip44'))
const cipher44 = await withTimeout(nostrConnectAccount.signer.nip44!.encrypt(self, 'probe-nip44')) await withTimeout(nostrConnectAccount.signer.nip44!.decrypt(self, ''))
const plain44 = await withTimeout(nostrConnectAccount.signer.nip44!.decrypt(self, cipher44)) } catch (_err) {
console.log('[bunker] 🔎 Probe nip44 responded:', typeof plain44 === 'string' ? plain44 : typeof plain44) // Ignore probe errors
} catch (err) {
console.log('[bunker] 🔎 Probe nip44 result:', err instanceof Error ? err.message : err)
} }
try { try {
console.log('[bunker] 🔎 Probe nip04 roundtrip (encrypt→decrypt)…') await withTimeout(nostrConnectAccount.signer.nip04!.encrypt(self, 'probe-nip04'))
const cipher04 = await withTimeout(nostrConnectAccount.signer.nip04!.encrypt(self, 'probe-nip04')) await withTimeout(nostrConnectAccount.signer.nip04!.decrypt(self, ''))
const plain04 = await withTimeout(nostrConnectAccount.signer.nip04!.decrypt(self, cipher04)) } catch (_err) {
console.log('[bunker] 🔎 Probe nip04 responded:', typeof plain04 === 'string' ? plain04 : typeof plain04) // Ignore probe errors
} catch (err) {
console.log('[bunker] 🔎 Probe nip04 result:', err instanceof Error ? err.message : err)
} }
}, 0) }, 0)
} catch (err) { } catch (_err) {
console.log('[bunker] 🔎 Probe setup failed:', err) // Ignore signer setup errors
} }
// The bunker remembers the permissions from the initial connection // The bunker remembers the permissions from the initial connection
nostrConnectAccount.signer.isConnected = true 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 // Mark this account as reconnected
reconnectedAccounts.add(account.id) reconnectedAccounts.add(account.id)
console.log('[bunker] 🎉 Signer ready for signing')
} catch (error) { } catch (error) {
console.error('[bunker] ❌ Failed to open signer:', error) console.error('[bunker] ❌ Failed to open signer:', error)
} }
@@ -614,7 +590,6 @@ function App() {
next: () => {}, // No-op, we don't care about events next: () => {}, // No-op, we don't care about events
error: (err) => console.warn('Keep-alive subscription error:', err) error: (err) => console.warn('Keep-alive subscription error:', err)
}) })
console.log('🔗 Created keep-alive subscription for', RELAYS.length, 'relay(s)')
// Store subscription for cleanup // Store subscription for cleanup
;(pool as unknown as { _keepAliveSubscription: typeof keepAliveSub })._keepAliveSubscription = keepAliveSub ;(pool as unknown as { _keepAliveSubscription: typeof keepAliveSub })._keepAliveSubscription = keepAliveSub

View File

@@ -33,6 +33,11 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) { } else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
progressColor = 'var(--color-text)' // Neutral text color (started) progressColor = 'var(--color-text)' // Neutral text color (started)
} }
// Debug log - reading progress shown as visual indicator
if (readingProgress !== undefined) {
// Reading progress display
}
return ( return (
<Link <Link

View File

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

View File

@@ -13,7 +13,7 @@ import { ViewMode } from './Bookmarks'
import { usePullToRefresh } from 'use-pull-to-refresh' import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator' import RefreshIndicator from './RefreshIndicator'
import { BookmarkSkeleton } from './Skeletons' import { BookmarkSkeleton } from './Skeletons'
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet } from '../utils/bookmarkUtils' import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet, hasCreationDate } from '../utils/bookmarkUtils'
import { UserSettings } from '../services/settingsService' import { UserSettings } from '../services/settingsService'
import AddBookmarkModal from './AddBookmarkModal' import AddBookmarkModal from './AddBookmarkModal'
import { createWebBookmark } from '../services/webBookmarkService' import { createWebBookmark } from '../services/webBookmarkService'
@@ -22,6 +22,10 @@ import { Hooks } from 'applesauce-react'
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters' import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier' import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
import LoginOptions from './LoginOptions' 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 { interface BookmarkListProps {
bookmarks: Bookmark[] bookmarks: Bookmark[]
@@ -70,6 +74,45 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
return saved === 'flat' ? 'flat' : 'grouped' return saved === 'flat' ? 'flat' : 'grouped'
}) })
const activeAccount = Hooks.useActiveAccount() 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 toggleGroupingMode = () => {
const newMode = groupingMode === 'grouped' ? 'flat' : 'grouped' 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 // Merge and flatten all individual bookmarks from all lists
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || []) const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
.filter(hasContent) .filter(hasContent)
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b))
// Apply filter // Apply filter
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter) 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-private', title: 'Private Bookmarks', items: groups.nip51Private },
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public }, { key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
{ key: 'amethyst-private', title: 'Amethyst Private', items: groups.amethystPrivate }, { key: 'amethyst-private', title: 'Private Lists', items: groups.amethystPrivate },
{ key: 'amethyst-public', title: 'Amethyst Lists', items: groups.amethystPublic }, { key: 'amethyst-public', title: 'My Lists', items: groups.amethystPublic },
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb } { key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb }
] ]
@@ -220,6 +264,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
index={index} index={index}
onSelectUrl={onSelectUrl} onSelectUrl={onSelectUrl}
viewMode={viewMode} viewMode={viewMode}
readingProgress={getBookmarkReadingProgress(individualBookmark)}
/> />
))} ))}
</div> </div>

View File

@@ -24,6 +24,7 @@ interface CardViewProps {
articleImage?: string articleImage?: string
articleSummary?: string articleSummary?: string
contentTypeIcon: IconDefinition contentTypeIcon: IconDefinition
readingProgress?: number
} }
export const CardView: React.FC<CardViewProps> = ({ export const CardView: React.FC<CardViewProps> = ({
@@ -38,7 +39,8 @@ export const CardView: React.FC<CardViewProps> = ({
handleReadNow, handleReadNow,
articleImage, articleImage,
articleSummary, articleSummary,
contentTypeIcon contentTypeIcon,
readingProgress
}) => { }) => {
const firstUrl = hasUrls ? extractedUrls[0] : null const firstUrl = hasUrls ? extractedUrls[0] : null
const firstUrlClassificationType = firstUrl ? classifyUrl(firstUrl)?.type : null const firstUrlClassificationType = firstUrl ? classifyUrl(firstUrl)?.type : null
@@ -52,6 +54,14 @@ export const CardView: React.FC<CardViewProps> = ({
const shouldTruncate = !expanded && contentLength > 210 const shouldTruncate = !expanded && contentLength > 210
const isArticle = bookmark.kind === 30023 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) // Determine which image to use (article image, instant preview, or OG image)
const previewImage = articleImage || instantPreview || ogImage const previewImage = articleImage || instantPreview || ogImage
const cachedImage = useImageCache(previewImage || undefined) const cachedImage = useImageCache(previewImage || undefined)
@@ -163,6 +173,28 @@ export const CardView: React.FC<CardViewProps> = ({
</button> </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-footer">
<div className="bookmark-meta-minimal"> <div className="bookmark-meta-minimal">
<Link <Link

View File

@@ -13,6 +13,7 @@ interface CompactViewProps {
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
articleSummary?: string articleSummary?: string
contentTypeIcon: IconDefinition contentTypeIcon: IconDefinition
readingProgress?: number
} }
export const CompactView: React.FC<CompactViewProps> = ({ export const CompactView: React.FC<CompactViewProps> = ({
@@ -22,12 +23,21 @@ export const CompactView: React.FC<CompactViewProps> = ({
extractedUrls, extractedUrls,
onSelectUrl, onSelectUrl,
articleSummary, articleSummary,
contentTypeIcon contentTypeIcon,
readingProgress
}) => { }) => {
const isArticle = bookmark.kind === 30023 const isArticle = bookmark.kind === 30023
const isWebBookmark = bookmark.kind === 39701 const isWebBookmark = bookmark.kind === 39701
const isClickable = hasUrls || isArticle || isWebBookmark 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 = () => { const handleCompactClick = () => {
if (!onSelectUrl) return if (!onSelectUrl) return
@@ -62,6 +72,29 @@ export const CompactView: React.FC<CompactViewProps> = ({
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at)}</span> <span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at)}</span>
{/* CTA removed */} {/* CTA removed */}
</div> </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> </div>
) )
} }

View File

@@ -17,6 +17,7 @@ import { Bookmark } from '../types/bookmarks'
import ThreePaneLayout from './ThreePaneLayout' import ThreePaneLayout from './ThreePaneLayout'
import Explore from './Explore' import Explore from './Explore'
import Me from './Me' import Me from './Me'
import Profile from './Profile'
import Support from './Support' import Support from './Support'
import { classifyHighlights } from '../utils/highlightClassification' import { classifyHighlights } from '../utils/highlightClassification'
@@ -330,7 +331,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
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} /> : null
) : undefined} ) : undefined}
profile={showProfile && profilePubkey ? ( profile={showProfile && profilePubkey ? (
relayPool ? <Me relayPool={relayPool} eventStore={eventStore} activeTab={profileTab} pubkey={profilePubkey} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} /> : null relayPool ? <Profile relayPool={relayPool} eventStore={eventStore} pubkey={profilePubkey} activeTab={profileTab} /> : null
) : undefined} ) : undefined}
support={showSupport ? ( support={showSupport ? (
relayPool ? <Support relayPool={relayPool} eventStore={eventStore} settings={settings} /> : null relayPool ? <Support relayPool={relayPool} eventStore={eventStore} settings={settings} /> : null

View File

@@ -32,7 +32,7 @@ import {
import AuthorCard from './AuthorCard' import AuthorCard from './AuthorCard'
import { faBooks } from '../icons/customIcons' import { faBooks } from '../icons/customIcons'
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService' import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
import { classifyUrl } from '../utils/helpers' import { classifyUrl, shouldTrackReadingProgress } from '../utils/helpers'
import { buildNativeVideoUrl } from '../utils/videoHelpers' import { buildNativeVideoUrl } from '../utils/videoHelpers'
import { useReadingPosition } from '../hooks/useReadingPosition' import { useReadingPosition } from '../hooks/useReadingPosition'
import { ReadingProgressIndicator } from './ReadingProgressIndicator' import { ReadingProgressIndicator } from './ReadingProgressIndicator'
@@ -151,20 +151,18 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
// Callback to save reading position // Callback to save reading position
const handleSavePosition = useCallback(async (position: number) => { const handleSavePosition = useCallback(async (position: number) => {
if (!activeAccount || !relayPool || !eventStore || !articleIdentifier) { if (!activeAccount || !relayPool || !eventStore || !articleIdentifier) {
console.log('⏭️ [ContentPanel] Skipping save - missing requirements:', {
hasAccount: !!activeAccount,
hasRelayPool: !!relayPool,
hasEventStore: !!eventStore,
hasIdentifier: !!articleIdentifier
})
return return
} }
if (!settings?.syncReadingPosition) { if (!settings?.syncReadingPosition) {
console.log('⏭️ [ContentPanel] Sync disabled in settings') return
}
// Check if content is long enough to track reading progress
if (!shouldTrackReadingProgress(html, markdown)) {
return return
} }
console.log('💾 [ContentPanel] Saving position:', Math.round(position * 100) + '%', 'for article:', selectedUrl?.slice(0, 50)) const scrollTop = window.pageYOffset || document.documentElement.scrollTop
try { try {
const factory = new EventFactory({ signer: activeAccount }) const factory = new EventFactory({ signer: activeAccount })
@@ -176,45 +174,39 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
{ {
position, position,
timestamp: Math.floor(Date.now() / 1000), timestamp: Math.floor(Date.now() / 1000),
scrollTop: window.pageYOffset || document.documentElement.scrollTop scrollTop
} }
) )
} catch (error) { } catch (error) {
console.error('❌ [ContentPanel] Failed to save reading position:', 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 { isReadingComplete, progressPercentage, saveNow } = useReadingPosition({
enabled: isTextContent, enabled: isTextContent,
syncEnabled: settings?.syncReadingPosition, syncEnabled: settings?.syncReadingPosition !== false,
onSave: handleSavePosition, onSave: handleSavePosition,
onReadingComplete: () => { onReadingComplete: () => {
// Optional: Auto-mark as read when reading is complete // Auto-mark as read when reading is complete (if enabled in settings)
if (activeAccount && !isMarkedAsRead) { if (activeAccount && !isMarkedAsRead && settings?.autoMarkAsReadOnCompletion) {
// Could trigger auto-mark as read here if desired handleMarkAsRead()
} }
} }
}) })
// Log sync status when it changes
useEffect(() => {
}, [isTextContent, settings?.syncReadingPosition, activeAccount, relayPool, eventStore, articleIdentifier, progressPercentage])
// Load saved reading position when article loads // Load saved reading position when article loads
useEffect(() => { useEffect(() => {
if (!isTextContent || !activeAccount || !relayPool || !eventStore || !articleIdentifier) { if (!isTextContent || !activeAccount || !relayPool || !eventStore || !articleIdentifier) {
console.log('⏭️ [ContentPanel] Skipping position restore - missing requirements:', {
isTextContent,
hasAccount: !!activeAccount,
hasRelayPool: !!relayPool,
hasEventStore: !!eventStore,
hasIdentifier: !!articleIdentifier
})
return return
} }
if (!settings?.syncReadingPosition) { if (settings?.syncReadingPosition === false) {
console.log('⏭️ [ContentPanel] Sync disabled - not restoring position')
return return
} }
console.log('📖 [ContentPanel] Loading position for article:', selectedUrl?.slice(0, 50))
const loadPosition = async () => { const loadPosition = async () => {
try { try {
const savedPosition = await loadReadingPosition( const savedPosition = await loadReadingPosition(
@@ -225,7 +217,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
) )
if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) { 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 // Wait for content to be fully rendered before scrolling
setTimeout(() => { setTimeout(() => {
const documentHeight = document.documentElement.scrollHeight const documentHeight = document.documentElement.scrollHeight
@@ -236,14 +227,12 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
top: scrollTop, top: scrollTop,
behavior: 'smooth' behavior: 'smooth'
}) })
console.log('✅ [ContentPanel] Restored to position:', Math.round(savedPosition.position * 100) + '%', 'scrollTop:', scrollTop)
}, 500) // Give content time to render }, 500) // Give content time to render
} else if (savedPosition) { } else if (savedPosition) {
if (savedPosition.position === 1) { if (savedPosition.position === 1) {
console.log('✅ [ContentPanel] Article completed (100%), starting from top') // Article was completed, start from top
} else { } else {
console.log('⏭️ [ContentPanel] Position too early (<5%):', Math.round(savedPosition.position * 100) + '%') // Position was too early, skip restore
} }
} }
} catch (error) { } catch (error) {
@@ -620,14 +609,12 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
activeAccount, activeAccount,
relayPool relayPool
) )
console.log('✅ Marked nostr article as read')
} else if (selectedUrl) { } else if (selectedUrl) {
await createWebsiteReaction( await createWebsiteReaction(
selectedUrl, selectedUrl,
activeAccount, activeAccount,
relayPool relayPool
) )
console.log('✅ Marked website as read')
} }
} catch (error) { } catch (error) {
console.error('Failed to mark as read:', error) console.error('Failed to mark as read:', error)

View File

@@ -18,6 +18,8 @@ import { useBookmarksUI } from '../hooks/useBookmarksUI'
import { useSettings } from '../hooks/useSettings' import { useSettings } from '../hooks/useSettings'
import { fetchHighlights, fetchHighlightsFromAuthors } from '../services/highlightService' import { fetchHighlights, fetchHighlightsFromAuthors } from '../services/highlightService'
import { contactsController } from '../services/contactsController' import { contactsController } from '../services/contactsController'
import { writingsController } from '../services/writingsController'
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
const defaultPayload = 'The quick brown fox jumps over the lazy dog.' const defaultPayload = 'The quick brown fox jumps over the lazy dog.'
@@ -94,6 +96,12 @@ const Debug: React.FC<DebugProps> = ({
const [tLoadHighlights, setTLoadHighlights] = useState<number | null>(null) const [tLoadHighlights, setTLoadHighlights] = useState<number | null>(null)
const [tFirstHighlight, setTFirstHighlight] = useState<number | null>(null) const [tFirstHighlight, setTFirstHighlight] = useState<number | null>(null)
// Writings loading state
const [isLoadingWritings, setIsLoadingWritings] = useState(false)
const [writingPosts, setWritingPosts] = useState<BlogPostPreview[]>([])
const [tLoadWritings, setTLoadWritings] = useState<number | null>(null)
const [tFirstWriting, setTFirstWriting] = useState<number | null>(null)
// Live timing state // Live timing state
const [liveTiming, setLiveTiming] = useState<{ const [liveTiming, setLiveTiming] = useState<{
nip44?: { type: 'encrypt' | 'decrypt'; startTime: number } nip44?: { type: 'encrypt' | 'decrypt'; startTime: number }
@@ -302,10 +310,6 @@ const Debug: React.FC<DebugProps> = ({
// Subscribe to decrypt complete events for Debug UI display // Subscribe to decrypt complete events for Debug UI display
const unsubscribeDecrypt = bookmarkController.onDecryptComplete((eventId, publicCount, privateCount) => { 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, { setDecryptedEvents(prev => new Map(prev).set(eventId, {
public: publicCount, public: publicCount,
private: privateCount private: privateCount
@@ -538,6 +542,188 @@ const Debug: React.FC<DebugProps> = ({
} }
} }
const handleLoadMyWritings = async () => {
if (!relayPool || !activeAccount?.pubkey || !eventStore) {
DebugBus.warn('debug', 'Please log in to load your writings')
return
}
const start = performance.now()
setWritingPosts([])
setIsLoadingWritings(true)
setTLoadWritings(null)
setTFirstWriting(null)
DebugBus.info('debug', 'Loading my writings via writingsController...')
try {
let firstEventTime: number | null = null
const unsub = writingsController.onWritings((posts) => {
if (firstEventTime === null && posts.length > 0) {
firstEventTime = performance.now() - start
setTFirstWriting(Math.round(firstEventTime))
}
setWritingPosts(posts)
})
await writingsController.start({
relayPool,
eventStore,
pubkey: activeAccount.pubkey,
force: true
})
unsub()
const currentWritings = writingsController.getWritings()
setWritingPosts(currentWritings)
DebugBus.info('debug', `Loaded ${currentWritings.length} writings via controller`)
} finally {
setIsLoadingWritings(false)
const elapsed = Math.round(performance.now() - start)
setTLoadWritings(elapsed)
DebugBus.info('debug', `Loaded my writings in ${elapsed}ms`)
}
}
const handleLoadFriendsWritings = async () => {
if (!relayPool || !activeAccount?.pubkey) {
DebugBus.warn('debug', 'Please log in to load friends writings')
return
}
const start = performance.now()
setWritingPosts([])
setIsLoadingWritings(true)
setTLoadWritings(null)
setTFirstWriting(null)
DebugBus.info('debug', 'Loading friends writings...')
try {
// Get contacts first
await contactsController.start({ relayPool, pubkey: activeAccount.pubkey })
const friends = contactsController.getContacts()
const friendsArray = Array.from(friends)
DebugBus.info('debug', `Found ${friendsArray.length} friends`)
if (friendsArray.length === 0) {
DebugBus.warn('debug', 'No friends found to load writings from')
return
}
let firstEventTime: number | null = null
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const posts = await fetchBlogPostsFromAuthors(
relayPool,
friendsArray,
relayUrls,
(post) => {
if (firstEventTime === null) {
firstEventTime = performance.now() - start
setTFirstWriting(Math.round(firstEventTime))
}
setWritingPosts(prev => {
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${post.author}:${dTag}`
const exists = prev.find(p => {
const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
return `${p.author}:${pDTag}` === key
})
if (exists) return prev
return [...prev, post].sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
})
}
)
setWritingPosts(posts)
DebugBus.info('debug', `Loaded ${posts.length} friend writings`)
} finally {
setIsLoadingWritings(false)
const elapsed = Math.round(performance.now() - start)
setTLoadWritings(elapsed)
DebugBus.info('debug', `Loaded friend writings in ${elapsed}ms`)
}
}
const handleLoadNostrverseWritings = async () => {
if (!relayPool) {
DebugBus.warn('debug', 'Relay pool not available')
return
}
const start = performance.now()
setWritingPosts([])
setIsLoadingWritings(true)
setTLoadWritings(null)
setTFirstWriting(null)
DebugBus.info('debug', 'Loading nostrverse writings (kind:30023)...')
try {
let firstEventTime: number | null = null
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const { queryEvents } = await import('../services/dataFetch')
const { Helpers } = await import('applesauce-core')
const { getArticleTitle, getArticleSummary, getArticleImage, getArticlePublished } = Helpers
const uniqueEvents = new Map<string, NostrEvent>()
await queryEvents(relayPool, { kinds: [30023], limit: 50 }, {
relayUrls,
onEvent: (evt) => {
const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${evt.pubkey}:${dTag}`
const existing = uniqueEvents.get(key)
if (!existing || evt.created_at > existing.created_at) {
uniqueEvents.set(key, evt)
if (firstEventTime === null) {
firstEventTime = performance.now() - start
setTFirstWriting(Math.round(firstEventTime))
}
const posts = Array.from(uniqueEvents.values()).map(event => ({
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
} as BlogPostPreview)).sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
setWritingPosts(posts)
}
}
})
const finalPosts = Array.from(uniqueEvents.values()).map(event => ({
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
} as BlogPostPreview)).sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
setWritingPosts(finalPosts)
DebugBus.info('debug', `Loaded ${finalPosts.length} nostrverse writings`)
} finally {
setIsLoadingWritings(false)
const elapsed = Math.round(performance.now() - start)
setTLoadWritings(elapsed)
DebugBus.info('debug', `Loaded nostrverse writings in ${elapsed}ms`)
}
}
const handleClearWritings = () => {
setWritingPosts([])
setTLoadWritings(null)
setTFirstWriting(null)
}
const handleLoadFriendsList = async () => { const handleLoadFriendsList = async () => {
if (!relayPool || !activeAccount?.pubkey) { if (!relayPool || !activeAccount?.pubkey) {
DebugBus.warn('debug', 'Please log in to load friends list') DebugBus.warn('debug', 'Please log in to load friends list')
@@ -552,7 +738,6 @@ const Debug: React.FC<DebugProps> = ({
// Subscribe to controller updates to see streaming // Subscribe to controller updates to see streaming
const unsubscribe = contactsController.onContacts((contacts) => { const unsubscribe = contactsController.onContacts((contacts) => {
console.log('[debug] Received contacts update:', contacts.size)
setFriendsPubkeys(new Set(contacts)) setFriendsPubkeys(new Set(contacts))
}) })
@@ -1070,6 +1255,99 @@ const Debug: React.FC<DebugProps> = ({
)} )}
</div> </div>
{/* Writings Loading Section */}
<div className="settings-section">
<h3 className="section-title">Writings Loading</h3>
<div className="mb-3 text-sm opacity-70">Quick load options:</div>
<div className="flex gap-2 mb-3 flex-wrap">
<button
className="btn btn-secondary text-sm"
onClick={handleLoadMyWritings}
disabled={isLoadingWritings || !relayPool || !activeAccount || !eventStore}
>
{isLoadingWritings ? (
<FontAwesomeIcon icon={faSpinner} className="animate-spin" />
) : (
'Load My Writings'
)}
</button>
<button
className="btn btn-secondary text-sm"
onClick={handleLoadFriendsWritings}
disabled={isLoadingWritings || !relayPool || !activeAccount}
>
{isLoadingWritings ? (
<FontAwesomeIcon icon={faSpinner} className="animate-spin" />
) : (
'Load Friends Writings'
)}
</button>
<button
className="btn btn-secondary text-sm"
onClick={handleLoadNostrverseWritings}
disabled={isLoadingWritings || !relayPool}
>
{isLoadingWritings ? (
<FontAwesomeIcon icon={faSpinner} className="animate-spin" />
) : (
'Load Nostrverse Writings'
)}
</button>
<button
className="btn btn-secondary text-sm ml-auto"
onClick={handleClearWritings}
disabled={writingPosts.length === 0}
>
Clear
</button>
</div>
<div className="mb-3 flex gap-2 flex-wrap">
<Stat label="total" value={tLoadWritings} />
<Stat label="first event" value={tFirstWriting} />
</div>
{writingPosts.length > 0 && (
<div className="mb-3">
<div className="text-sm opacity-70 mb-2">Loaded Writings ({writingPosts.length}):</div>
<div className="space-y-2 max-h-96 overflow-y-auto">
{writingPosts.map((post, idx) => {
const title = post.title
const summary = post.summary
const dTag = post.event.tags.find(t => t[0] === 'd')?.[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">Writing #{idx + 1}</div>
<div className="opacity-70 mb-1">
<div>Author: {post.author.slice(0, 16)}...</div>
<div>Published: {post.published ? new Date(post.published * 1000).toLocaleString() : new Date(post.event.created_at * 1000).toLocaleString()}</div>
<div>d-tag: {dTag || '(empty)'}</div>
</div>
<div className="mt-1">
<div className="font-semibold text-[11px]">Title:</div>
<div>&quot;{title}&quot;</div>
</div>
{summary && (
<div className="mt-1 text-[11px] opacity-70">
<div>Summary: {summary.substring(0, 100)}{summary.length > 100 ? '...' : ''}</div>
</div>
)}
{post.image && (
<div className="mt-1 text-[11px] opacity-70">
<div>Image: {post.image.substring(0, 40)}...</div>
</div>
)}
<div className="opacity-50 mt-1 text-[10px] break-all">ID: {post.event.id}</div>
</div>
)
})}
</div>
</div>
)}
</div>
{/* Web of Trust Section */} {/* Web of Trust Section */}
<div className="settings-section"> <div className="settings-section">
<h3 className="section-title">Web of Trust</h3> <h3 className="section-title">Web of Trust</h3>

View File

@@ -13,6 +13,7 @@ import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreS
import { fetchHighlightsFromAuthors } from '../services/highlightService' import { fetchHighlightsFromAuthors } from '../services/highlightService'
import { fetchProfiles } from '../services/profileService' import { fetchProfiles } from '../services/profileService'
import { fetchNostrverseBlogPosts, fetchNostrverseHighlights } from '../services/nostrverseService' import { fetchNostrverseBlogPosts, fetchNostrverseHighlights } from '../services/nostrverseService'
import { nostrverseHighlightsController } from '../services/nostrverseHighlightsController'
import { highlightsController } from '../services/highlightsController' import { highlightsController } from '../services/highlightsController'
import { Highlight } from '../types/highlights' import { Highlight } from '../types/highlights'
import { UserSettings } from '../services/settingsService' import { UserSettings } from '../services/settingsService'
@@ -27,6 +28,9 @@ import { KINDS } from '../config/kinds'
import { eventToHighlight } from '../services/highlightEventProcessor' import { eventToHighlight } from '../services/highlightEventProcessor'
import { useStoreTimeline } from '../hooks/useStoreTimeline' import { useStoreTimeline } from '../hooks/useStoreTimeline'
import { dedupeHighlightsById, dedupeWritingsByReplaceable } from '../utils/dedupe' import { dedupeHighlightsById, dedupeWritingsByReplaceable } from '../utils/dedupe'
import { writingsController } from '../services/writingsController'
import { nostrverseWritingsController } from '../services/nostrverseWritingsController'
import { readingProgressController } from '../services/readingProgressController'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
@@ -48,10 +52,16 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set()) const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [refreshTrigger, setRefreshTrigger] = useState(0) const [refreshTrigger, setRefreshTrigger] = useState(0)
const [hasLoadedNostrverse, setHasLoadedNostrverse] = useState(false)
const [hasLoadedMine, setHasLoadedMine] = useState(false)
const [hasLoadedNostrverseHighlights, setHasLoadedNostrverseHighlights] = useState(false)
// Get myHighlights directly from controller // Get myHighlights directly from controller
const [myHighlights, setMyHighlights] = useState<Highlight[]>([]) const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
const [myHighlightsLoading, setMyHighlightsLoading] = useState(false) // Remove unused loading state to avoid warnings
// Reading progress state (naddr -> progress 0-1)
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
// Load cached content from event store (instant display) // Load cached content from event store (instant display)
const cachedHighlights = useStoreTimeline(eventStore, { kinds: [KINDS.Highlights] }, eventToHighlight, []) const cachedHighlights = useStoreTimeline(eventStore, { kinds: [KINDS.Highlights] }, eventToHighlight, [])
@@ -66,24 +76,153 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
}), []) }), [])
const cachedWritings = useStoreTimeline(eventStore, { kinds: [30023] }, toBlogPostPreview, []) const cachedWritings = useStoreTimeline(eventStore, { kinds: [30023] }, toBlogPostPreview, [])
// Visibility filters (defaults from settings)
// Visibility filters (defaults from settings or nostrverse when logged out)
const [visibility, setVisibility] = useState<HighlightVisibility>({ const [visibility, setVisibility] = useState<HighlightVisibility>({
nostrverse: settings?.defaultExploreScopeNostrverse ?? false, nostrverse: activeAccount ? (settings?.defaultExploreScopeNostrverse ?? false) : true,
friends: settings?.defaultExploreScopeFriends ?? true, friends: settings?.defaultExploreScopeFriends ?? true,
mine: settings?.defaultExploreScopeMine ?? false mine: settings?.defaultExploreScopeMine ?? false
}) })
// Ensure at least one scope remains active
const toggleScope = useCallback((key: 'nostrverse' | 'friends' | 'mine') => {
setVisibility(prev => {
const next = { ...prev, [key]: !prev[key] }
if (!next.nostrverse && !next.friends && !next.mine) {
return prev // ignore toggle that would disable all scopes
}
return next
})
}, [])
// Subscribe to highlights controller // Subscribe to highlights controller
useEffect(() => { useEffect(() => {
const unsubHighlights = highlightsController.onHighlights(setMyHighlights) const unsubHighlights = highlightsController.onHighlights(setMyHighlights)
const unsubLoading = highlightsController.onLoading(setMyHighlightsLoading)
return () => { return () => {
unsubHighlights() unsubHighlights()
unsubLoading()
} }
}, []) }, [])
// Subscribe to nostrverse highlights controller for global stream
useEffect(() => {
const apply = (incoming: Highlight[]) => {
setHighlights(prev => {
const byId = new Map(prev.map(h => [h.id, h]))
for (const h of incoming) byId.set(h.id, h)
return Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
})
}
// seed immediately
apply(nostrverseHighlightsController.getHighlights())
const unsub = nostrverseHighlightsController.onHighlights(apply)
return () => unsub()
}, [])
// Subscribe to nostrverse writings controller for global stream
useEffect(() => {
const apply = (incoming: BlogPostPreview[]) => {
setBlogPosts(prev => {
const byKey = new Map<string, BlogPostPreview>()
for (const p of prev) {
const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${p.author}:${dTag}`
byKey.set(key, p)
}
for (const p of incoming) {
const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${p.author}:${dTag}`
const existing = byKey.get(key)
if (!existing || p.event.created_at > existing.event.created_at) byKey.set(key, p)
}
return Array.from(byKey.values()).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
})
}
apply(nostrverseWritingsController.getWritings())
const unsub = nostrverseWritingsController.onWritings(apply)
return () => unsub()
}, [])
// Subscribe to writings controller for "mine" posts and seed immediately
useEffect(() => {
// Seed from controller's current state
const seed = writingsController.getWritings()
if (seed.length > 0) {
setBlogPosts(prev => {
const merged = dedupeWritingsByReplaceable([...prev, ...seed])
return merged.sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
})
}
// Stream updates
const unsub = writingsController.onWritings((posts) => {
setBlogPosts(prev => {
const merged = dedupeWritingsByReplaceable([...prev, ...posts])
return merged.sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
})
})
return () => unsub()
}, [])
// Subscribe to reading progress controller
useEffect(() => {
// Get initial state immediately
const initialMap = readingProgressController.getProgressMap()
setReadingProgressMap(initialMap)
// Subscribe to updates
const unsubProgress = readingProgressController.onProgress((newMap) => {
setReadingProgressMap(newMap)
})
return () => {
unsubProgress()
}
}, [])
// Load reading progress data when logged in
useEffect(() => {
if (!activeAccount?.pubkey) {
return
}
readingProgressController.start({
relayPool,
eventStore,
pubkey: activeAccount.pubkey,
force: refreshTrigger > 0
})
}, [activeAccount?.pubkey, relayPool, eventStore, refreshTrigger])
// Update visibility when settings/login state changes
useEffect(() => {
if (!activeAccount) {
// When logged out, show nostrverse by default
setVisibility(prev => ({ ...prev, nostrverse: true, friends: false, mine: false }))
setHasLoadedNostrverse(true) // logged out path loads nostrverse immediately
setHasLoadedNostrverseHighlights(true)
} else {
// When logged in, use settings defaults immediately
setVisibility({
nostrverse: settings?.defaultExploreScopeNostrverse ?? false,
friends: settings?.defaultExploreScopeFriends ?? true,
mine: settings?.defaultExploreScopeMine ?? false
})
setHasLoadedNostrverse(false)
setHasLoadedNostrverseHighlights(false)
}
}, [activeAccount, settings?.defaultExploreScopeNostrverse, settings?.defaultExploreScopeFriends, settings?.defaultExploreScopeMine])
// Update local state when prop changes // Update local state when prop changes
useEffect(() => { useEffect(() => {
if (propActiveTab) { if (propActiveTab) {
@@ -93,21 +232,22 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
if (!activeAccount) {
setLoading(false)
return
}
try { try {
// show spinner but keep existing data // begin load, but do not block rendering
setLoading(true) setLoading(true)
// If not logged in, only fetch nostrverse content with streaming posts
if (!activeAccount) {
// Logged out: rely entirely on centralized controllers; do not fetch here
setLoading(false)
}
// Seed from in-memory cache if available to avoid empty flash // Seed from in-memory cache if available to avoid empty flash
const memoryCachedPosts = getCachedPosts(activeAccount.pubkey) const memoryCachedPosts = activeAccount ? getCachedPosts(activeAccount.pubkey) : []
if (memoryCachedPosts && memoryCachedPosts.length > 0) { if (memoryCachedPosts && memoryCachedPosts.length > 0) {
setBlogPosts(prev => prev.length === 0 ? memoryCachedPosts : prev) setBlogPosts(prev => prev.length === 0 ? memoryCachedPosts : prev)
} }
const memoryCachedHighlights = getCachedHighlights(activeAccount.pubkey) const memoryCachedHighlights = activeAccount ? getCachedHighlights(activeAccount.pubkey) : []
if (memoryCachedHighlights && memoryCachedHighlights.length > 0) { if (memoryCachedHighlights && memoryCachedHighlights.length > 0) {
setHighlights(prev => prev.length === 0 ? memoryCachedHighlights : prev) setHighlights(prev => prev.length === 0 ? memoryCachedHighlights : prev)
} }
@@ -133,10 +273,13 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
}) })
} }
// At this point, we have seeded any available data; lift the loading state
setLoading(false)
// Fetch the user's contacts (friends) // Fetch the user's contacts (friends)
const contacts = await fetchContacts( const contacts = await fetchContacts(
relayPool, relayPool,
activeAccount.pubkey, activeAccount?.pubkey || '',
(partial) => { (partial) => {
// Store followed pubkeys for highlight classification // Store followed pubkeys for highlight classification
setFollowedPubkeys(partial) setFollowedPubkeys(partial)
@@ -184,7 +327,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
return timeB - timeA return timeB - timeA
}) })
}) })
setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post)) if (activeAccount) setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post))
} }
).then((all) => { ).then((all) => {
setBlogPosts((prev) => { setBlogPosts((prev) => {
@@ -213,7 +356,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
const timeB = b.published || b.event.created_at const timeB = b.published || b.event.created_at
return timeB - timeA return timeB - timeA
}) })
setCachedPosts(activeAccount.pubkey, merged) if (activeAccount) setCachedPosts(activeAccount.pubkey, merged)
return merged return merged
}) })
}) })
@@ -229,14 +372,14 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
const next = [...prev, highlight] const next = [...prev, highlight]
return next.sort((a, b) => b.created_at - a.created_at) return next.sort((a, b) => b.created_at - a.created_at)
}) })
setCachedHighlights(activeAccount.pubkey, upsertCachedHighlight(activeAccount.pubkey, highlight)) if (activeAccount) setCachedHighlights(activeAccount.pubkey, upsertCachedHighlight(activeAccount.pubkey, highlight))
} }
).then((all) => { ).then((all) => {
setHighlights((prev) => { setHighlights((prev) => {
const byId = new Map(prev.map(h => [h.id, h])) const byId = new Map(prev.map(h => [h.id, h]))
for (const highlight of all) byId.set(highlight.id, highlight) for (const highlight of all) byId.set(highlight.id, highlight)
const merged = Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at) const merged = Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
setCachedHighlights(activeAccount.pubkey, merged) if (activeAccount) setCachedHighlights(activeAccount.pubkey, merged)
return merged return merged
}) })
}) })
@@ -250,52 +393,143 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
// Store final followed pubkeys // Store final followed pubkeys
setFollowedPubkeys(contacts) setFollowedPubkeys(contacts)
// Fetch both friends content and nostrverse content in parallel // Fetch friends content and (optionally) nostrverse + mine content in parallel
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const contactsArray = Array.from(contacts) const contactsArray = Array.from(contacts)
const [friendsPosts, friendsHighlights, nostrversePosts, nostriverseHighlights] = await Promise.all([ // Use centralized writingsController for my posts (non-blocking)
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls), // pull from writingsController; no need to store promise
fetchHighlightsFromAuthors(relayPool, contactsArray), setBlogPosts(prev => dedupeWritingsByReplaceable([...prev, ...writingsController.getWritings()]).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)))
fetchNostrverseBlogPosts(relayPool, relayUrls, 50, eventStore || undefined), setHasLoadedMine(true)
fetchNostrverseHighlights(relayPool, 100, eventStore || undefined) const nostrversePostsPromise = visibility.nostrverse
]) ? fetchNostrverseBlogPosts(relayPool, relayUrls, 50, eventStore || undefined, (post) => {
// Stream nostrverse posts too when logged in
setBlogPosts(prev => {
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${post.author}:${dTag}`
const existingIndex = prev.findIndex(p => {
const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
return `${p.author}:${pDTag}` === key
})
if (existingIndex >= 0) {
const existing = prev[existingIndex]
if (post.event.created_at <= existing.event.created_at) return prev
const next = [...prev]
next[existingIndex] = post
return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
}
const next = [...prev, post]
return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
})
})
: Promise.resolve([] as BlogPostPreview[])
// Merge and deduplicate all posts // Fire non-blocking fetches and merge as they resolve
const allPosts = [...friendsPosts, ...nostrversePosts] fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls)
const uniquePosts = dedupeWritingsByReplaceable(allPosts).sort((a, b) => { .then((friendsPosts) => {
const timeA = a.published || a.event.created_at setBlogPosts(prev => {
const timeB = b.published || b.event.created_at const merged = dedupeWritingsByReplaceable([...prev, ...friendsPosts])
return timeB - timeA const sorted = merged.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
}) if (activeAccount) setCachedPosts(activeAccount.pubkey, sorted)
// Pre-cache profiles in background
const authorPubkeys = Array.from(new Set(sorted.map(p => p.author)))
fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(() => {})
return sorted
})
}).catch(() => {})
// Merge and deduplicate all highlights (mine from controller + friends + nostrverse) fetchHighlightsFromAuthors(relayPool, contactsArray)
const allHighlights = [...myHighlights, ...friendsHighlights, ...nostriverseHighlights] .then((friendsHighlights) => {
const uniqueHighlights = dedupeHighlightsById(allHighlights).sort((a, b) => b.created_at - a.created_at) setHighlights(prev => {
const merged = dedupeHighlightsById([...prev, ...friendsHighlights])
const sorted = merged.sort((a, b) => b.created_at - a.created_at)
if (activeAccount) setCachedHighlights(activeAccount.pubkey, sorted)
return sorted
})
}).catch(() => {})
// Fetch profiles for all blog post authors to cache them nostrversePostsPromise.then((nostrversePosts) => {
if (uniquePosts.length > 0) { setBlogPosts(prev => dedupeWritingsByReplaceable([...prev, ...nostrversePosts]).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)))
const authorPubkeys = Array.from(new Set(uniquePosts.map(p => p.author))) }).catch(() => {})
fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(err => {
console.error('Failed to fetch author profiles:', err)
})
}
// No blocking errors - let empty states handle messaging fetchNostrverseHighlights(relayPool, 100, eventStore || undefined)
setBlogPosts(uniquePosts) .then((nostriverseHighlights) => {
setCachedPosts(activeAccount.pubkey, uniquePosts) setHighlights(prev => dedupeHighlightsById([...prev, ...nostriverseHighlights]).sort((a, b) => b.created_at - a.created_at))
}).catch(() => {})
setHighlights(uniqueHighlights)
setCachedHighlights(activeAccount.pubkey, uniqueHighlights)
} catch (err) { } catch (err) {
console.error('Failed to load data:', err) console.error('Failed to load data:', err)
// No blocking error - user can pull-to-refresh // No blocking error - user can pull-to-refresh
} finally { } finally {
setLoading(false) // loading is already turned off after seeding
} }
} }
loadData() loadData()
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings, myHighlights, cachedHighlights, cachedWritings]) // eslint-disable-next-line react-hooks/exhaustive-deps
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings])
// Lazy-load nostrverse writings when user toggles it on (logged in)
useEffect(() => {
if (!activeAccount || !relayPool || !visibility.nostrverse || hasLoadedNostrverse) return
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
setHasLoadedNostrverse(true)
fetchNostrverseBlogPosts(
relayPool,
relayUrls,
50,
eventStore || undefined,
(post) => {
setBlogPosts(prev => {
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${post.author}:${dTag}`
const existingIndex = prev.findIndex(p => {
const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
return `${p.author}:${pDTag}` === key
})
if (existingIndex >= 0) {
const existing = prev[existingIndex]
if (post.event.created_at <= existing.event.created_at) return prev
const next = [...prev]
next[existingIndex] = post
return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
}
const next = [...prev, post]
return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
})
}
).then((finalPosts) => {
// Ensure final deduped list
setBlogPosts(prev => {
const byKey = new Map<string, BlogPostPreview>()
for (const p of [...prev, ...finalPosts]) {
const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${p.author}:${dTag}`
const existing = byKey.get(key)
if (!existing || p.event.created_at > existing.event.created_at) byKey.set(key, p)
}
return Array.from(byKey.values()).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
})
}).catch(() => {})
}, [visibility.nostrverse, activeAccount, relayPool, eventStore, hasLoadedNostrverse])
// Lazy-load nostrverse highlights when user toggles it on (logged in)
useEffect(() => {
if (!activeAccount || !relayPool || !visibility.nostrverse || hasLoadedNostrverseHighlights) return
setHasLoadedNostrverseHighlights(true)
fetchNostrverseHighlights(relayPool, 100, eventStore || undefined)
.then((hl) => {
if (hl && hl.length > 0) {
setHighlights(prev => dedupeHighlightsById([...prev, ...hl]).sort((a, b) => b.created_at - a.created_at))
}
})
.catch(() => {})
}, [visibility.nostrverse, activeAccount, relayPool, eventStore, hasLoadedNostrverseHighlights])
// Lazy-load my writings when user toggles "mine" on (logged in)
// No direct fetch here; writingsController streams my posts centrally
useEffect(() => {
if (!activeAccount || !visibility.mine || hasLoadedMine) return
setHasLoadedMine(true)
}, [visibility.mine, activeAccount, hasLoadedMine])
// Pull-to-refresh // Pull-to-refresh
const { isRefreshing, pullPosition } = usePullToRefresh({ const { isRefreshing, pullPosition } = usePullToRefresh({
@@ -333,10 +567,20 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
}) })
}, [highlights, activeAccount?.pubkey, followedPubkeys, visibility]) }, [highlights, activeAccount?.pubkey, followedPubkeys, visibility])
// Dedupe and sort posts once for rendering
const uniqueSortedPosts = useMemo(() => {
const unique = dedupeWritingsByReplaceable(blogPosts)
return unique.sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
}, [blogPosts])
// Filter blog posts by future dates and visibility, and add level classification // Filter blog posts by future dates and visibility, and add level classification
const filteredBlogPosts = useMemo(() => { const filteredBlogPosts = useMemo(() => {
const maxFutureTime = Date.now() / 1000 + (24 * 60 * 60) // 1 day from now const maxFutureTime = Date.now() / 1000 + (24 * 60 * 60) // 1 day from now
return blogPosts return uniqueSortedPosts
.filter(post => { .filter(post => {
// Filter out future dates // Filter out future dates
const publishedTime = post.published || post.event.created_at const publishedTime = post.published || post.event.created_at
@@ -360,7 +604,29 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
const level: 'mine' | 'friends' | 'nostrverse' = isMine ? 'mine' : isFriend ? 'friends' : 'nostrverse' const level: 'mine' | 'friends' | 'nostrverse' = isMine ? 'mine' : isFriend ? 'friends' : 'nostrverse'
return { ...post, level } return { ...post, level }
}) })
}, [blogPosts, activeAccount, followedPubkeys, visibility]) }, [uniqueSortedPosts, activeAccount, followedPubkeys, visibility])
// Helper to get reading progress for a post
const getReadingProgress = useCallback((post: BlogPostPreview): number | undefined => {
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1]
if (!dTag) {
return undefined
}
try {
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: post.author,
identifier: dTag
})
const progress = readingProgressMap.get(naddr)
return progress
} catch (err) {
console.error('[progress] ❌ Error encoding naddr:', err)
return undefined
}
}, [readingProgressMap])
const renderTabContent = () => { const renderTabContent = () => {
switch (activeTab) { switch (activeTab) {
@@ -386,6 +652,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
post={post} post={post}
href={getPostUrl(post)} href={getPostUrl(post)}
level={post.level} level={post.level}
readingProgress={getReadingProgress(post)}
/> />
))} ))}
</div> </div>
@@ -403,7 +670,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
} }
return classifiedHighlights.length === 0 ? ( return classifiedHighlights.length === 0 ? (
<div className="explore-loading" style={{ gridColumn: '1/-1', display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}> <div className="explore-loading" style={{ gridColumn: '1/-1', display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
<FontAwesomeIcon icon={faSpinner} spin size="2x" /> <span>No highlights to show for the selected scope.</span>
</div> </div>
) : ( ) : (
<div className="explore-grid"> <div className="explore-grid">
@@ -422,9 +689,9 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
} }
} }
// Show content progressively - no blocking error screens // Show skeletons while first load in this session
const hasData = highlights.length > 0 || blogPosts.length > 0 const hasData = highlights.length > 0 || blogPosts.length > 0
const showSkeletons = (loading || myHighlightsLoading) && !hasData const showSkeletons = loading && !hasData
return ( return (
<div className="explore-container"> <div className="explore-container">
@@ -451,7 +718,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
/> />
<IconButton <IconButton
icon={faNetworkWired} icon={faNetworkWired}
onClick={() => setVisibility({ ...visibility, nostrverse: !visibility.nostrverse })} onClick={() => toggleScope('nostrverse')}
title="Toggle nostrverse content" title="Toggle nostrverse content"
ariaLabel="Toggle nostrverse content" ariaLabel="Toggle nostrverse content"
variant="ghost" variant="ghost"
@@ -462,7 +729,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
/> />
<IconButton <IconButton
icon={faUserGroup} icon={faUserGroup}
onClick={() => setVisibility({ ...visibility, friends: !visibility.friends })} onClick={() => toggleScope('friends')}
title={activeAccount ? "Toggle friends content" : "Login to see friends content"} title={activeAccount ? "Toggle friends content" : "Login to see friends content"}
ariaLabel="Toggle friends content" ariaLabel="Toggle friends content"
variant="ghost" variant="ghost"
@@ -474,7 +741,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
/> />
<IconButton <IconButton
icon={faUser} icon={faUser}
onClick={() => setVisibility({ ...visibility, mine: !visibility.mine })} onClick={() => toggleScope('mine')}
title={activeAccount ? "Toggle my content" : "Login to see your content"} title={activeAccount ? "Toggle my content" : "Login to see your content"}
ariaLabel="Toggle my content" ariaLabel="Toggle my content"
variant="ghost" variant="ghost"

View File

@@ -27,7 +27,6 @@ export const HighlightCitation: React.FC<HighlightCitationProps> = ({
// Fallback: extract directly from p tag // Fallback: extract directly from p tag
const pTag = highlight.tags.find(t => t[0] === 'p') const pTag = highlight.tags.find(t => t[0] === 'p')
if (pTag && pTag[1]) { if (pTag && pTag[1]) {
console.log('📝 Found author from p tag:', pTag[1])
return pTag[1] return pTag[1]
} }

View File

@@ -348,11 +348,9 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
// Publish to all configured relays - let the relay pool handle connection state // Publish to all configured relays - let the relay pool handle connection state
const targetRelays = RELAYS const targetRelays = RELAYS
console.log('📡 Rebroadcasting highlight to', targetRelays.length, 'relay(s):', targetRelays)
await relayPool.publish(targetRelays, event) await relayPool.publish(targetRelays, event)
console.log('✅ Rebroadcast successful!')
// Update the highlight with new relay info // Update the highlight with new relay info
const isLocalOnly = areAllRelaysLocal(targetRelays) const isLocalOnly = areAllRelaysLocal(targetRelays)
@@ -449,7 +447,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
relayPool relayPool
) )
console.log('✅ Highlight deletion request published')
// Notify parent to remove this highlight from the list // Notify parent to remove this highlight from the list
if (onHighlightDelete) { if (onHighlightDelete) {

View File

@@ -46,36 +46,38 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
opacity: highlightVisibility.nostrverse ? 1 : 0.4 opacity: highlightVisibility.nostrverse ? 1 : 0.4
}} }}
/> />
<IconButton {currentUserPubkey && (
icon={faUserGroup} <>
onClick={() => onHighlightVisibilityChange({ <IconButton
...highlightVisibility, icon={faUserGroup}
friends: !highlightVisibility.friends onClick={() => onHighlightVisibilityChange({
})} ...highlightVisibility,
title={currentUserPubkey ? "Toggle friends highlights" : "Login to see friends highlights"} friends: !highlightVisibility.friends
ariaLabel="Toggle friends highlights" })}
variant="ghost" title="Toggle friends highlights"
disabled={!currentUserPubkey} ariaLabel="Toggle friends highlights"
style={{ variant="ghost"
color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined, style={{
opacity: highlightVisibility.friends ? 1 : 0.4 color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined,
}} opacity: highlightVisibility.friends ? 1 : 0.4
/> }}
<IconButton />
icon={faUser} <IconButton
onClick={() => onHighlightVisibilityChange({ icon={faUser}
...highlightVisibility, onClick={() => onHighlightVisibilityChange({
mine: !highlightVisibility.mine ...highlightVisibility,
})} mine: !highlightVisibility.mine
title={currentUserPubkey ? "Toggle my highlights" : "Login to see your highlights"} })}
ariaLabel="Toggle my highlights" title="Toggle my highlights"
variant="ghost" ariaLabel="Toggle my highlights"
disabled={!currentUserPubkey} variant="ghost"
style={{ style={{
color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined, color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined,
opacity: highlightVisibility.mine ? 1 : 0.4 opacity: highlightVisibility.mine ? 1 : 0.4
}} }}
/> />
</>
)}
</div> </div>
)} )}
{onRefresh && ( {onRefresh && (

View File

@@ -1,26 +1,24 @@
import React, { useState, useEffect, useMemo } from 'react' import React, { useState, useEffect } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare, faLink, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons' import { faHighlighter, faBookmark, faPenToSquare, faLink, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react' import { Hooks } from 'applesauce-react'
import { IEventStore, Helpers } from 'applesauce-core' import { IEventStore } from 'applesauce-core'
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons' import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { nip19, NostrEvent } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { Highlight } from '../types/highlights' import { Highlight } from '../types/highlights'
import { HighlightItem } from './HighlightItem' import { HighlightItem } from './HighlightItem'
import { fetchHighlights } from '../services/highlightService'
import { highlightsController } from '../services/highlightsController' import { highlightsController } from '../services/highlightsController'
import { writingsController } from '../services/writingsController'
import { fetchAllReads, ReadItem } from '../services/readsService' import { fetchAllReads, ReadItem } from '../services/readsService'
import { fetchLinks } from '../services/linksService' import { fetchLinks } from '../services/linksService'
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService' import { BlogPostPreview } from '../services/exploreService'
import { RELAYS } from '../config/relays'
import { Bookmark, IndividualBookmark } from '../types/bookmarks' import { Bookmark, IndividualBookmark } from '../types/bookmarks'
import AuthorCard from './AuthorCard' import AuthorCard from './AuthorCard'
import BlogPostCard from './BlogPostCard' import BlogPostCard from './BlogPostCard'
import { BookmarkItem } from './BookmarkItem' import { BookmarkItem } from './BookmarkItem'
import IconButton from './IconButton' import IconButton from './IconButton'
import { ViewMode } from './Bookmarks'
import { getCachedMeData, updateCachedHighlights } from '../services/meCache' import { getCachedMeData, updateCachedHighlights } from '../services/meCache'
import { faBooks } from '../icons/customIcons' import { faBooks } from '../icons/customIcons'
import { usePullToRefresh } from 'use-pull-to-refresh' import { usePullToRefresh } from 'use-pull-to-refresh'
@@ -33,17 +31,12 @@ import { filterByReadingProgress } from '../utils/readingProgressUtils'
import { deriveReadsFromBookmarks } from '../utils/readsFromBookmarks' import { deriveReadsFromBookmarks } from '../utils/readsFromBookmarks'
import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks' import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks'
import { mergeReadItem } from '../utils/readItemMerge' import { mergeReadItem } from '../utils/readItemMerge'
import { useStoreTimeline } from '../hooks/useStoreTimeline' import { readingProgressController } from '../services/readingProgressController'
import { eventToHighlight } from '../services/highlightEventProcessor'
import { KINDS } from '../config/kinds'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
interface MeProps { interface MeProps {
relayPool: RelayPool relayPool: RelayPool
eventStore: IEventStore eventStore: IEventStore
activeTab?: TabType activeTab?: TabType
pubkey?: string // Optional pubkey for viewing other users' profiles
bookmarks: Bookmark[] // From centralized App.tsx state bookmarks: Bookmark[] // From centralized App.tsx state
bookmarksLoading?: boolean // From centralized App.tsx state (reserved for future use) bookmarksLoading?: boolean // From centralized App.tsx state (reserved for future use)
} }
@@ -51,13 +44,12 @@ interface MeProps {
type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings' type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
// Valid reading progress filters // Valid reading progress filters
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed'] const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed', 'highlighted']
const Me: React.FC<MeProps> = ({ const Me: React.FC<MeProps> = ({
relayPool, relayPool,
eventStore, eventStore,
activeTab: propActiveTab, activeTab: propActiveTab,
pubkey: propPubkey,
bookmarks bookmarks
}) => { }) => {
const activeAccount = Hooks.useActiveAccount() const activeAccount = Hooks.useActiveAccount()
@@ -65,9 +57,8 @@ const Me: React.FC<MeProps> = ({
const { filter: urlFilter } = useParams<{ filter?: string }>() const { filter: urlFilter } = useParams<{ filter?: string }>()
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights') const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
// Use provided pubkey or fall back to active account // Only for own profile
const viewingPubkey = propPubkey || activeAccount?.pubkey const viewingPubkey = activeAccount?.pubkey
const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey)
const [highlights, setHighlights] = useState<Highlight[]>([]) const [highlights, setHighlights] = useState<Highlight[]>([])
const [reads, setReads] = useState<ReadItem[]>([]) const [reads, setReads] = useState<ReadItem[]>([])
const [, setReadsMap] = useState<Map<string, ReadItem>>(new Map()) const [, setReadsMap] = useState<Map<string, ReadItem>>(new Map())
@@ -81,30 +72,10 @@ const Me: React.FC<MeProps> = ({
const [myHighlights, setMyHighlights] = useState<Highlight[]>([]) const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
const [myHighlightsLoading, setMyHighlightsLoading] = useState(false) const [myHighlightsLoading, setMyHighlightsLoading] = useState(false)
// Load cached data from event store for OTHER profiles (not own) // Get myWritings directly from controller
const cachedHighlights = useStoreTimeline( const [myWritings, setMyWritings] = useState<BlogPostPreview[]>([])
eventStore, const [myWritingsLoading, setMyWritingsLoading] = useState(false)
!isOwnProfile && viewingPubkey ? { kinds: [KINDS.Highlights], authors: [viewingPubkey] } : { kinds: [KINDS.Highlights], limit: 0 },
eventToHighlight,
[viewingPubkey, isOwnProfile]
)
const toBlogPostPreview = useMemo(() => (event: NostrEvent): BlogPostPreview => ({
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
}), [])
const cachedWritings = useStoreTimeline(
eventStore,
!isOwnProfile && viewingPubkey ? { kinds: [30023], authors: [viewingPubkey] } : { kinds: [30023], limit: 0 },
toBlogPostPreview,
[viewingPubkey, isOwnProfile]
)
const [viewMode, setViewMode] = useState<ViewMode>('cards')
const [refreshTrigger, setRefreshTrigger] = useState(0) const [refreshTrigger, setRefreshTrigger] = useState(0)
const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all') const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all')
const [groupingMode, setGroupingMode] = useState<'grouped' | 'flat'>(() => { const [groupingMode, setGroupingMode] = useState<'grouped' | 'flat'>(() => {
@@ -123,6 +94,9 @@ const Me: React.FC<MeProps> = ({
? (urlFilter as ReadingProgressFilterType) ? (urlFilter as ReadingProgressFilterType)
: 'all' : 'all'
const [readingProgressFilter, setReadingProgressFilter] = useState<ReadingProgressFilterType>(initialFilter) const [readingProgressFilter, setReadingProgressFilter] = useState<ReadingProgressFilterType>(initialFilter)
// Reading progress state for writings tab (naddr -> progress 0-1)
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
// Subscribe to highlights controller // Subscribe to highlights controller
useEffect(() => { useEffect(() => {
@@ -138,6 +112,20 @@ const Me: React.FC<MeProps> = ({
} }
}, []) }, [])
// Subscribe to writings controller
useEffect(() => {
// Get initial state immediately
setMyWritings(writingsController.getWritings())
// Subscribe to updates
const unsubWritings = writingsController.onWritings(setMyWritings)
const unsubLoading = writingsController.onLoading(setMyWritingsLoading)
return () => {
unsubWritings()
unsubLoading()
}
}, [])
// Update local state when prop changes // Update local state when prop changes
useEffect(() => { useEffect(() => {
if (propActiveTab) { if (propActiveTab) {
@@ -164,68 +152,64 @@ const Me: React.FC<MeProps> = ({
} }
} }
} }
// Subscribe to reading progress controller
useEffect(() => {
// Get initial state immediately
setReadingProgressMap(readingProgressController.getProgressMap())
// Subscribe to updates
const unsubProgress = readingProgressController.onProgress(setReadingProgressMap)
return () => {
unsubProgress()
}
}, [])
// Load reading progress data for writings tab
useEffect(() => {
if (!viewingPubkey) {
return
}
readingProgressController.start({
relayPool,
eventStore,
pubkey: viewingPubkey,
force: refreshTrigger > 0
})
}, [viewingPubkey, relayPool, eventStore, refreshTrigger])
// Tab-specific loading functions // Tab-specific loading functions
const loadHighlightsTab = async () => { const loadHighlightsTab = async () => {
if (!viewingPubkey) return if (!viewingPubkey) return
// Only show loading skeleton if tab hasn't been loaded yet // Highlights come from controller subscription (sync effect handles it)
const hasBeenLoaded = loadedTabs.has('highlights') setLoadedTabs(prev => new Set(prev).add('highlights'))
setLoading(false)
try {
if (!hasBeenLoaded) setLoading(true)
// For own profile, highlights come from controller subscription (sync effect handles it)
// For viewing other users, seed with cached data then fetch fresh
if (!isOwnProfile) {
// Seed with cached highlights first
if (cachedHighlights.length > 0) {
setHighlights(cachedHighlights.sort((a, b) => b.created_at - a.created_at))
}
// Fetch fresh highlights
const userHighlights = await fetchHighlights(relayPool, viewingPubkey)
setHighlights(userHighlights)
}
setLoadedTabs(prev => new Set(prev).add('highlights'))
} catch (err) {
console.error('Failed to load highlights:', err)
} finally {
if (!hasBeenLoaded) setLoading(false)
}
} }
const loadWritingsTab = async () => { const loadWritingsTab = async () => {
if (!viewingPubkey) return if (!viewingPubkey) return
const hasBeenLoaded = loadedTabs.has('writings')
try { try {
if (!hasBeenLoaded) setLoading(true) // Use centralized controller
await writingsController.start({
// Seed with cached writings first relayPool,
if (!isOwnProfile && cachedWritings.length > 0) { eventStore,
setWritings(cachedWritings.sort((a, b) => { pubkey: viewingPubkey,
const timeA = a.published || a.event.created_at force: refreshTrigger > 0
const timeB = b.published || b.event.created_at })
return timeB - timeA
}))
}
// Fetch fresh writings
const userWritings = await fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
setWritings(userWritings)
setLoadedTabs(prev => new Set(prev).add('writings')) setLoadedTabs(prev => new Set(prev).add('writings'))
setLoading(false)
} catch (err) { } catch (err) {
console.error('Failed to load writings:', err) console.error('Failed to load writings:', err)
} finally { setLoading(false)
if (!hasBeenLoaded) setLoading(false)
} }
} }
const loadReadingListTab = async () => { const loadReadingListTab = async () => {
if (!viewingPubkey || !isOwnProfile || !activeAccount) return if (!viewingPubkey || !activeAccount) return
const hasBeenLoaded = loadedTabs.has('reading-list') const hasBeenLoaded = loadedTabs.has('reading-list')
@@ -241,7 +225,7 @@ const Me: React.FC<MeProps> = ({
} }
const loadReadsTab = async () => { const loadReadsTab = async () => {
if (!viewingPubkey || !isOwnProfile || !activeAccount) return if (!viewingPubkey || !activeAccount) return
const hasBeenLoaded = loadedTabs.has('reads') const hasBeenLoaded = loadedTabs.has('reads')
@@ -259,23 +243,15 @@ const Me: React.FC<MeProps> = ({
// Background enrichment: merge reading progress and mark-as-read // Background enrichment: merge reading progress and mark-as-read
// Only update items that are already in our map // Only update items that are already in our map
fetchAllReads(relayPool, viewingPubkey, bookmarks, (item) => { 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 => { setReadsMap(prevMap => {
// Only update if item exists in our current map // Only update if item exists in our current map
if (!prevMap.has(item.id)) { if (!prevMap.has(item.id)) {
console.log('⚠️ [Reads] Item not in map, skipping:', item.id.slice(0, 20) + '...')
return prevMap return prevMap
} }
const newMap = new Map(prevMap) const newMap = new Map(prevMap)
const merged = mergeReadItem(newMap, item) const merged = mergeReadItem(newMap, item)
if (merged) { if (merged) {
console.log('✅ [Reads] Merged progress:', item.id.slice(0, 20) + '...', item.readingProgress)
// Update reads array after map is updated // Update reads array after map is updated
setReads(Array.from(newMap.values())) setReads(Array.from(newMap.values()))
return newMap return newMap
@@ -291,7 +267,7 @@ const Me: React.FC<MeProps> = ({
} }
const loadLinksTab = async () => { const loadLinksTab = async () => {
if (!viewingPubkey || !isOwnProfile || !activeAccount) return if (!viewingPubkey || !activeAccount) return
const hasBeenLoaded = loadedTabs.has('links') const hasBeenLoaded = loadedTabs.has('links')
@@ -337,14 +313,12 @@ const Me: React.FC<MeProps> = ({
} }
// Load cached data immediately if available // Load cached data immediately if available
if (isOwnProfile) { const cached = getCachedMeData(viewingPubkey)
const cached = getCachedMeData(viewingPubkey) if (cached) {
if (cached) { setHighlights(cached.highlights)
setHighlights(cached.highlights) // Bookmarks come from App.tsx centralized state, no local caching needed
// Bookmarks come from App.tsx centralized state, no local caching needed setReads(cached.reads || [])
setReads(cached.reads || []) setLinks(cached.links || [])
setLinks(cached.links || [])
}
} }
// Load data for active tab (refresh in background if already loaded) // Load data for active tab (refresh in background if already loaded)
@@ -366,14 +340,17 @@ const Me: React.FC<MeProps> = ({
break break
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab, viewingPubkey, refreshTrigger]) }, [activeTab, viewingPubkey, refreshTrigger, bookmarks])
// Sync myHighlights from controller when viewing own profile // Sync myHighlights from controller
useEffect(() => { useEffect(() => {
if (isOwnProfile) { setHighlights(myHighlights)
setHighlights(myHighlights) }, [myHighlights])
}
}, [isOwnProfile, myHighlights]) // Sync myWritings from controller
useEffect(() => {
setWritings(myWritings)
}, [myWritings])
// Pull-to-refresh - reload active tab without clearing state // Pull-to-refresh - reload active tab without clearing state
const { isRefreshing, pullPosition } = usePullToRefresh({ const { isRefreshing, pullPosition } = usePullToRefresh({
@@ -389,8 +366,8 @@ const Me: React.FC<MeProps> = ({
const handleHighlightDelete = (highlightId: string) => { const handleHighlightDelete = (highlightId: string) => {
setHighlights(prev => { setHighlights(prev => {
const updated = prev.filter(h => h.id !== highlightId) const updated = prev.filter(h => h.id !== highlightId)
// Update cache when highlight is deleted (own profile only) // Update cache when highlight is deleted
if (isOwnProfile && viewingPubkey) { if (viewingPubkey) {
updateCachedHighlights(viewingPubkey, updated) updateCachedHighlights(viewingPubkey, updated)
} }
return updated return updated
@@ -468,6 +445,42 @@ const Me: React.FC<MeProps> = ({
navigate(`/r/${encodeURIComponent(url)}`) navigate(`/r/${encodeURIComponent(url)}`)
} }
} }
// Helper to get reading progress for a post
const getWritingReadingProgress = (post: BlogPostPreview): number | undefined => {
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1]
if (!dTag) return undefined
try {
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: post.author,
identifier: dTag
})
return readingProgressMap.get(naddr)
} catch (err) {
return undefined
}
}
// 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 // Merge and flatten all individual bookmarks
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || []) const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
@@ -478,23 +491,44 @@ const Me: React.FC<MeProps> = ({
const groups = groupIndividualBookmarks(filteredBookmarks) const groups = groupIndividualBookmarks(filteredBookmarks)
// Enrich reads and links with reading progress from controller
const readsWithProgress = reads.map(item => {
if (item.type === 'article' && item.author) {
const progress = readingProgressMap.get(item.id)
if (progress !== undefined) {
return { ...item, readingProgress: progress }
}
}
return item
})
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 // Apply reading progress filter
const filteredReads = filterByReadingProgress(reads, readingProgressFilter) const filteredReads = filterByReadingProgress(readsWithProgress, readingProgressFilter, highlights)
const filteredLinks = filterByReadingProgress(links, readingProgressFilter) const filteredLinks = filterByReadingProgress(linksWithProgress, readingProgressFilter, highlights)
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
groupingMode === 'flat' groupingMode === 'flat'
? [{ key: 'all', title: `All Bookmarks (${filteredBookmarks.length})`, items: filteredBookmarks }] ? [{ key: 'all', title: `All Bookmarks (${filteredBookmarks.length})`, items: filteredBookmarks }]
: [ : [
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private }, { key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public }, { key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
{ key: 'amethyst-private', title: 'Amethyst Private', items: groups.amethystPrivate }, { key: 'amethyst-private', title: 'Private Lists', items: groups.amethystPrivate },
{ key: 'amethyst-public', title: 'Amethyst Lists', items: groups.amethystPublic }, { key: 'amethyst-public', title: 'My Lists', items: groups.amethystPublic },
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb } { key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb }
] ]
// Show content progressively - no blocking error screens // Show content progressively - no blocking error screens
const hasData = highlights.length > 0 || bookmarks.length > 0 || reads.length > 0 || links.length > 0 || writings.length > 0 const hasData = highlights.length > 0 || bookmarks.length > 0 || reads.length > 0 || links.length > 0 || writings.length > 0
const showSkeletons = (loading || (isOwnProfile && myHighlightsLoading)) && !hasData const showSkeletons = (loading || myHighlightsLoading) && !hasData
const renderTabContent = () => { const renderTabContent = () => {
switch (activeTab) { switch (activeTab) {
@@ -508,7 +542,7 @@ const Me: React.FC<MeProps> = ({
</div> </div>
) )
} }
return highlights.length === 0 && !loading && !(isOwnProfile && myHighlightsLoading) ? ( return highlights.length === 0 && !loading && !myHighlightsLoading ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}> <div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No highlights yet. No highlights yet.
</div> </div>
@@ -529,9 +563,9 @@ const Me: React.FC<MeProps> = ({
if (showSkeletons) { if (showSkeletons) {
return ( return (
<div className="bookmarks-list"> <div className="bookmarks-list">
<div className={`bookmarks-grid bookmarks-${viewMode}`}> <div className="bookmarks-grid bookmarks-cards">
{Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: 6 }).map((_, i) => (
<BookmarkSkeleton key={i} viewMode={viewMode} /> <BookmarkSkeleton key={i} viewMode="cards" />
))} ))}
</div> </div>
</div> </div>
@@ -557,14 +591,15 @@ const Me: React.FC<MeProps> = ({
sections.filter(s => s.items.length > 0).map(section => ( sections.filter(s => s.items.length > 0).map(section => (
<div key={section.key} className="bookmarks-section"> <div key={section.key} className="bookmarks-section">
<h3 className="bookmarks-section-title">{section.title}</h3> <h3 className="bookmarks-section-title">{section.title}</h3>
<div className={`bookmarks-grid bookmarks-${viewMode}`}> <div className="bookmarks-grid bookmarks-cards">
{section.items.map((individualBookmark, index) => ( {section.items.map((individualBookmark, index) => (
<BookmarkItem <BookmarkItem
key={`${section.key}-${individualBookmark.id}-${index}`} key={`${section.key}-${individualBookmark.id}-${index}`}
bookmark={individualBookmark} bookmark={individualBookmark}
index={index} index={index}
viewMode={viewMode} viewMode="cards"
onSelectUrl={handleSelectUrl} onSelectUrl={handleSelectUrl}
readingProgress={getBookmarkReadingProgress(individualBookmark)}
/> />
))} ))}
</div> </div>
@@ -585,27 +620,6 @@ const Me: React.FC<MeProps> = ({
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'} ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
variant="ghost" variant="ghost"
/> />
<IconButton
icon={faList}
onClick={() => setViewMode('compact')}
title="Compact list view"
ariaLabel="Compact list view"
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faThLarge}
onClick={() => setViewMode('cards')}
title="Cards view"
ariaLabel="Cards view"
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faImage}
onClick={() => setViewMode('large')}
title="Large preview view"
ariaLabel="Large preview view"
variant={viewMode === 'large' ? 'primary' : 'ghost'}
/>
</div> </div>
</div> </div>
) )
@@ -714,7 +728,7 @@ const Me: React.FC<MeProps> = ({
</div> </div>
) )
} }
return writings.length === 0 && !loading ? ( return writings.length === 0 && !loading && !myWritingsLoading ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}> <div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No articles written yet. No articles written yet.
</div> </div>
@@ -725,6 +739,7 @@ const Me: React.FC<MeProps> = ({
key={post.event.id} key={post.event.id}
post={post} post={post}
href={getPostUrl(post)} href={getPostUrl(post)}
readingProgress={getWritingReadingProgress(post)}
/> />
))} ))}
</div> </div>
@@ -748,43 +763,39 @@ const Me: React.FC<MeProps> = ({
<button <button
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`} className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
data-tab="highlights" data-tab="highlights"
onClick={() => navigate(isOwnProfile ? '/me/highlights' : `/p/${propPubkey && nip19.npubEncode(propPubkey)}`)} onClick={() => navigate('/me/highlights')}
> >
<FontAwesomeIcon icon={faHighlighter} /> <FontAwesomeIcon icon={faHighlighter} />
<span className="tab-label">Highlights</span> <span className="tab-label">Highlights</span>
</button> </button>
{isOwnProfile && ( <button
<> className={`me-tab ${activeTab === 'reading-list' ? 'active' : ''}`}
<button data-tab="reading-list"
className={`me-tab ${activeTab === 'reading-list' ? 'active' : ''}`} onClick={() => navigate('/me/reading-list')}
data-tab="reading-list" >
onClick={() => navigate('/me/reading-list')} <FontAwesomeIcon icon={faBookmark} />
> <span className="tab-label">Bookmarks</span>
<FontAwesomeIcon icon={faBookmark} /> </button>
<span className="tab-label">Bookmarks</span> <button
</button> className={`me-tab ${activeTab === 'reads' ? 'active' : ''}`}
<button data-tab="reads"
className={`me-tab ${activeTab === 'reads' ? 'active' : ''}`} onClick={() => navigate('/me/reads')}
data-tab="reads" >
onClick={() => navigate('/me/reads')} <FontAwesomeIcon icon={faBooks} />
> <span className="tab-label">Reads</span>
<FontAwesomeIcon icon={faBooks} /> </button>
<span className="tab-label">Reads</span> <button
</button> className={`me-tab ${activeTab === 'links' ? 'active' : ''}`}
<button data-tab="links"
className={`me-tab ${activeTab === 'links' ? 'active' : ''}`} onClick={() => navigate('/me/links')}
data-tab="links" >
onClick={() => navigate('/me/links')} <FontAwesomeIcon icon={faLink} />
> <span className="tab-label">Links</span>
<FontAwesomeIcon icon={faLink} /> </button>
<span className="tab-label">Links</span>
</button>
</>
)}
<button <button
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`} className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
data-tab="writings" data-tab="writings"
onClick={() => navigate(isOwnProfile ? '/me/writings' : `/p/${propPubkey && nip19.npubEncode(propPubkey)}/writings`)} onClick={() => navigate('/me/writings')}
> >
<FontAwesomeIcon icon={faPenToSquare} /> <FontAwesomeIcon icon={faPenToSquare} />
<span className="tab-label">Writings</span> <span className="tab-label">Writings</span>

271
src/components/Profile.tsx Normal file
View File

@@ -0,0 +1,271 @@
import React, { useState, useEffect, useCallback } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
import { IEventStore } from 'applesauce-core'
import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools'
import { useNavigate } from 'react-router-dom'
import { HighlightItem } from './HighlightItem'
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
import { fetchHighlights } from '../services/highlightService'
import { RELAYS } from '../config/relays'
import { KINDS } from '../config/kinds'
import AuthorCard from './AuthorCard'
import BlogPostCard from './BlogPostCard'
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
import { useStoreTimeline } from '../hooks/useStoreTimeline'
import { eventToHighlight } from '../services/highlightEventProcessor'
import { toBlogPostPreview } from '../utils/toBlogPostPreview'
import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator'
import { Hooks } from 'applesauce-react'
import { readingProgressController } from '../services/readingProgressController'
interface ProfileProps {
relayPool: RelayPool
eventStore: IEventStore
pubkey: string
activeTab?: 'highlights' | 'writings'
}
const Profile: React.FC<ProfileProps> = ({
relayPool,
eventStore,
pubkey,
activeTab: propActiveTab
}) => {
const navigate = useNavigate()
const activeAccount = Hooks.useActiveAccount()
const [activeTab, setActiveTab] = useState<'highlights' | 'writings'>(propActiveTab || 'highlights')
const [refreshTrigger, setRefreshTrigger] = useState(0)
// Reading progress state (naddr -> progress 0-1)
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
// Load cached data from event store instantly
const cachedHighlights = useStoreTimeline(
eventStore,
{ kinds: [KINDS.Highlights], authors: [pubkey] },
eventToHighlight,
[pubkey]
)
const cachedWritings = useStoreTimeline(
eventStore,
{ kinds: [30023], authors: [pubkey] },
toBlogPostPreview,
[pubkey]
)
// Update local state when prop changes
useEffect(() => {
if (propActiveTab) {
setActiveTab(propActiveTab)
}
}, [propActiveTab])
// Subscribe to reading progress controller
useEffect(() => {
// Get initial state immediately
const initialMap = readingProgressController.getProgressMap()
setReadingProgressMap(initialMap)
// Subscribe to updates
const unsubProgress = readingProgressController.onProgress((newMap) => {
setReadingProgressMap(newMap)
})
return () => {
unsubProgress()
}
}, [])
// Load reading progress data when logged in
useEffect(() => {
if (!activeAccount?.pubkey) {
return
}
readingProgressController.start({
relayPool,
eventStore,
pubkey: activeAccount.pubkey,
force: refreshTrigger > 0
})
}, [activeAccount?.pubkey, relayPool, eventStore, refreshTrigger])
// Background fetch to populate event store (non-blocking)
useEffect(() => {
if (!pubkey || !relayPool || !eventStore) return
// Fetch highlights in background
fetchHighlights(relayPool, pubkey, undefined, undefined, false, eventStore)
.then(() => {
// Highlights fetched
})
.catch(err => {
console.warn('⚠️ [Profile] Failed to fetch highlights:', err)
})
// Fetch writings in background (no limit for single user profile)
fetchBlogPostsFromAuthors(relayPool, [pubkey], RELAYS, undefined, null)
.then(writings => {
writings.forEach(w => eventStore.add(w.event))
})
.catch(err => {
console.warn('⚠️ [Profile] Failed to fetch writings:', err)
})
}, [pubkey, relayPool, eventStore, refreshTrigger])
// Pull-to-refresh
const { isRefreshing, pullPosition } = usePullToRefresh({
onRefresh: () => {
setRefreshTrigger(prev => prev + 1)
},
maximumPullLength: 240,
refreshThreshold: 80,
isDisabled: !pubkey
})
const getPostUrl = (post: BlogPostPreview) => {
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: post.author,
identifier: dTag
})
return `/a/${naddr}`
}
// Helper to get reading progress for a post
const getReadingProgress = useCallback((post: BlogPostPreview): number | undefined => {
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1]
if (!dTag) return undefined
try {
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: post.author,
identifier: dTag
})
const progress = readingProgressMap.get(naddr)
// Only log when found or map is empty
if (progress || readingProgressMap.size === 0) {
// Progress found or map is empty
}
return progress
} catch (err) {
return undefined
}
}, [readingProgressMap])
const handleHighlightDelete = () => {
// Not allowed to delete other users' highlights
return
}
const npub = nip19.npubEncode(pubkey)
const showSkeletons = cachedHighlights.length === 0 && cachedWritings.length === 0
const renderTabContent = () => {
switch (activeTab) {
case 'highlights':
if (showSkeletons) {
return (
<div className="explore-grid">
{Array.from({ length: 8 }).map((_, i) => (
<HighlightSkeleton key={i} />
))}
</div>
)
}
return cachedHighlights.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No highlights yet.
</div>
) : (
<div className="highlights-list me-highlights-list">
{cachedHighlights.map((highlight) => (
<HighlightItem
key={highlight.id}
highlight={{ ...highlight, level: 'mine' }}
relayPool={relayPool}
onHighlightDelete={handleHighlightDelete}
/>
))}
</div>
)
case 'writings':
if (showSkeletons) {
return (
<div className="explore-grid">
{Array.from({ length: 6 }).map((_, i) => (
<BlogPostSkeleton key={i} />
))}
</div>
)
}
return cachedWritings.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No articles written yet.
</div>
) : (
<div className="explore-grid">
{cachedWritings.map((post) => (
<BlogPostCard
key={post.event.id}
post={post}
href={getPostUrl(post)}
readingProgress={getReadingProgress(post)}
/>
))}
</div>
)
default:
return null
}
}
return (
<div className="explore-container">
<RefreshIndicator
isRefreshing={isRefreshing}
pullPosition={pullPosition}
/>
<div className="explore-header">
<AuthorCard authorPubkey={pubkey} clickable={false} />
<div className="me-tabs">
<button
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
data-tab="highlights"
onClick={() => navigate(`/p/${npub}`)}
>
<FontAwesomeIcon icon={faHighlighter} />
<span className="tab-label">Highlights</span>
</button>
<button
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
data-tab="writings"
onClick={() => navigate(`/p/${npub}/writings`)}
>
<FontAwesomeIcon icon={faPenToSquare} />
<span className="tab-label">Writings</span>
</button>
</div>
</div>
<div className="me-tab-content">
{renderTabContent()}
</div>
</div>
)
}
export default Profile

View File

@@ -1,9 +1,9 @@
import React from 'react' import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 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 { faEnvelope, faEnvelopeOpen } from '@fortawesome/free-regular-svg-icons' 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'
interface ReadingProgressFiltersProps { interface ReadingProgressFiltersProps {
selectedFilter: ReadingProgressFilterType selectedFilter: ReadingProgressFilterType
@@ -16,6 +16,7 @@ const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selecte
{ type: 'unopened' as const, icon: faEnvelope, label: 'Unopened' }, { type: 'unopened' as const, icon: faEnvelope, label: 'Unopened' },
{ type: 'started' as const, icon: faEnvelopeOpen, label: 'Started' }, { type: 'started' as const, icon: faEnvelopeOpen, label: 'Started' },
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' }, { type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
{ type: 'highlighted' as const, icon: faHighlighter, label: 'Highlighted' },
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' } { type: 'completed' as const, icon: faCheckCircle, label: 'Completed' }
] ]
@@ -23,8 +24,15 @@ const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selecte
<div className="bookmark-filters"> <div className="bookmark-filters">
{filters.map(filter => { {filters.map(filter => {
const isActive = selectedFilter === filter.type const isActive = selectedFilter === filter.type
// Only "completed" gets green color, everything else uses default blue // Only "completed" gets green color, "highlighted" gets yellow, everything else uses default blue
const activeStyle = isActive && filter.type === 'completed' ? { color: '#10b981' } : undefined 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
}
}
return ( return (
<button <button

View File

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

View File

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

View File

@@ -117,6 +117,32 @@ const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ setting
<span>Sync reading position across devices</span> <span>Sync reading position across devices</span>
</label> </label>
</div> </div>
<div className="setting-group">
<label htmlFor="autoMarkAsReadOnCompletion" className="checkbox-label">
<input
id="autoMarkAsReadOnCompletion"
type="checkbox"
checked={settings.autoMarkAsReadOnCompletion ?? false}
onChange={(e) => onUpdate({ autoMarkAsReadOnCompletion: e.target.checked })}
className="setting-checkbox"
/>
<span>Automatically mark as read 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> </div>
) )
} }

View File

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

View File

@@ -368,7 +368,9 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
summary={props.readerContent?.summary} summary={props.readerContent?.summary}
published={props.readerContent?.published} published={props.readerContent?.published}
selectedUrl={props.selectedUrl} selectedUrl={props.selectedUrl}
highlights={props.classifiedHighlights} highlights={props.selectedUrl && props.selectedUrl.startsWith('nostr:')
? props.highlights // article-specific highlights only
: props.classifiedHighlights}
showHighlights={props.showHighlights} showHighlights={props.showHighlights}
highlightStyle={props.settings.highlightStyle || 'marker'} highlightStyle={props.settings.highlightStyle || 'marker'}
highlightColor={props.settings.highlightColor || '#ffff00'} highlightColor={props.settings.highlightColor || '#ffff00'}

View File

@@ -1,8 +1,9 @@
// Nostr event kinds used throughout the application // Nostr event kinds used throughout the application
export const KINDS = { export const KINDS = {
Highlights: 9802, // NIP-?? user highlights Highlights: 9802, // NIP-84 user highlights
BlogPost: 30023, // NIP-23 long-form article BlogPost: 30023, // NIP-23 long-form article
AppData: 30078, // NIP-78 application data (reading positions) AppData: 30078, // NIP-78 application data
ReadingProgress: 39802, // NIP-85 reading progress
List: 30001, // NIP-51 list (addressable) List: 30001, // NIP-51 list (addressable)
ListReplaceable: 30003, // NIP-51 replaceable list ListReplaceable: 30003, // NIP-51 replaceable list
ListSimple: 10003, // NIP-51 simple list ListSimple: 10003, // NIP-51 simple list
@@ -13,3 +14,9 @@ export const KINDS = {
export type KindValue = typeof KINDS[keyof typeof 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

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

View File

@@ -64,8 +64,6 @@ export function useArticleLoader({
setCurrentArticleEventId(article.event.id) setCurrentArticleEventId(article.event.id)
setCurrentArticle?.(article.event) setCurrentArticle?.(article.event)
console.log('📰 Article loaded:', article.title)
console.log('📍 Coordinate:', articleCoordinate)
// Set reader loading to false immediately after article content is ready // Set reader loading to false immediately after article content is ready
// Don't wait for highlights to finish loading // Don't wait for highlights to finish loading
@@ -92,7 +90,6 @@ export function useArticleLoader({
}, },
settings settings
) )
console.log(`📌 Found ${highlightsMap.size} highlights`)
} catch (err) { } catch (err) {
console.error('Failed to fetch highlights:', err) console.error('Failed to fetch highlights:', err)
} finally { } finally {

View File

@@ -11,6 +11,7 @@ import { contactsController } from '../services/contactsController'
import { useStoreTimeline } from './useStoreTimeline' import { useStoreTimeline } from './useStoreTimeline'
import { eventToHighlight } from '../services/highlightEventProcessor' import { eventToHighlight } from '../services/highlightEventProcessor'
import { KINDS } from '../config/kinds' import { KINDS } from '../config/kinds'
import { nip19 } from 'nostr-tools'
interface UseBookmarksDataParams { interface UseBookmarksDataParams {
relayPool: RelayPool | null relayPool: RelayPool | null
@@ -44,21 +45,38 @@ export const useBookmarksData = ({
const [isRefreshing, setIsRefreshing] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false)
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null) const [lastFetchTime, setLastFetchTime] = useState<number | null>(null)
// Determine effective article coordinate as early as possible
// Prefer state-derived coordinate, but fall back to route naddr before content loads
const effectiveArticleCoordinate = useMemo(() => {
if (currentArticleCoordinate) return currentArticleCoordinate
if (!naddr) return undefined
try {
const decoded = nip19.decode(naddr)
if (decoded.type === 'naddr') {
const ptr = decoded.data as { kind: number; pubkey: string; identifier: string }
return `${ptr.kind}:${ptr.pubkey}:${ptr.identifier}`
}
} catch {
// ignore decode failure; treat as no coordinate yet
}
return undefined
}, [currentArticleCoordinate, naddr])
// Load cached article-specific highlights from event store // Load cached article-specific highlights from event store
const articleFilter = useMemo(() => { const articleFilter = useMemo(() => {
if (!currentArticleCoordinate) return null if (!effectiveArticleCoordinate) return null
return { return {
kinds: [KINDS.Highlights], kinds: [KINDS.Highlights],
'#a': [currentArticleCoordinate], '#a': [effectiveArticleCoordinate],
...(currentArticleEventId ? { '#e': [currentArticleEventId] } : {}) ...(currentArticleEventId ? { '#e': [currentArticleEventId] } : {})
} }
}, [currentArticleCoordinate, currentArticleEventId]) }, [effectiveArticleCoordinate, currentArticleEventId])
const cachedArticleHighlights = useStoreTimeline( const cachedArticleHighlights = useStoreTimeline(
eventStore || null, eventStore || null,
articleFilter || { kinds: [KINDS.Highlights], limit: 0 }, // empty filter if no article articleFilter || { kinds: [KINDS.Highlights], limit: 0 }, // empty filter if no article
eventToHighlight, eventToHighlight,
[currentArticleCoordinate, currentArticleEventId] [effectiveArticleCoordinate, currentArticleEventId]
) )
// Subscribe to centralized controllers // Subscribe to centralized controllers
@@ -84,7 +102,7 @@ export const useBookmarksData = ({
setHighlightsLoading(true) setHighlightsLoading(true)
try { try {
if (currentArticleCoordinate) { if (effectiveArticleCoordinate) {
// Seed with cached highlights first // Seed with cached highlights first
if (cachedArticleHighlights.length > 0) { if (cachedArticleHighlights.length > 0) {
setArticleHighlights(cachedArticleHighlights.sort((a, b) => b.created_at - a.created_at)) setArticleHighlights(cachedArticleHighlights.sort((a, b) => b.created_at - a.created_at))
@@ -97,7 +115,7 @@ export const useBookmarksData = ({
await fetchHighlightsForArticle( await fetchHighlightsForArticle(
relayPool, relayPool,
currentArticleCoordinate, effectiveArticleCoordinate,
currentArticleEventId, currentArticleEventId,
(highlight) => { (highlight) => {
// Deduplicate highlights by ID as they arrive // Deduplicate highlights by ID as they arrive
@@ -120,7 +138,7 @@ export const useBookmarksData = ({
} finally { } finally {
setHighlightsLoading(false) setHighlightsLoading(false)
} }
}, [relayPool, currentArticleCoordinate, currentArticleEventId, settings, eventStore, cachedArticleHighlights]) }, [relayPool, effectiveArticleCoordinate, currentArticleEventId, settings, eventStore, cachedArticleHighlights])
const handleRefreshAll = useCallback(async () => { const handleRefreshAll = useCallback(async () => {
if (!relayPool || !activeAccount || isRefreshing) return if (!relayPool || !activeAccount || isRefreshing) return
@@ -143,19 +161,20 @@ export const useBookmarksData = ({
if (!relayPool || !activeAccount) return if (!relayPool || !activeAccount) return
// Fetch article-specific highlights when viewing an article // Fetch article-specific highlights when viewing an article
// External URLs have their highlights fetched by useExternalUrlLoader // External URLs have their highlights fetched by useExternalUrlLoader
if (currentArticleCoordinate && !externalUrl) { if (effectiveArticleCoordinate && !externalUrl) {
handleFetchHighlights() handleFetchHighlights()
} else if (!naddr && !externalUrl) { } else if (!naddr && !externalUrl) {
// Clear article highlights when not viewing an article // Clear article highlights when not viewing an article
setArticleHighlights([]) setArticleHighlights([])
setHighlightsLoading(false) setHighlightsLoading(false)
} }
}, [relayPool, activeAccount, currentArticleCoordinate, naddr, externalUrl, handleFetchHighlights]) }, [relayPool, activeAccount, effectiveArticleCoordinate, naddr, externalUrl, handleFetchHighlights])
// Merge highlights from controller with article-specific highlights // When viewing an article, show only article-specific highlights
const highlights = [...myHighlights, ...articleHighlights] // Otherwise, show user's highlights from controller
.filter((h, i, arr) => arr.findIndex(x => x.id === h.id) === i) // Deduplicate const highlights = effectiveArticleCoordinate || externalUrl
.sort((a, b) => b.created_at - a.created_at) ? articleHighlights.sort((a, b) => b.created_at - a.created_at)
: myHighlights
return { return {
highlights, highlights,

View File

@@ -77,7 +77,6 @@ export function useExternalUrlLoader({
const content = await fetchReadableContent(url) const content = await fetchReadableContent(url)
setReaderContent(content) setReaderContent(content)
console.log('🌐 External URL loaded:', content.title)
// Set reader loading to false immediately after content is ready // Set reader loading to false immediately after content is ready
setReaderLoading(false) setReaderLoading(false)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,40 +4,47 @@ interface UseReadingPositionOptions {
enabled?: boolean enabled?: boolean
onPositionChange?: (position: number) => void onPositionChange?: (position: number) => void
onReadingComplete?: () => void onReadingComplete?: () => void
readingCompleteThreshold?: number // Default 0.9 (90%) readingCompleteThreshold?: number // Default 0.95 (95%) - matches filter threshold
syncEnabled?: boolean // Whether to sync positions to Nostr syncEnabled?: boolean // Whether to sync positions to Nostr
onSave?: (position: number) => void // Callback for saving position onSave?: (position: number) => void // Callback for saving position
autoSaveInterval?: number // Auto-save interval in ms (default 5000) autoSaveInterval?: number // Auto-save interval in ms (default 5000)
completionHoldMs?: number // How long to hold at 100% before firing complete (default 2000)
} }
export const useReadingPosition = ({ export const useReadingPosition = ({
enabled = true, enabled = true,
onPositionChange, onPositionChange,
onReadingComplete, onReadingComplete,
readingCompleteThreshold = 0.9, readingCompleteThreshold = 0.95, // Match filter threshold for consistency
syncEnabled = false, syncEnabled = false,
onSave, onSave,
autoSaveInterval = 5000 autoSaveInterval = 5000,
completionHoldMs = 2000
}: UseReadingPositionOptions = {}) => { }: UseReadingPositionOptions = {}) => {
const [position, setPosition] = useState(0) const [position, setPosition] = useState(0)
const [isReadingComplete, setIsReadingComplete] = useState(false) const [isReadingComplete, setIsReadingComplete] = useState(false)
const hasTriggeredComplete = useRef(false) const hasTriggeredComplete = useRef(false)
const lastSavedPosition = useRef(0) const lastSavedPosition = useRef(0)
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const hasSavedOnce = useRef(false)
const completionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Debounced save function // Debounced save function
const scheduleSave = useCallback((currentPosition: number) => { const scheduleSave = useCallback((currentPosition: number) => {
if (!syncEnabled || !onSave) return if (!syncEnabled || !onSave) {
return
// Don't save if position is too low (< 5%) }
if (currentPosition < 0.05) return
// Don't save if position hasn't changed significantly (less than 1%) // Don't save if position hasn't changed significantly (less than 1%)
// But always save if we've reached 100% (completion) // But always save if we've reached 100% (completion)
const hasSignificantChange = Math.abs(currentPosition - lastSavedPosition.current) >= 0.01 const hasSignificantChange = Math.abs(currentPosition - lastSavedPosition.current) >= 0.01
const hasReachedCompletion = currentPosition === 1 && lastSavedPosition.current < 1 const hasReachedCompletion = currentPosition === 1 && lastSavedPosition.current < 1
const isInitialSave = !hasSavedOnce.current
if (!hasSignificantChange && !hasReachedCompletion) return if (!hasSignificantChange && !hasReachedCompletion && !isInitialSave) {
// Not significant enough to save
return
}
// Clear existing timer // Clear existing timer
if (saveTimerRef.current) { if (saveTimerRef.current) {
@@ -47,6 +54,7 @@ export const useReadingPosition = ({
// Schedule new save // Schedule new save
saveTimerRef.current = setTimeout(() => { saveTimerRef.current = setTimeout(() => {
lastSavedPosition.current = currentPosition lastSavedPosition.current = currentPosition
hasSavedOnce.current = true
onSave(currentPosition) onSave(currentPosition)
}, autoSaveInterval) }, autoSaveInterval)
}, [syncEnabled, onSave, autoSaveInterval]) }, [syncEnabled, onSave, autoSaveInterval])
@@ -61,11 +69,10 @@ export const useReadingPosition = ({
saveTimerRef.current = null saveTimerRef.current = null
} }
// Save if position is meaningful (>= 5%) // Always allow immediate save (including 0%)
if (position >= 0.05) { lastSavedPosition.current = position
lastSavedPosition.current = position hasSavedOnce.current = true
onSave(position) onSave(position)
}
}, [syncEnabled, onSave, position]) }, [syncEnabled, onSave, position])
useEffect(() => { useEffect(() => {
@@ -89,17 +96,46 @@ export const useReadingPosition = ({
const isAtBottom = scrollTop + windowHeight >= documentHeight - 5 const isAtBottom = scrollTop + windowHeight >= documentHeight - 5
const clampedProgress = isAtBottom ? 1 : Math.max(0, Math.min(1, scrollProgress)) const clampedProgress = isAtBottom ? 1 : Math.max(0, Math.min(1, scrollProgress))
// Only log on significant changes (every 5%) to avoid flooding console
const prevPercent = Math.floor(position * 20) // Groups by 5%
const newPercent = Math.floor(clampedProgress * 20)
if (prevPercent !== newPercent) {
// Position threshold crossed
}
setPosition(clampedProgress) setPosition(clampedProgress)
onPositionChange?.(clampedProgress) onPositionChange?.(clampedProgress)
// Schedule auto-save if sync is enabled // Schedule auto-save if sync is enabled
scheduleSave(clampedProgress) scheduleSave(clampedProgress)
// Check if reading is complete // Completion detection with 2s hold at 100%
if (clampedProgress >= readingCompleteThreshold && !hasTriggeredComplete.current) { if (!hasTriggeredComplete.current) {
setIsReadingComplete(true) // If at exact 100%, start a hold timer; cancel if we scroll up
hasTriggeredComplete.current = true if (clampedProgress === 1) {
onReadingComplete?.() if (!completionTimerRef.current) {
completionTimerRef.current = setTimeout(() => {
if (!hasTriggeredComplete.current && position === 1) {
setIsReadingComplete(true)
hasTriggeredComplete.current = true
onReadingComplete?.()
}
completionTimerRef.current = null
}, completionHoldMs)
}
} else {
// If we moved off 100%, cancel any pending completion hold
if (completionTimerRef.current) {
clearTimeout(completionTimerRef.current)
completionTimerRef.current = null
// still allow threshold-based completion for near-bottom if configured
if (clampedProgress >= readingCompleteThreshold) {
setIsReadingComplete(true)
hasTriggeredComplete.current = true
onReadingComplete?.()
}
}
}
} }
} }
@@ -118,7 +154,12 @@ export const useReadingPosition = ({
if (saveTimerRef.current) { if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current) clearTimeout(saveTimerRef.current)
} }
if (completionTimerRef.current) {
clearTimeout(completionTimerRef.current)
}
} }
// position is intentionally not in deps - it's computed from scroll and would cause infinite re-renders
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, scheduleSave]) }, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, scheduleSave])
// Reset reading complete state when enabled changes // Reset reading complete state when enabled changes
@@ -126,6 +167,12 @@ export const useReadingPosition = ({
if (!enabled) { if (!enabled) {
setIsReadingComplete(false) setIsReadingComplete(false)
hasTriggeredComplete.current = false hasTriggeredComplete.current = false
hasSavedOnce.current = false
lastSavedPosition.current = 0
if (completionTimerRef.current) {
clearTimeout(completionTimerRef.current)
completionTimerRef.current = null
}
} }
}, [enabled]) }, [enabled])

View File

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

View File

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

View File

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

View File

@@ -126,18 +126,14 @@ class BookmarkController {
generation: number generation: number
): void { ): void {
if (!this.eventLoader) { if (!this.eventLoader) {
console.warn('[bookmark] ⚠️ EventLoader not initialized')
return return
} }
// Filter to unique IDs not already hydrated // Filter to unique IDs not already hydrated
const unique = Array.from(new Set(ids)).filter(id => !idToEvent.has(id)) const unique = Array.from(new Set(ids)).filter(id => !idToEvent.has(id))
if (unique.length === 0) { if (unique.length === 0) {
console.log('[bookmark] 🔧 All IDs already hydrated, skipping')
return return
} }
console.log('[bookmark] 🔧 Hydrating', unique.length, 'IDs using EventLoader')
// Convert IDs to EventPointers // Convert IDs to EventPointers
const pointers: EventPointer[] = unique.map(id => ({ id })) const pointers: EventPointer[] = unique.map(id => ({ id }))
@@ -159,8 +155,8 @@ class BookmarkController {
onProgress() onProgress()
}, },
error: (error) => { error: () => {
console.error('[bookmark] ❌ EventLoader error:', error) // Silent error - EventLoader handles retries
} }
}) })
} }
@@ -175,14 +171,11 @@ class BookmarkController {
generation: number generation: number
): void { ): void {
if (!this.addressLoader) { if (!this.addressLoader) {
console.warn('[bookmark] ⚠️ AddressLoader not initialized')
return return
} }
if (coords.length === 0) return if (coords.length === 0) return
console.log('[bookmark] 🔧 Hydrating', coords.length, 'coordinates using AddressLoader')
// Convert coordinates to AddressPointers // Convert coordinates to AddressPointers
const pointers = coords.map(c => ({ const pointers = coords.map(c => ({
kind: c.kind, kind: c.kind,
@@ -203,8 +196,8 @@ class BookmarkController {
onProgress() onProgress()
}, },
error: (error) => { error: () => {
console.error('[bookmark] ❌ AddressLoader error:', error) // Silent error - AddressLoader handles retries
} }
}) })
} }
@@ -223,10 +216,6 @@ class BookmarkController {
return this.decryptedResults.has(getEventKey(evt)) return this.decryptedResults.has(getEventKey(evt))
}) })
const unencryptedCount = allEvents.filter(evt => !hasEncryptedContent(evt)).length
const decryptedCount = readyEvents.length - unencryptedCount
console.log('[bookmark] 📋 Building bookmarks:', unencryptedCount, 'unencrypted,', decryptedCount, 'decrypted, of', allEvents.length, 'total')
if (readyEvents.length === 0) { if (readyEvents.length === 0) {
this.bookmarksListeners.forEach(cb => cb([])) this.bookmarksListeners.forEach(cb => cb([]))
return return
@@ -237,17 +226,14 @@ class BookmarkController {
const unencryptedEvents = readyEvents.filter(evt => !hasEncryptedContent(evt)) const unencryptedEvents = readyEvents.filter(evt => !hasEncryptedContent(evt))
const decryptedEvents = readyEvents.filter(evt => hasEncryptedContent(evt)) const decryptedEvents = readyEvents.filter(evt => hasEncryptedContent(evt))
console.log('[bookmark] 🔧 Processing', unencryptedEvents.length, 'unencrypted events')
// Process unencrypted events // Process unencrypted events
const { publicItemsAll: publicUnencrypted, privateItemsAll: privateUnencrypted, newestCreatedAt, latestContent, allTags } = const { publicItemsAll: publicUnencrypted, privateItemsAll: privateUnencrypted, newestCreatedAt, latestContent, allTags } =
await collectBookmarksFromEvents(unencryptedEvents, activeAccount, signerCandidate) await collectBookmarksFromEvents(unencryptedEvents, activeAccount, signerCandidate)
console.log('[bookmark] 🔧 Unencrypted returned:', publicUnencrypted.length, 'public,', privateUnencrypted.length, 'private')
// Merge in decrypted results // Merge in decrypted results
let publicItemsAll = [...publicUnencrypted] let publicItemsAll = [...publicUnencrypted]
let privateItemsAll = [...privateUnencrypted] let privateItemsAll = [...privateUnencrypted]
console.log('[bookmark] 🔧 Merging', decryptedEvents.length, 'decrypted events')
decryptedEvents.forEach(evt => { decryptedEvents.forEach(evt => {
const eventKey = getEventKey(evt) const eventKey = getEventKey(evt)
const decrypted = this.decryptedResults.get(eventKey) const decrypted = this.decryptedResults.get(eventKey)
@@ -256,11 +242,8 @@ class BookmarkController {
privateItemsAll = [...privateItemsAll, ...decrypted.privateItems] privateItemsAll = [...privateItemsAll, ...decrypted.privateItems]
} }
}) })
console.log('[bookmark] 🔧 Total after merge:', publicItemsAll.length, 'public,', privateItemsAll.length, 'private')
const allItems = [...publicItemsAll, ...privateItemsAll] const allItems = [...publicItemsAll, ...privateItemsAll]
console.log('[bookmark] 🔧 Total items to process:', allItems.length)
// Separate hex IDs from coordinates // Separate hex IDs from coordinates
const noteIds: string[] = [] const noteIds: string[] = []
@@ -276,14 +259,11 @@ class BookmarkController {
// Helper to build and emit bookmarks // Helper to build and emit bookmarks
const emitBookmarks = (idToEvent: Map<string, NostrEvent>) => { const emitBookmarks = (idToEvent: Map<string, NostrEvent>) => {
console.log('[bookmark] 🔧 Building final bookmarks list...')
const allBookmarks = dedupeBookmarksById([ const allBookmarks = dedupeBookmarksById([
...hydrateItems(publicItemsAll, idToEvent), ...hydrateItems(publicItemsAll, idToEvent),
...hydrateItems(privateItemsAll, idToEvent) ...hydrateItems(privateItemsAll, idToEvent)
]) ])
console.log('[bookmark] 🔧 After hydration and dedup:', allBookmarks.length, 'bookmarks')
console.log('[bookmark] 🔧 Enriching and sorting...')
const enriched = allBookmarks.map(b => ({ const enriched = allBookmarks.map(b => ({
...b, ...b,
tags: b.tags || [], tags: b.tags || [],
@@ -293,9 +273,7 @@ class BookmarkController {
const sortedBookmarks = enriched const sortedBookmarks = enriched
.map(b => ({ ...b, urlReferences: extractUrlsFromContent(b.content) })) .map(b => ({ ...b, urlReferences: extractUrlsFromContent(b.content) }))
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0))) .sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
console.log('[bookmark] 🔧 Sorted:', sortedBookmarks.length, 'bookmarks')
console.log('[bookmark] 🔧 Creating final Bookmark object...')
const bookmark: Bookmark = { const bookmark: Bookmark = {
id: `${activeAccount.pubkey}-bookmarks`, id: `${activeAccount.pubkey}-bookmarks`,
title: `Bookmarks (${sortedBookmarks.length})`, title: `Bookmarks (${sortedBookmarks.length})`,
@@ -310,18 +288,14 @@ class BookmarkController {
encryptedContent: undefined encryptedContent: undefined
} }
console.log('[bookmark] 📋 Built bookmark with', sortedBookmarks.length, 'items')
console.log('[bookmark] 📤 Emitting to', this.bookmarksListeners.length, 'listeners')
this.bookmarksListeners.forEach(cb => cb([bookmark])) this.bookmarksListeners.forEach(cb => cb([bookmark]))
} }
// Emit immediately with empty metadata (show placeholders) // Emit immediately with empty metadata (show placeholders)
const idToEvent: Map<string, NostrEvent> = new Map() const idToEvent: Map<string, NostrEvent> = new Map()
console.log('[bookmark] 🚀 Emitting initial bookmarks with placeholders (IDs only)...')
emitBookmarks(idToEvent) emitBookmarks(idToEvent)
// Now fetch events progressively in background using batched hydrators // Now fetch events progressively in background using batched hydrators
console.log('[bookmark] 🔧 Background hydration:', noteIds.length, 'note IDs and', coordinates.length, 'coordinates')
const generation = this.hydrationGeneration const generation = this.hydrationGeneration
const onProgress = () => emitBookmarks(idToEvent) const onProgress = () => emitBookmarks(idToEvent)
@@ -341,9 +315,7 @@ class BookmarkController {
this.hydrateByIds(noteIds, idToEvent, onProgress, generation) this.hydrateByIds(noteIds, idToEvent, onProgress, generation)
this.hydrateByCoordinates(coordObjs, idToEvent, onProgress, generation) this.hydrateByCoordinates(coordObjs, idToEvent, onProgress, generation)
} catch (error) { } catch (error) {
console.error('[bookmark] ❌ Failed to build bookmarks:', error) console.error('Failed to build bookmarks:', error)
console.error('[bookmark] ❌ Error details:', error instanceof Error ? error.message : String(error))
console.error('[bookmark] ❌ Stack:', error instanceof Error ? error.stack : 'no stack')
this.bookmarksListeners.forEach(cb => cb([])) this.bookmarksListeners.forEach(cb => cb([]))
} }
} }
@@ -356,7 +328,6 @@ class BookmarkController {
const { relayPool, activeAccount, accountManager } = options const { relayPool, activeAccount, accountManager } = options
if (!activeAccount || typeof (activeAccount as { pubkey?: string }).pubkey !== 'string') { if (!activeAccount || typeof (activeAccount as { pubkey?: string }).pubkey !== 'string') {
console.error('[bookmark] Invalid activeAccount')
return return
} }
@@ -366,7 +337,6 @@ class BookmarkController {
this.hydrationGeneration++ this.hydrationGeneration++
// Initialize loaders for this session // Initialize loaders for this session
console.log('[bookmark] 🔧 Initializing EventLoader and AddressLoader with', RELAYS.length, 'relays')
this.eventLoader = createEventLoader(relayPool, { this.eventLoader = createEventLoader(relayPool, {
eventStore: this.eventStore, eventStore: this.eventStore,
extraRelays: RELAYS extraRelays: RELAYS
@@ -377,7 +347,6 @@ class BookmarkController {
}) })
this.setLoading(true) this.setLoading(true)
console.log('[bookmark] 🔍 Starting bookmark load for', account.pubkey.slice(0, 8))
try { try {
// Get signer for auto-decryption // Get signer for auto-decryption
@@ -405,7 +374,6 @@ class BookmarkController {
// Add/update event // Add/update event
this.currentEvents.set(key, evt) this.currentEvents.set(key, evt)
console.log('[bookmark] 📨 Event:', evt.kind, evt.id.slice(0, 8), 'encrypted:', hasEncryptedContent(evt))
// Emit raw event for Debug UI // Emit raw event for Debug UI
this.emitRawEvent(evt) this.emitRawEvent(evt)
@@ -415,12 +383,13 @@ class BookmarkController {
if (!isEncrypted) { if (!isEncrypted) {
// For unencrypted events, build bookmarks immediately (progressive update) // For unencrypted events, build bookmarks immediately (progressive update)
this.buildAndEmitBookmarks(maybeAccount, signerCandidate) this.buildAndEmitBookmarks(maybeAccount, signerCandidate)
.catch(err => console.error('[bookmark] ❌ Failed to update after event:', err)) .catch(() => {
// Silent error - will retry on next event
})
} }
// Auto-decrypt if event has encrypted content (fire-and-forget, non-blocking) // Auto-decrypt if event has encrypted content (fire-and-forget, non-blocking)
if (isEncrypted) { if (isEncrypted) {
console.log('[bookmark] 🔓 Auto-decrypting event', evt.id.slice(0, 8))
// Don't await - let it run in background // Don't await - let it run in background
collectBookmarksFromEvents([evt], account, signerCandidate) collectBookmarksFromEvents([evt], account, signerCandidate)
.then(({ publicItemsAll, privateItemsAll, newestCreatedAt, latestContent, allTags }) => { .then(({ publicItemsAll, privateItemsAll, newestCreatedAt, latestContent, allTags }) => {
@@ -433,10 +402,6 @@ class BookmarkController {
latestContent, latestContent,
allTags allTags
}) })
console.log('[bookmark] ✅ Auto-decrypted:', evt.id.slice(0, 8), {
public: publicItemsAll.length,
private: privateItemsAll.length
})
// Emit decrypt complete for Debug UI // Emit decrypt complete for Debug UI
this.decryptCompleteListeners.forEach(cb => this.decryptCompleteListeners.forEach(cb =>
@@ -445,10 +410,12 @@ class BookmarkController {
// Rebuild bookmarks with newly decrypted content (progressive update) // Rebuild bookmarks with newly decrypted content (progressive update)
this.buildAndEmitBookmarks(maybeAccount, signerCandidate) this.buildAndEmitBookmarks(maybeAccount, signerCandidate)
.catch(err => console.error('[bookmark] ❌ Failed to update after decrypt:', err)) .catch(() => {
// Silent error - will retry on next event
})
}) })
.catch((error) => { .catch(() => {
console.error('[bookmark] ❌ Auto-decrypt failed:', evt.id.slice(0, 8), error) // Silent error - decrypt failed
}) })
} }
} }
@@ -457,9 +424,8 @@ class BookmarkController {
// Final update after EOSE // Final update after EOSE
await this.buildAndEmitBookmarks(maybeAccount, signerCandidate) await this.buildAndEmitBookmarks(maybeAccount, signerCandidate)
console.log('[bookmark] ✅ Bookmark load complete')
} catch (error) { } catch (error) {
console.error('[bookmark] ❌ Failed to load bookmarks:', error) console.error('Failed to load bookmarks:', error)
this.bookmarksListeners.forEach(cb => cb([])) this.bookmarksListeners.forEach(cb => cb([]))
} finally { } finally {
this.setLoading(false) this.setLoading(false)

View File

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

View File

@@ -15,7 +15,6 @@ export const fetchContacts = async (
): Promise<Set<string>> => { ): Promise<Set<string>> => {
try { try {
const relayUrls = prioritizeLocalRelays(Array.from(relayPool.relays.values()).map(relay => relay.url)) 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 partialFollowed = new Set<string>()
const events = await queryEvents( const events = await queryEvents(
@@ -51,9 +50,7 @@ export const fetchContacts = async (
} }
// merged already via streams // merged already via streams
console.log('📊 Contact events fetched:', events.length)
console.log('👥 Followed contacts:', followed.size)
return followed return followed
} catch (error) { } catch (error) {
console.error('Failed to fetch contacts:', 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) // Skip if already loaded for this pubkey (unless forced)
if (!force && this.isLoadedFor(pubkey)) { if (!force && this.isLoadedFor(pubkey)) {
console.log('[contacts] ✅ Already loaded for', pubkey.slice(0, 8))
this.emitContacts(this.currentContacts) this.emitContacts(this.currentContacts)
return return
} }
this.setLoading(true) this.setLoading(true)
console.log('[contacts] 🔍 Loading contacts for', pubkey.slice(0, 8))
try { try {
const contacts = await fetchContacts( const contacts = await fetchContacts(
@@ -89,7 +87,6 @@ class ContactsController {
// Stream partial updates // Stream partial updates
this.currentContacts = new Set(partial) this.currentContacts = new Set(partial)
this.emitContacts(this.currentContacts) this.emitContacts(this.currentContacts)
console.log('[contacts] 📥 Partial contacts:', partial.size)
} }
) )
@@ -98,7 +95,6 @@ class ContactsController {
this.lastLoadedPubkey = pubkey this.lastLoadedPubkey = pubkey
this.emitContacts(this.currentContacts) this.emitContacts(this.currentContacts)
console.log('[contacts] ✅ Loaded', contacts.size, 'contacts')
} catch (error) { } catch (error) {
console.error('[contacts] ❌ Failed to load contacts:', error) console.error('[contacts] ❌ Failed to load contacts:', error)
this.currentContacts.clear() this.currentContacts.clear()

View File

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

View File

@@ -20,29 +20,34 @@ export interface BlogPostPreview {
* @param relayPool - The relay pool to query * @param relayPool - The relay pool to query
* @param pubkeys - Array of pubkeys to fetch posts from * @param pubkeys - Array of pubkeys to fetch posts from
* @param relayUrls - Array of relay URLs to query * @param relayUrls - Array of relay URLs to query
* @param onPost - Optional callback for streaming posts
* @param limit - Limit for number of events to fetch (default: 100, pass null for no limit)
* @returns Array of blog post previews * @returns Array of blog post previews
*/ */
export const fetchBlogPostsFromAuthors = async ( export const fetchBlogPostsFromAuthors = async (
relayPool: RelayPool, relayPool: RelayPool,
pubkeys: string[], pubkeys: string[],
relayUrls: string[], relayUrls: string[],
onPost?: (post: BlogPostPreview) => void onPost?: (post: BlogPostPreview) => void,
limit: number | null = 100
): Promise<BlogPostPreview[]> => { ): Promise<BlogPostPreview[]> => {
try { try {
if (pubkeys.length === 0) { if (pubkeys.length === 0) {
console.log('⚠️ No pubkeys to fetch blog posts from')
return [] return []
} }
console.log('📚 Fetching blog posts (kind 30023) from', pubkeys.length, 'authors')
// Deduplicate replaceable events by keeping the most recent version // Deduplicate replaceable events by keeping the most recent version
// Group by author + d-tag identifier // Group by author + d-tag identifier
const uniqueEvents = new Map<string, NostrEvent>() const uniqueEvents = new Map<string, NostrEvent>()
const filter = limit !== null
? { kinds: [KINDS.BlogPost], authors: pubkeys, limit }
: { kinds: [KINDS.BlogPost], authors: pubkeys }
await queryEvents( await queryEvents(
relayPool, relayPool,
{ kinds: [KINDS.BlogPost], authors: pubkeys, limit: 100 }, filter,
{ {
relayUrls, relayUrls,
onEvent: (event: NostrEvent) => { onEvent: (event: NostrEvent) => {
@@ -68,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) // Convert to blog post previews and sort by published date (most recent first)
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values()) const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
@@ -90,7 +94,6 @@ export const fetchBlogPostsFromAuthors = async (
return timeB - timeA // Most recent first return timeB - timeA // Most recent first
}) })
console.log('📰 Processed', blogPosts.length, 'unique blog posts')
return blogPosts return blogPosts
} catch (error) { } catch (error) {

View File

@@ -46,7 +46,6 @@ export async function createHighlight(
} }
// Create EventFactory with the account as signer // 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 }) const factory = new EventFactory({ signer: account.signer })
let blueprintSource: NostrEvent | AddressPointer | string let blueprintSource: NostrEvent | AddressPointer | string
@@ -117,9 +116,7 @@ export async function createHighlight(
} }
// Sign the event // Sign the event
console.log('[bunker] Signing highlight event...', { kind: highlightEvent.kind, tags: highlightEvent.tags.length })
const signedEvent = await factory.sign(highlightEvent) 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 // Use unified write service to store and publish
await publishEvent(relayPool, eventStore, signedEvent) await publishEvent(relayPool, eventStore, signedEvent)

View File

@@ -22,7 +22,6 @@ export const fetchHighlights = async (
const cacheKey = highlightCache.authorKey(pubkey) const cacheKey = highlightCache.authorKey(pubkey)
const cached = highlightCache.get(cacheKey) const cached = highlightCache.get(cacheKey)
if (cached) { if (cached) {
console.log(`📌 Using cached highlights for author (${cached.length} items)`)
// Stream cached highlights if callback provided // Stream cached highlights if callback provided
if (onHighlight) { if (onHighlight) {
cached.forEach(h => onHighlight(h)) 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 // Store all events in event store if provided
if (eventStore) { if (eventStore) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,12 +4,12 @@ import { queryEvents } from './dataFetch'
import { RELAYS } from '../config/relays' import { RELAYS } from '../config/relays'
import { KINDS } from '../config/kinds' import { KINDS } from '../config/kinds'
import { ReadItem } from './readsService' import { ReadItem } from './readsService'
import { processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor' import { processReadingProgress, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor'
import { mergeReadItem } from '../utils/readItemMerge' import { mergeReadItem } from '../utils/readItemMerge'
/** /**
* Fetches external URL links with reading progress from: * Fetches external URL links with reading progress from:
* - URLs with reading progress (kind:30078) * - URLs with reading progress (kind:39802)
* - Manually marked as read URLs (kind:7, kind:17) * - Manually marked as read URLs (kind:7, kind:17)
*/ */
export async function fetchLinks( export async function fetchLinks(
@@ -17,7 +17,6 @@ export async function fetchLinks(
userPubkey: string, userPubkey: string,
onItem?: (item: ReadItem) => void onItem?: (item: ReadItem) => void
): Promise<ReadItem[]> { ): Promise<ReadItem[]> {
console.log('🔗 [Links] Fetching external links for user:', userPubkey.slice(0, 8))
const linksMap = new Map<string, ReadItem>() const linksMap = new Map<string, ReadItem>()
@@ -32,18 +31,13 @@ export async function fetchLinks(
try { try {
// Fetch all data sources in parallel // Fetch all data sources in parallel
const [readingPositionEvents, markedAsReadArticles] = await Promise.all([ const [progressEvents, markedAsReadArticles] = await Promise.all([
queryEvents(relayPool, { kinds: [KINDS.AppData], authors: [userPubkey] }, { relayUrls: RELAYS }), queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [userPubkey] }, { relayUrls: RELAYS }),
fetchReadArticles(relayPool, userPubkey) fetchReadArticles(relayPool, userPubkey)
]) ])
console.log('📊 [Links] Data fetched:', { // Process reading progress events (kind 39802)
readingPositions: readingPositionEvents.length, processReadingProgress(progressEvents, linksMap)
markedAsRead: markedAsReadArticles.length
})
// Process reading positions and emit external items
processReadingPositions(readingPositionEvents, linksMap)
if (onItem) { if (onItem) {
linksMap.forEach(item => { linksMap.forEach(item => {
if (item.type === 'external') { if (item.type === 'external') {
@@ -79,7 +73,6 @@ export async function fetchLinks(
const validLinks = filterValidItems(links) const validLinks = filterValidItems(links)
const sortedLinks = sortByReadingActivity(validLinks) const sortedLinks = sortByReadingActivity(validLinks)
console.log('✅ [Links] Processed', sortedLinks.length, 'total links')
return sortedLinks return sortedLinks
} catch (error) { } catch (error) {

View File

@@ -0,0 +1,139 @@
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { Highlight } from '../types/highlights'
import { queryEvents } from './dataFetch'
import { KINDS } from '../config/kinds'
import { eventToHighlight, sortHighlights } from './highlightEventProcessor'
type HighlightsCallback = (highlights: Highlight[]) => void
type LoadingCallback = (loading: boolean) => void
const LAST_SYNCED_KEY = 'nostrverse_highlights_last_synced'
class NostrverseHighlightsController {
private highlightsListeners: HighlightsCallback[] = []
private loadingListeners: LoadingCallback[] = []
private currentHighlights: Highlight[] = []
private loaded = false
private generation = 0
onHighlights(cb: HighlightsCallback): () => void {
this.highlightsListeners.push(cb)
return () => {
this.highlightsListeners = this.highlightsListeners.filter(l => l !== cb)
}
}
onLoading(cb: LoadingCallback): () => void {
this.loadingListeners.push(cb)
return () => {
this.loadingListeners = this.loadingListeners.filter(l => l !== cb)
}
}
private setLoading(loading: boolean): void {
this.loadingListeners.forEach(cb => cb(loading))
}
private emitHighlights(highlights: Highlight[]): void {
this.highlightsListeners.forEach(cb => cb(highlights))
}
getHighlights(): Highlight[] {
return [...this.currentHighlights]
}
isLoaded(): boolean {
return this.loaded
}
private getLastSyncedAt(): number | null {
try {
const raw = localStorage.getItem(LAST_SYNCED_KEY)
if (!raw) return null
const parsed = JSON.parse(raw)
return typeof parsed?.ts === 'number' ? parsed.ts : null
} catch {
return null
}
}
private setLastSyncedAt(timestamp: number): void {
try {
localStorage.setItem(LAST_SYNCED_KEY, JSON.stringify({ ts: timestamp }))
} catch { /* ignore */ }
}
async start(options: {
relayPool: RelayPool
eventStore: IEventStore
force?: boolean
}): Promise<void> {
const { relayPool, eventStore, force = false } = options
if (!force && this.loaded) {
this.emitHighlights(this.currentHighlights)
return
}
this.generation++
const currentGeneration = this.generation
this.setLoading(true)
try {
const seenIds = new Set<string>()
const highlightsMap = new Map<string, Highlight>()
const lastSyncedAt = force ? null : this.getLastSyncedAt()
const filter: { kinds: number[]; since?: number } = { kinds: [KINDS.Highlights] }
if (lastSyncedAt) filter.since = lastSyncedAt
const events = await queryEvents(
relayPool,
filter,
{
onEvent: (evt) => {
if (currentGeneration !== this.generation) return
if (seenIds.has(evt.id)) return
seenIds.add(evt.id)
eventStore.add(evt)
const highlight = eventToHighlight(evt)
highlightsMap.set(highlight.id, highlight)
const sorted = sortHighlights(Array.from(highlightsMap.values()))
this.currentHighlights = sorted
this.emitHighlights(sorted)
}
}
)
if (currentGeneration !== this.generation) return
events.forEach(evt => eventStore.add(evt))
const highlights = events.map(eventToHighlight)
const unique = Array.from(new Map(highlights.map(h => [h.id, h])).values())
const sorted = sortHighlights(unique)
this.currentHighlights = sorted
this.loaded = true
this.emitHighlights(sorted)
if (sorted.length > 0) {
const newest = Math.max(...sorted.map(h => h.created_at))
this.setLastSyncedAt(newest)
}
} catch (err) {
this.currentHighlights = []
this.emitHighlights(this.currentHighlights)
} finally {
if (currentGeneration === this.generation) this.setLoading(false)
}
}
}
export const nostrverseHighlightsController = new NostrverseHighlightsController()

View File

@@ -20,10 +20,10 @@ export const fetchNostrverseBlogPosts = async (
relayPool: RelayPool, relayPool: RelayPool,
relayUrls: string[], relayUrls: string[],
limit = 50, limit = 50,
eventStore?: IEventStore eventStore?: IEventStore,
onPost?: (post: BlogPostPreview) => void
): Promise<BlogPostPreview[]> => { ): Promise<BlogPostPreview[]> => {
try { try {
console.log('[NOSTRVERSE] 📚 Fetching blog posts (kind 30023), limit:', limit)
// Deduplicate replaceable events by keeping the most recent version // Deduplicate replaceable events by keeping the most recent version
const uniqueEvents = new Map<string, NostrEvent>() const uniqueEvents = new Map<string, NostrEvent>()
@@ -44,12 +44,24 @@ export const fetchNostrverseBlogPosts = async (
const existing = uniqueEvents.get(key) const existing = uniqueEvents.get(key)
if (!existing || event.created_at > existing.created_at) { if (!existing || event.created_at > existing.created_at) {
uniqueEvents.set(key, event) uniqueEvents.set(key, event)
// Stream post immediately if callback provided
if (onPost) {
const post: BlogPostPreview = {
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
}
onPost(post)
}
} }
} }
} }
) )
console.log('[NOSTRVERSE] 📊 Blog post events fetched (unique):', uniqueEvents.size)
// Convert to blog post previews and sort by published date (most recent first) // Convert to blog post previews and sort by published date (most recent first)
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values()) const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
@@ -67,7 +79,6 @@ export const fetchNostrverseBlogPosts = async (
return timeB - timeA // Most recent first return timeB - timeA // Most recent first
}) })
console.log('[NOSTRVERSE] 📰 Processed', blogPosts.length, 'unique blog posts')
return blogPosts return blogPosts
} catch (error) { } catch (error) {
@@ -89,9 +100,10 @@ export const fetchNostrverseHighlights = async (
eventStore?: IEventStore eventStore?: IEventStore
): Promise<Highlight[]> => { ): Promise<Highlight[]> => {
try { try {
console.log('[NOSTRVERSE] 💡 Fetching highlights (kind 9802), limit:', limit)
const seenIds = new Set<string>() const seenIds = new Set<string>()
// Collect but do not block callers awaiting network completion
const collected: NostrEvent[] = []
const rawEvents = await queryEvents( const rawEvents = await queryEvents(
relayPool, relayPool,
{ kinds: [9802], limit }, { kinds: [9802], limit },
@@ -104,6 +116,7 @@ export const fetchNostrverseHighlights = async (
if (eventStore) { if (eventStore) {
eventStore.add(event) eventStore.add(event)
} }
collected.push(event)
} }
} }
) )
@@ -113,10 +126,9 @@ export const fetchNostrverseHighlights = async (
rawEvents.forEach(evt => eventStore.add(evt)) rawEvents.forEach(evt => eventStore.add(evt))
} }
const uniqueEvents = dedupeHighlights(rawEvents) const uniqueEvents = dedupeHighlights([...collected, ...rawEvents])
const highlights = uniqueEvents.map(eventToHighlight) const highlights = uniqueEvents.map(eventToHighlight)
console.log('[NOSTRVERSE] 💡 Processed', highlights.length, 'unique highlights')
return sortHighlights(highlights) return sortHighlights(highlights)
} catch (error) { } catch (error) {

View File

@@ -0,0 +1,169 @@
import { RelayPool } from 'applesauce-relay'
import { IEventStore, Helpers } from 'applesauce-core'
import { NostrEvent } from 'nostr-tools'
import { KINDS } from '../config/kinds'
import { queryEvents } from './dataFetch'
import { BlogPostPreview } from './exploreService'
const { getArticleTitle, getArticleSummary, getArticleImage, getArticlePublished } = Helpers
type WritingsCallback = (posts: BlogPostPreview[]) => void
type LoadingCallback = (loading: boolean) => void
const LAST_SYNCED_KEY = 'nostrverse_writings_last_synced'
function toPreview(event: NostrEvent): BlogPostPreview {
return {
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
}
}
function sortPosts(posts: BlogPostPreview[]): BlogPostPreview[] {
return posts.slice().sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
}
class NostrverseWritingsController {
private writingsListeners: WritingsCallback[] = []
private loadingListeners: LoadingCallback[] = []
private currentPosts: BlogPostPreview[] = []
private loaded = false
private generation = 0
onWritings(cb: WritingsCallback): () => void {
this.writingsListeners.push(cb)
return () => {
this.writingsListeners = this.writingsListeners.filter(l => l !== cb)
}
}
onLoading(cb: LoadingCallback): () => void {
this.loadingListeners.push(cb)
return () => {
this.loadingListeners = this.loadingListeners.filter(l => l !== cb)
}
}
private setLoading(loading: boolean): void {
this.loadingListeners.forEach(cb => cb(loading))
}
private emitWritings(posts: BlogPostPreview[]): void {
this.writingsListeners.forEach(cb => cb(posts))
}
getWritings(): BlogPostPreview[] {
return [...this.currentPosts]
}
isLoaded(): boolean {
return this.loaded
}
private getLastSyncedAt(): number | null {
try {
const raw = localStorage.getItem(LAST_SYNCED_KEY)
if (!raw) return null
const parsed = JSON.parse(raw)
return typeof parsed?.ts === 'number' ? parsed.ts : null
} catch {
return null
}
}
private setLastSyncedAt(ts: number): void {
try { localStorage.setItem(LAST_SYNCED_KEY, JSON.stringify({ ts })) } catch { /* ignore */ }
}
async start(options: {
relayPool: RelayPool
eventStore: IEventStore
force?: boolean
}): Promise<void> {
const { relayPool, eventStore, force = false } = options
if (!force && this.loaded) {
this.emitWritings(this.currentPosts)
return
}
this.generation++
const currentGeneration = this.generation
this.setLoading(true)
try {
const seenIds = new Set<string>()
const uniqueByReplaceable = new Map<string, BlogPostPreview>()
const lastSyncedAt = force ? null : this.getLastSyncedAt()
const filter: { kinds: number[]; since?: number } = { kinds: [KINDS.BlogPost] }
if (lastSyncedAt) filter.since = lastSyncedAt
const events = await queryEvents(
relayPool,
filter,
{
onEvent: (evt) => {
if (currentGeneration !== this.generation) return
if (seenIds.has(evt.id)) return
seenIds.add(evt.id)
eventStore.add(evt)
const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${evt.pubkey}:${dTag}`
const preview = toPreview(evt)
const existing = uniqueByReplaceable.get(key)
if (!existing || evt.created_at > existing.event.created_at) {
uniqueByReplaceable.set(key, preview)
const sorted = sortPosts(Array.from(uniqueByReplaceable.values()))
this.currentPosts = sorted
this.emitWritings(sorted)
}
}
}
)
if (currentGeneration !== this.generation) return
events.forEach(evt => eventStore.add(evt))
events.forEach(evt => {
const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${evt.pubkey}:${dTag}`
const existing = uniqueByReplaceable.get(key)
if (!existing || evt.created_at > existing.event.created_at) {
uniqueByReplaceable.set(key, toPreview(evt))
}
})
const sorted = sortPosts(Array.from(uniqueByReplaceable.values()))
this.currentPosts = sorted
this.loaded = true
this.emitWritings(sorted)
if (sorted.length > 0) {
const newest = Math.max(...sorted.map(p => p.event.created_at))
this.setLastSyncedAt(newest)
}
} catch {
this.currentPosts = []
this.emitWritings(this.currentPosts)
} finally {
if (currentGeneration === this.generation) this.setLoading(false)
}
}
}
export const nostrverseWritingsController = new NostrverseWritingsController()

View File

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

View File

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

View File

@@ -42,12 +42,10 @@ export async function createEventReaction(
const signed = await factory.sign(draft) const signed = await factory.sign(draft)
console.log('📚 Created kind:7 reaction (mark as read) for event:', eventId.slice(0, 8))
// Publish to relays // Publish to relays
await relayPool.publish(RELAYS, signed) await relayPool.publish(RELAYS, signed)
console.log('✅ Reaction published to', RELAYS.length, 'relay(s)')
return signed return signed
} }
@@ -94,12 +92,10 @@ export async function createWebsiteReaction(
const signed = await factory.sign(draft) const signed = await factory.sign(draft)
console.log('📚 Created kind:17 reaction (mark as read) for URL:', normalizedUrl)
// Publish to relays // Publish to relays
await relayPool.publish(RELAYS, signed) await relayPool.publish(RELAYS, signed)
console.log('✅ Website reaction published to', RELAYS.length, 'relay(s)')
return signed return signed
} }

View File

@@ -1,8 +1,9 @@
import { NostrEvent } from 'nostr-tools' import { NostrEvent, nip19 } from 'nostr-tools'
import { ReadItem } from './readsService' import { ReadItem } from './readsService'
import { fallbackTitleFromUrl } from '../utils/readItemMerge' import { fallbackTitleFromUrl } from '../utils/readItemMerge'
import { KINDS } from '../config/kinds'
const READING_POSITION_PREFIX = 'boris:reading-position:' const READING_PROGRESS_KIND = KINDS.ReadingProgress // 39802 - NIP-85
interface ReadArticle { interface ReadArticle {
id: string id: string
@@ -13,44 +14,81 @@ interface ReadArticle {
} }
/** /**
* Processes reading position events into ReadItems * Processes reading progress events (kind 39802) into ReadItems
*
* Test scenarios:
* - Kind 39802 with d="30023:..." → article ReadItem with naddr id
* - Kind 39802 with d="url:..." → external ReadItem with decoded URL
* - Newer event.created_at overwrites older timestamp
* - Invalid d tag format → skip event
* - Malformed JSON content → skip event
*/ */
export function processReadingPositions( export function processReadingProgress(
events: NostrEvent[], events: NostrEvent[],
readsMap: Map<string, ReadItem> readsMap: Map<string, ReadItem>
): void { ): void {
for (const event of events) { for (const event of events) {
if (event.kind !== READING_PROGRESS_KIND) {
continue
}
const dTag = event.tags.find(t => t[0] === 'd')?.[1] const dTag = event.tags.find(t => t[0] === 'd')?.[1]
if (!dTag || !dTag.startsWith(READING_POSITION_PREFIX)) continue if (!dTag) {
continue
const identifier = dTag.replace(READING_POSITION_PREFIX, '') }
try { try {
const positionData = JSON.parse(event.content) const content = JSON.parse(event.content)
const position = positionData.position const position = content.progress || 0
const timestamp = positionData.timestamp
// Validate progress is between 0 and 1 (NIP-85 requirement)
if (position < 0 || position > 1) {
continue
}
// Use event.created_at as authoritative timestamp (NIP-85 spec)
const timestamp = event.created_at
let itemId: string let itemId: string
let itemUrl: string | undefined let itemUrl: string | undefined
let itemType: 'article' | 'external' = 'external' let itemType: 'article' | 'external' = 'external'
// Check if it's a nostr article (naddr format) // Check if d tag is a coordinate (30023:pubkey:identifier)
if (identifier.startsWith('naddr1')) { if (dTag.startsWith('30023:')) {
itemId = identifier // It's a nostr article coordinate
itemType = 'article' const parts = dTag.split(':')
} else { if (parts.length === 3) {
// It's a base64url-encoded URL // Convert to naddr for consistency with the rest of the app
try {
const naddr = nip19.naddrEncode({
kind: parseInt(parts[0]),
pubkey: parts[1],
identifier: parts[2]
})
itemId = naddr
itemType = 'article'
} catch (e) {
continue
}
} else {
continue
}
} else if (dTag.startsWith('url:')) {
// It's a URL with base64url encoding
const encoded = dTag.replace('url:', '')
try { try {
itemUrl = atob(identifier.replace(/-/g, '+').replace(/_/g, '/')) itemUrl = atob(encoded.replace(/-/g, '+').replace(/_/g, '/'))
itemId = itemUrl itemId = itemUrl
itemType = 'external' itemType = 'external'
} catch (e) { } catch (e) {
console.warn('Failed to decode URL identifier:', identifier)
continue continue
} }
} else {
continue
} }
// Add or update the item // Add or update the item, preferring newer timestamps
const existing = readsMap.get(itemId) const existing = readsMap.get(itemId)
if (!existing || !existing.readingTimestamp || timestamp > existing.readingTimestamp) { if (!existing || !existing.readingTimestamp || timestamp > existing.readingTimestamp) {
readsMap.set(itemId, { readsMap.set(itemId, {
@@ -64,7 +102,7 @@ export function processReadingPositions(
}) })
} }
} catch (error) { } catch (error) {
console.warn('Failed to parse reading position:', error) // Silently fail
} }
} }
} }

View File

@@ -1,13 +1,13 @@
import { IEventStore, mapEventsToStore } from 'applesauce-core' import { IEventStore, mapEventsToStore } from 'applesauce-core'
import { EventFactory } from 'applesauce-factory' import { EventFactory } from 'applesauce-factory'
import { RelayPool, onlyEvents } from 'applesauce-relay' import { RelayPool, onlyEvents } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools' import { NostrEvent, nip19 } from 'nostr-tools'
import { firstValueFrom } from 'rxjs' import { firstValueFrom } from 'rxjs'
import { publishEvent } from './writeService' import { publishEvent } from './writeService'
import { RELAYS } from '../config/relays' import { RELAYS } from '../config/relays'
import { KINDS } from '../config/kinds'
const APP_DATA_KIND = 30078 // NIP-78 Application Data const READING_PROGRESS_KIND = KINDS.ReadingProgress // 39802 - NIP-85 Reading Progress
const READING_POSITION_PREFIX = 'boris:reading-position:'
export interface ReadingPosition { export interface ReadingPosition {
position: number // 0-1 scroll progress position: number // 0-1 scroll progress
@@ -15,16 +15,79 @@ export interface ReadingPosition {
scrollTop?: number // Optional: pixel position scrollTop?: number // Optional: pixel position
} }
// Helper to extract and parse reading position from an event export interface ReadingProgressContent {
function getReadingPositionContent(event: NostrEvent): ReadingPosition | undefined { progress: number // 0-1 scroll progress
ts?: number // Unix timestamp (optional, for display)
loc?: number // Optional: pixel position
ver?: string // Schema version
}
// Helper to extract and parse reading progress from event (kind 39802)
function getReadingProgressContent(event: NostrEvent): ReadingPosition | undefined {
if (!event.content || event.content.length === 0) return undefined if (!event.content || event.content.length === 0) return undefined
try { try {
return JSON.parse(event.content) as ReadingPosition const content = JSON.parse(event.content) as ReadingProgressContent
return {
position: content.progress,
timestamp: content.ts || event.created_at,
scrollTop: content.loc
}
} catch { } catch {
return undefined return undefined
} }
} }
// Generate d tag for kind 39802 based on target
// Test cases:
// - naddr1... → "30023:<pubkey>:<identifier>"
// - https://example.com/post → "url:<base64url>"
// - Invalid naddr → "url:<base64url>" (fallback)
function generateDTag(naddrOrUrl: string): string {
// If it's a nostr article (naddr format), decode and build coordinate
if (naddrOrUrl.startsWith('naddr1')) {
try {
const decoded = nip19.decode(naddrOrUrl)
if (decoded.type === 'naddr') {
const dTag = `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier || ''}`
return dTag
}
} catch (e) {
// Ignore decode errors
}
}
// For URLs, use url: prefix with base64url encoding
const base64url = btoa(naddrOrUrl)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
return `url:${base64url}`
}
// Generate tags for kind 39802 event
function generateProgressTags(naddrOrUrl: string): string[][] {
const dTag = generateDTag(naddrOrUrl)
const tags: string[][] = [['d', dTag]]
// Add 'a' tag for nostr articles
if (naddrOrUrl.startsWith('naddr1')) {
try {
const decoded = nip19.decode(naddrOrUrl)
if (decoded.type === 'naddr') {
const coordinate = `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier || ''}`
tags.push(['a', coordinate])
}
} catch (e) {
// Ignore decode errors
}
} else {
// Add 'r' tag for URLs
tags.push(['r', naddrOrUrl])
}
return tags
}
/** /**
* Generate a unique identifier for an article * Generate a unique identifier for an article
* For Nostr articles: use the naddr directly * For Nostr articles: use the naddr directly
@@ -43,7 +106,7 @@ export function generateArticleIdentifier(naddrOrUrl: string): string {
} }
/** /**
* Save reading position to Nostr (Kind 30078) * Save reading position to Nostr (kind 39802)
*/ */
export async function saveReadingPosition( export async function saveReadingPosition(
relayPool: RelayPool, relayPool: RelayPool,
@@ -52,36 +115,31 @@ export async function saveReadingPosition(
articleIdentifier: string, articleIdentifier: string,
position: ReadingPosition position: ReadingPosition
): Promise<void> { ): Promise<void> {
console.log('💾 [ReadingPosition] Saving position:', { const now = Math.floor(Date.now() / 1000)
identifier: articleIdentifier.slice(0, 32) + '...',
position: position.position,
positionPercent: Math.round(position.position * 100) + '%',
timestamp: position.timestamp,
scrollTop: position.scrollTop
})
const dTag = `${READING_POSITION_PREFIX}${articleIdentifier}`
const progressContent: ReadingProgressContent = {
progress: position.position,
ts: position.timestamp,
loc: position.scrollTop,
ver: '1'
}
const tags = generateProgressTags(articleIdentifier)
const draft = await factory.create(async () => ({ const draft = await factory.create(async () => ({
kind: APP_DATA_KIND, kind: READING_PROGRESS_KIND,
content: JSON.stringify(position), content: JSON.stringify(progressContent),
tags: [ tags,
['d', dTag], created_at: now
['client', 'boris']
],
created_at: Math.floor(Date.now() / 1000)
})) }))
const signed = await factory.sign(draft) const signed = await factory.sign(draft)
// Use unified write service
await publishEvent(relayPool, eventStore, signed) await publishEvent(relayPool, eventStore, signed)
console.log('✅ [ReadingPosition] Position saved successfully, event ID:', signed.id.slice(0, 8))
} }
/** /**
* Load reading position from Nostr * Load reading position from Nostr (kind 39802)
*/ */
export async function loadReadingPosition( export async function loadReadingPosition(
relayPool: RelayPool, relayPool: RelayPool,
@@ -89,32 +147,20 @@ export async function loadReadingPosition(
pubkey: string, pubkey: string,
articleIdentifier: string articleIdentifier: string
): Promise<ReadingPosition | null> { ): Promise<ReadingPosition | null> {
const dTag = `${READING_POSITION_PREFIX}${articleIdentifier}` const dTag = generateDTag(articleIdentifier)
console.log('📖 [ReadingPosition] Loading position:', { // Check local event store first
pubkey: pubkey.slice(0, 8) + '...',
identifier: articleIdentifier.slice(0, 32) + '...',
dTag: dTag.slice(0, 50) + '...'
})
// First, check if we already have the position in the local event store
try { try {
const localEvent = await firstValueFrom( const localEvent = await firstValueFrom(
eventStore.replaceable(APP_DATA_KIND, pubkey, dTag) eventStore.replaceable(READING_PROGRESS_KIND, pubkey, dTag)
) )
if (localEvent) { if (localEvent) {
const content = getReadingPositionContent(localEvent) const content = getReadingProgressContent(localEvent)
if (content) { if (content) {
console.log('✅ [ReadingPosition] Loaded from local store:', { // Fetch from relays in background to get any updates
position: content.position,
positionPercent: Math.round(content.position * 100) + '%',
timestamp: content.timestamp
})
// Still fetch from relays in the background to get any updates
relayPool relayPool
.subscription(RELAYS, { .subscription(RELAYS, {
kinds: [APP_DATA_KIND], kinds: [READING_PROGRESS_KIND],
authors: [pubkey], authors: [pubkey],
'#d': [dTag] '#d': [dTag]
}) })
@@ -125,23 +171,43 @@ export async function loadReadingPosition(
} }
} }
} catch (err) { } catch (err) {
console.log('📭 No cached reading position found, fetching from relays...') // Ignore errors and fetch from relays
} }
// If not in local store, fetch from relays // Fetch from relays
const result = await fetchFromRelays(
relayPool,
eventStore,
pubkey,
READING_PROGRESS_KIND,
dTag,
getReadingProgressContent
)
return result || null
}
// Helper function to fetch from relays with timeout
async function fetchFromRelays(
relayPool: RelayPool,
eventStore: IEventStore,
pubkey: string,
kind: number,
dTag: string,
parser: (event: NostrEvent) => ReadingPosition | undefined
): Promise<ReadingPosition | null> {
return new Promise((resolve) => { return new Promise((resolve) => {
let hasResolved = false let hasResolved = false
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
if (!hasResolved) { if (!hasResolved) {
console.log('⏱️ Reading position load timeout - no position found')
hasResolved = true hasResolved = true
resolve(null) resolve(null)
} }
}, 3000) // Shorter timeout for reading positions }, 3000)
const sub = relayPool const sub = relayPool
.subscription(RELAYS, { .subscription(RELAYS, {
kinds: [APP_DATA_KIND], kinds: [kind],
authors: [pubkey], authors: [pubkey],
'#d': [dTag] '#d': [dTag]
}) })
@@ -153,33 +219,20 @@ export async function loadReadingPosition(
hasResolved = true hasResolved = true
try { try {
const event = await firstValueFrom( const event = await firstValueFrom(
eventStore.replaceable(APP_DATA_KIND, pubkey, dTag) eventStore.replaceable(kind, pubkey, dTag)
) )
if (event) { if (event) {
const content = getReadingPositionContent(event) const content = parser(event)
if (content) { resolve(content || null)
console.log('✅ [ReadingPosition] Loaded from relays:', {
position: content.position,
positionPercent: Math.round(content.position * 100) + '%',
timestamp: content.timestamp
})
resolve(content)
} else {
console.log('⚠️ [ReadingPosition] Event found but no valid content')
resolve(null)
}
} else { } else {
console.log('📭 [ReadingPosition] No position found on relays')
resolve(null) resolve(null)
} }
} catch (err) { } catch (err) {
console.error('❌ Error loading reading position:', err)
resolve(null) resolve(null)
} }
} }
}, },
error: (err) => { error: () => {
console.error('❌ Reading position subscription error:', err)
clearTimeout(timeout) clearTimeout(timeout)
if (!hasResolved) { if (!hasResolved) {
hasResolved = true hasResolved = true

View File

@@ -0,0 +1,277 @@
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { Filter, 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'
type ProgressMapCallback = (progressMap: Map<string, number>) => void
type LoadingCallback = (loading: boolean) => void
const LAST_SYNCED_KEY = 'reading_progress_last_synced'
const PROGRESS_CACHE_KEY = 'reading_progress_cache_v1'
/**
* Shared reading progress controller
* Manages the user's reading progress (kind:39802) centrally
*/
class ReadingProgressController {
private progressListeners: ProgressMapCallback[] = []
private loadingListeners: LoadingCallback[] = []
private currentProgressMap: Map<string, number> = new Map()
private lastLoadedPubkey: string | null = null
private generation = 0
private timelineSubscription: { unsubscribe: () => void } | null = null
onProgress(cb: ProgressMapCallback): () => void {
this.progressListeners.push(cb)
return () => {
this.progressListeners = this.progressListeners.filter(l => l !== cb)
}
}
onLoading(cb: LoadingCallback): () => void {
this.loadingListeners.push(cb)
return () => {
this.loadingListeners = this.loadingListeners.filter(l => l !== cb)
}
}
private setLoading(loading: boolean): void {
this.loadingListeners.forEach(cb => cb(loading))
}
private emitProgress(progressMap: Map<string, number>): void {
this.progressListeners.forEach(cb => cb(new Map(progressMap)))
}
/**
* Get current reading progress map without triggering a reload
*/
getProgressMap(): Map<string, number> {
return new Map(this.currentProgressMap)
}
/**
* Load cached progress from localStorage for a pubkey
*/
private loadCachedProgress(pubkey: string): Map<string, number> {
try {
const raw = localStorage.getItem(PROGRESS_CACHE_KEY)
if (!raw) return new Map()
const parsed = JSON.parse(raw) as Record<string, Record<string, number>>
const forUser = parsed[pubkey] || {}
return new Map(Object.entries(forUser))
} catch {
return new Map()
}
}
/**
* Save current progress map to localStorage for the active pubkey
*/
private persistProgress(pubkey: string, progressMap: Map<string, number>): void {
try {
const raw = localStorage.getItem(PROGRESS_CACHE_KEY)
const parsed: Record<string, Record<string, number>> = raw ? JSON.parse(raw) : {}
parsed[pubkey] = Object.fromEntries(progressMap.entries())
localStorage.setItem(PROGRESS_CACHE_KEY, JSON.stringify(parsed))
} catch (err) {
// Silently fail cache persistence
}
}
/**
* Get progress for a specific article by naddr
*/
getProgress(naddr: string): number | undefined {
return this.currentProgressMap.get(naddr)
}
/**
* Check if reading progress is loaded for a specific pubkey
*/
isLoadedFor(pubkey: string): boolean {
return this.lastLoadedPubkey === pubkey
}
/**
* Reset state (for logout or manual refresh)
*/
reset(): void {
this.generation++
// Unsubscribe from any active timeline subscription
if (this.timelineSubscription) {
try {
this.timelineSubscription.unsubscribe()
} catch (err) {
// Silently fail on unsubscribe
}
this.timelineSubscription = null
}
this.currentProgressMap = new Map()
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
*/
private updateLastSyncedAt(pubkey: string, timestamp: number): void {
try {
const data = localStorage.getItem(LAST_SYNCED_KEY)
const parsed = data ? JSON.parse(data) : {}
parsed[pubkey] = timestamp
localStorage.setItem(LAST_SYNCED_KEY, JSON.stringify(parsed))
} catch (err) {
// Silently fail
}
}
/**
* Load and watch reading progress for a user
*/
async start(params: {
relayPool: RelayPool
eventStore: IEventStore
pubkey: string
force?: boolean
}): Promise<void> {
const { relayPool, eventStore, pubkey, force = false } = params
const startGeneration = this.generation
// Skip if already loaded for this pubkey and not forcing
if (!force && this.isLoadedFor(pubkey)) {
return
}
this.setLoading(true)
this.lastLoadedPubkey = pubkey
try {
// Seed from local cache immediately (survives refresh/flight mode)
const cached = this.loadCachedProgress(pubkey)
if (cached.size > 0) {
this.currentProgressMap = cached
this.emitProgress(this.currentProgressMap)
}
// Subscribe to local timeline for immediate and reactive updates
// Clean up any previous subscription first
if (this.timelineSubscription) {
try {
this.timelineSubscription.unsubscribe()
} catch (err) {
// Silently fail
}
this.timelineSubscription = null
}
const timeline$ = eventStore.timeline({
kinds: [KINDS.ReadingProgress],
authors: [pubkey]
})
const generationAtSubscribe = this.generation
this.timelineSubscription = timeline$.subscribe((localEvents: NostrEvent[]) => {
// Ignore if controller generation has changed (e.g., logout/login)
if (generationAtSubscribe !== this.generation) return
if (!Array.isArray(localEvents) || localEvents.length === 0) return
this.processEvents(localEvents)
})
// 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 = {
kinds: [KINDS.ReadingProgress],
authors: [pubkey]
}
if (lastSynced && !needsFullSync) {
filter.since = lastSynced
}
const relayEvents = await queryEvents(relayPool, filter, { relayUrls: RELAYS })
if (startGeneration !== this.generation) {
return
}
if (relayEvents.length > 0) {
// Add to event store
relayEvents.forEach(e => eventStore.add(e))
// Process and emit (merge with existing)
this.processEvents(relayEvents)
// Update last synced
const now = Math.floor(Date.now() / 1000)
this.updateLastSyncedAt(pubkey, now)
}
} catch (err) {
console.error('📊 [ReadingProgress] Failed to load:', err)
} finally {
if (startGeneration === this.generation) {
this.setLoading(false)
}
}
}
/**
* Process events and update progress map
*/
private processEvents(events: NostrEvent[]): void {
const readsMap = new Map<string, ReadItem>()
// Merge with existing progress
for (const [id, progress] of this.currentProgressMap.entries()) {
readsMap.set(id, {
id,
source: 'reading-progress',
type: 'article',
readingProgress: progress
})
}
// Process new events
processReadingProgress(events, readsMap)
// 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)
}
}
this.currentProgressMap = newProgressMap
this.emitProgress(this.currentProgressMap)
// Persist for current user so it survives refresh/flight mode
if (this.lastLoadedPubkey) {
this.persistProgress(this.lastLoadedPubkey, this.currentProgressMap)
}
}
}
export const readingProgressController = new ReadingProgressController()

View File

@@ -8,7 +8,7 @@ import { RELAYS } from '../config/relays'
import { KINDS } from '../config/kinds' import { KINDS } from '../config/kinds'
import { classifyBookmarkType } from '../utils/bookmarkTypeClassifier' import { classifyBookmarkType } from '../utils/bookmarkTypeClassifier'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import { processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor' import { processReadingProgress, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor'
import { mergeReadItem } from '../utils/readItemMerge' import { mergeReadItem } from '../utils/readItemMerge'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
@@ -37,7 +37,7 @@ export interface ReadItem {
/** /**
* Fetches all reads from multiple sources: * Fetches all reads from multiple sources:
* - Bookmarked articles (kind:30023) and article/website URLs * - Bookmarked articles (kind:30023) and article/website URLs
* - Articles/URLs with reading progress (kind:30078) * - Articles/URLs with reading progress (kind:39802)
* - Manually marked as read articles/URLs (kind:7, kind:17) * - Manually marked as read articles/URLs (kind:7, kind:17)
*/ */
export async function fetchAllReads( export async function fetchAllReads(
@@ -46,7 +46,6 @@ export async function fetchAllReads(
bookmarks: Bookmark[], bookmarks: Bookmark[],
onItem?: (item: ReadItem) => void onItem?: (item: ReadItem) => void
): Promise<ReadItem[]> { ): Promise<ReadItem[]> {
console.log('📚 [Reads] Fetching all reads for user:', userPubkey.slice(0, 8))
const readsMap = new Map<string, ReadItem>() const readsMap = new Map<string, ReadItem>()
@@ -61,24 +60,13 @@ export async function fetchAllReads(
try { try {
// Fetch all data sources in parallel // Fetch all data sources in parallel
const [readingPositionEvents, markedAsReadArticles] = await Promise.all([ const [progressEvents, markedAsReadArticles] = await Promise.all([
queryEvents(relayPool, { kinds: [KINDS.AppData], authors: [userPubkey] }, { relayUrls: RELAYS }), queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [userPubkey] }, { relayUrls: RELAYS }),
fetchReadArticles(relayPool, userPubkey) fetchReadArticles(relayPool, userPubkey)
]) ])
console.log('📊 [Reads] Data fetched:', { // Process reading progress events (kind 39802)
readingPositions: readingPositionEvents.length, processReadingProgress(progressEvents, readsMap)
markedAsRead: markedAsReadArticles.length,
bookmarks: bookmarks.length
})
// Process reading positions and emit items
processReadingPositions(readingPositionEvents, readsMap)
if (onItem) {
readsMap.forEach(item => {
if (item.type === 'article') onItem(item)
})
}
// Process marked-as-read and emit items // Process marked-as-read and emit items
processMarkedAsRead(markedAsReadArticles, readsMap) processMarkedAsRead(markedAsReadArticles, readsMap)
@@ -120,7 +108,6 @@ export async function fetchAllReads(
.map(item => item.id) .map(item => item.id)
if (articleCoordinates.length > 0) { if (articleCoordinates.length > 0) {
console.log('📖 [Reads] Fetching article events for', articleCoordinates.length, 'articles')
// Parse coordinates and fetch events // Parse coordinates and fetch events
const articlesToFetch: Array<{ pubkey: string; identifier: string }> = [] const articlesToFetch: Array<{ pubkey: string; identifier: string }> = []
@@ -187,7 +174,6 @@ export async function fetchAllReads(
const validArticles = filterValidItems(articles) const validArticles = filterValidItems(articles)
const sortedReads = sortByReadingActivity(validArticles) const sortedReads = sortByReadingActivity(validArticles)
console.log('✅ [Reads] Processed', sortedReads.length, 'total reads')
return sortedReads return sortedReads
} catch (error) { } 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 we're in flight mode (only local relays connected) and user wants to broadcast to all relays, skip
if (broadcastToAll && !hasRemoteConnection) { if (broadcastToAll && !hasRemoteConnection) {
console.log('✈️ Flight mode: skipping rebroadcast to remote relays')
return return
} }
@@ -50,7 +49,6 @@ export async function rebroadcastEvents(
} }
if (targetRelays.length === 0) { if (targetRelays.length === 0) {
console.log('📡 No target relays for rebroadcast')
return return
} }
@@ -58,7 +56,6 @@ export async function rebroadcastEvents(
const rebroadcastPromises = events.map(async (event) => { const rebroadcastPromises = events.map(async (event) => {
try { try {
await relayPool.publish(targetRelays, event) await relayPool.publish(targetRelays, event)
console.log('📡 Rebroadcast event', event.id?.slice(0, 8), 'to', targetRelays.length, 'relay(s)')
} catch (error) { } catch (error) {
console.warn('⚠️ Failed to rebroadcast event', event.id?.slice(0, 8), 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) => { Promise.all(rebroadcastPromises).catch((err) => {
console.warn('⚠️ Some rebroadcasts failed:', 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 connectedCount = statuses.filter(s => s.isInPool).length
const disconnectedCount = statuses.filter(s => !s.isInPool).length const disconnectedCount = statuses.filter(s => !s.isInPool).length
if (connectedCount === 0 || disconnectedCount > 0) { if (connectedCount === 0 || disconnectedCount > 0) {
console.log(`🔌 Relay status: ${connectedCount} connected, ${disconnectedCount} disconnected`) // Debug: relay status changed, but we're not logging it
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(', '))
} }
// Add recently seen relays that are no longer connected // Add recently seen relays that are no longer connected

View File

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

View File

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

View File

@@ -14,9 +14,11 @@ export async function publishEvent(
eventStore: IEventStore, eventStore: IEventStore,
event: NostrEvent event: NostrEvent
): Promise<void> { ): Promise<void> {
const isProgressEvent = event.kind === 39802
const logPrefix = isProgressEvent ? '[progress]' : ''
// Store the event in the local EventStore FIRST for immediate UI display // Store the event in the local EventStore FIRST for immediate UI display
eventStore.add(event) eventStore.add(event)
console.log('💾 Stored event in EventStore:', event.id.slice(0, 8), `(kind ${event.kind})`)
// Check current connection status - are we online or in flight mode? // Check current connection status - are we online or in flight mode?
const connectedRelays = Array.from(relayPool.relays.values()) const connectedRelays = Array.from(relayPool.relays.values())
@@ -32,13 +34,7 @@ export async function publishEvent(
const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays) const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays)
console.log('📍 Event relay status:', { // Publishing event
targetRelays: RELAYS.length,
expectedSuccessRelays: expectedSuccessRelays.length,
isLocalOnly,
hasRemoteConnection,
eventId: event.id.slice(0, 8)
})
// If we're in local-only mode, mark this event for later sync // If we're in local-only mode, mark this event for later sync
if (isLocalOnly) { if (isLocalOnly) {
@@ -48,10 +44,9 @@ export async function publishEvent(
// Publish to all configured relays in the background (non-blocking) // Publish to all configured relays in the background (non-blocking)
relayPool.publish(RELAYS, event) relayPool.publish(RELAYS, event)
.then(() => { .then(() => {
console.log('✅ Event published to', RELAYS.length, 'relay(s):', event.id.slice(0, 8))
}) })
.catch((error) => { .catch((error) => {
console.warn('⚠️ Failed to publish event to relays (event still saved locally):', error) console.warn(`${logPrefix} ⚠️ Failed to publish event to relays (event still saved locally):`, error)
// Surface common bunker signing errors for debugging // Surface common bunker signing errors for debugging
if (error instanceof Error && error.message.includes('permission')) { if (error instanceof Error && error.message.includes('permission')) {

View File

@@ -0,0 +1,245 @@
import { RelayPool } from 'applesauce-relay'
import { IEventStore, Helpers } from 'applesauce-core'
import { NostrEvent } from 'nostr-tools'
import { KINDS } from '../config/kinds'
import { queryEvents } from './dataFetch'
import { BlogPostPreview } from './exploreService'
const { getArticleTitle, getArticleSummary, getArticleImage, getArticlePublished } = Helpers
type WritingsCallback = (posts: BlogPostPreview[]) => void
type LoadingCallback = (loading: boolean) => void
const LAST_SYNCED_KEY = 'writings_last_synced'
/**
* Shared writings controller
* Manages the user's nostr-native long-form articles (kind:30023) centrally,
* similar to highlightsController
*/
class WritingsController {
private writingsListeners: WritingsCallback[] = []
private loadingListeners: LoadingCallback[] = []
private currentPosts: BlogPostPreview[] = []
private lastLoadedPubkey: string | null = null
private generation = 0
onWritings(cb: WritingsCallback): () => void {
this.writingsListeners.push(cb)
return () => {
this.writingsListeners = this.writingsListeners.filter(l => l !== cb)
}
}
onLoading(cb: LoadingCallback): () => void {
this.loadingListeners.push(cb)
return () => {
this.loadingListeners = this.loadingListeners.filter(l => l !== cb)
}
}
private setLoading(loading: boolean): void {
this.loadingListeners.forEach(cb => cb(loading))
}
private emitWritings(posts: BlogPostPreview[]): void {
this.writingsListeners.forEach(cb => cb(posts))
}
/**
* Get current writings without triggering a reload
*/
getWritings(): BlogPostPreview[] {
return [...this.currentPosts]
}
/**
* Check if writings are loaded for a specific pubkey
*/
isLoadedFor(pubkey: string): boolean {
return this.lastLoadedPubkey === pubkey && this.currentPosts.length >= 0
}
/**
* Reset state (for logout or manual refresh)
*/
reset(): void {
this.generation++
this.currentPosts = []
this.lastLoadedPubkey = null
this.emitWritings(this.currentPosts)
}
/**
* 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
*/
private setLastSyncedAt(pubkey: string, timestamp: number): void {
try {
const data = localStorage.getItem(LAST_SYNCED_KEY)
const parsed = data ? JSON.parse(data) : {}
parsed[pubkey] = timestamp
localStorage.setItem(LAST_SYNCED_KEY, JSON.stringify(parsed))
} catch (err) {
console.warn('[writings] Failed to save last synced timestamp:', err)
}
}
/**
* Convert NostrEvent to BlogPostPreview using applesauce Helpers
*/
private toPreview(event: NostrEvent): BlogPostPreview {
return {
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
}
}
/**
* Sort posts by published/created date (most recent first)
*/
private sortPosts(posts: BlogPostPreview[]): BlogPostPreview[] {
return posts.slice().sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
}
/**
* Load writings for a user (kind:30023)
* Streams results and stores in event store
*/
async start(options: {
relayPool: RelayPool
eventStore: IEventStore
pubkey: string
force?: boolean
}): Promise<void> {
const { relayPool, eventStore, pubkey, force = false } = options
// Skip if already loaded for this pubkey (unless forced)
if (!force && this.isLoadedFor(pubkey)) {
this.emitWritings(this.currentPosts)
return
}
// Increment generation to cancel any in-flight work
this.generation++
const currentGeneration = this.generation
this.setLoading(true)
try {
const seenIds = new Set<string>()
const uniqueByReplaceable = new Map<string, BlogPostPreview>()
// Get last synced timestamp for incremental loading
const lastSyncedAt = force ? null : this.getLastSyncedAt(pubkey)
const filter: { kinds: number[]; authors: string[]; since?: number } = {
kinds: [KINDS.BlogPost],
authors: [pubkey]
}
if (lastSyncedAt) {
filter.since = lastSyncedAt
}
const events = await queryEvents(
relayPool,
filter,
{
onEvent: (evt) => {
// Check if this generation is still active
if (currentGeneration !== this.generation) return
if (seenIds.has(evt.id)) return
seenIds.add(evt.id)
// Store in event store immediately
eventStore.add(evt)
// Dedupe by replaceable key (author + d-tag)
const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${evt.pubkey}:${dTag}`
const preview = this.toPreview(evt)
const existing = uniqueByReplaceable.get(key)
// Keep the newest version for replaceable events
if (!existing || evt.created_at > existing.event.created_at) {
uniqueByReplaceable.set(key, preview)
// Stream to listeners
const sortedPosts = this.sortPosts(Array.from(uniqueByReplaceable.values()))
this.currentPosts = sortedPosts
this.emitWritings(sortedPosts)
}
}
}
)
// Check if still active after async operation
if (currentGeneration !== this.generation) {
return
}
// Store all events in event store
events.forEach(evt => eventStore.add(evt))
// Final processing - ensure we have the latest version of each replaceable
events.forEach(evt => {
const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${evt.pubkey}:${dTag}`
const existing = uniqueByReplaceable.get(key)
if (!existing || evt.created_at > existing.event.created_at) {
uniqueByReplaceable.set(key, this.toPreview(evt))
}
})
const sorted = this.sortPosts(Array.from(uniqueByReplaceable.values()))
this.currentPosts = sorted
this.lastLoadedPubkey = pubkey
this.emitWritings(sorted)
// Update last synced timestamp
if (sorted.length > 0) {
const newestTimestamp = Math.max(...sorted.map(p => p.event.created_at))
this.setLastSyncedAt(pubkey, newestTimestamp)
}
} catch (error) {
console.error('[writings] ❌ Failed to load writings:', error)
this.currentPosts = []
this.emitWritings(this.currentPosts)
} finally {
// Only clear loading if this generation is still active
if (currentGeneration === this.generation) {
this.setLoading(false)
}
}
}
}
// Singleton instance
export const writingsController = new WritingsController()

View File

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

View File

@@ -32,7 +32,7 @@
.individual-bookmarks h4 { margin: 0 0 1rem 0; font-size: 1rem; color: var(--color-text); } .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 { 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; } .bookmarks-grid.bookmarks-large { gap: 1.5rem; }
@media (max-width: 768px) { @media (max-width: 768px) {
.bookmarks-grid { gap: 0.75rem; } .bookmarks-grid { gap: 0.75rem; }
@@ -44,9 +44,9 @@
.individual-bookmark:hover { border-color: var(--color-border); background: var(--color-bg-elevated); } .individual-bookmark:hover { border-color: var(--color-border); background: var(--color-bg-elevated); }
/* Compact view */ /* 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; } .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 { 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-thumbnail img { width: 100%; height: 100%; object-fit: cover; }
.compact-row.clickable { cursor: pointer; } .compact-row.clickable { cursor: pointer; }

View File

@@ -109,6 +109,16 @@
background: var(--color-bg-elevated) !important; 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 { .bookmark-item {
padding: 1rem; padding: 1rem;
background: var(--color-bg); background: var(--color-bg);

View File

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

View File

@@ -118,6 +118,16 @@ export function hasContent(bookmark: IndividualBookmark): boolean {
return hasValidContent || hasId 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) // Bookmark sets helpers (kind 30003)
export interface BookmarkSet { export interface BookmarkSet {
name: string name: string

View File

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

View File

@@ -1,4 +1,6 @@
// Extract pubkeys from nprofile strings in content // Extract pubkeys from nprofile strings in content
import { READING_PROGRESS } from '../config/kinds'
export const extractNprofilePubkeys = (content: string): string[] => { export const extractNprofilePubkeys = (content: string): string[] => {
const nprofileRegex = /nprofile1[a-z0-9]+/gi const nprofileRegex = /nprofile1[a-z0-9]+/gi
const matches = content.match(nprofileRegex) || [] const matches = content.match(nprofileRegex) || []
@@ -123,3 +125,14 @@ export function createParallelReqStreams(
return { local$, remote$ } 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' highlightStyle: 'marker' | 'underline' = 'marker'
): string { ): string {
if (!html || highlights.length === 0) { if (!html || highlights.length === 0) {
console.log('⚠️ applyHighlightsToHTML: No HTML or highlights', {
htmlLength: html?.length,
highlightsCount: highlights.length
})
return html return html
} }
console.log('🔨 applyHighlightsToHTML: Processing', highlights.length, 'highlights')
const tempDiv = document.createElement('div') const tempDiv = document.createElement('div')
tempDiv.innerHTML = html tempDiv.innerHTML = html
@@ -31,9 +26,6 @@ export function applyHighlightsToHTML(
mark.parentNode?.replaceChild(textNode, mark) mark.parentNode?.replaceChild(textNode, mark)
}) })
console.log('🧹 Removed', existingMarks.length, 'existing highlight marks')
let appliedCount = 0
for (const highlight of highlights) { for (const highlight of highlights) {
const searchText = highlight.content.trim() const searchText = highlight.content.trim()
@@ -42,7 +34,6 @@ export function applyHighlightsToHTML(
continue continue
} }
console.log('🔍 Searching for highlight:', searchText.substring(0, 50) + '...')
// Collect all text nodes // Collect all text nodes
const walker = document.createTreeWalker(tempDiv, NodeFilter.SHOW_TEXT, null) const walker = document.createTreeWalker(tempDiv, NodeFilter.SHOW_TEXT, null)
@@ -50,21 +41,16 @@ export function applyHighlightsToHTML(
let node: Node | null let node: Node | null
while ((node = walker.nextNode())) textNodes.push(node as Text) 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 // Try exact match first, then normalized match
const found = tryMarkInTextNodes(textNodes, searchText, highlight, false, highlightStyle) || const found = tryMarkInTextNodes(textNodes, searchText, highlight, false, highlightStyle) ||
tryMarkInTextNodes(textNodes, searchText, highlight, true, highlightStyle) tryMarkInTextNodes(textNodes, searchText, highlight, true, highlightStyle)
if (found) { if (!found) {
appliedCount++
console.log('✅ Highlight applied successfully')
} else {
console.warn('❌ Could not find match for highlight:', searchText.substring(0, 50)) console.warn('❌ Could not find match for highlight:', searchText.substring(0, 50))
} }
} }
console.log('🎉 Applied', appliedCount, '/', highlights.length, 'highlights')
return tempDiv.innerHTML return tempDiv.innerHTML
} }

View File

@@ -1,16 +1,58 @@
import { ReadItem } from '../services/readsService' import { ReadItem } from '../services/readsService'
import { ReadingProgressFilterType } from '../components/ReadingProgressFilters' import { ReadingProgressFilterType } from '../components/ReadingProgressFilters'
import { Highlight } from '../types/highlights'
import { nip19 } from 'nostr-tools'
/** /**
* Filters ReadItems by reading progress * Filters ReadItems by reading progress
*/ */
export function filterByReadingProgress( export function filterByReadingProgress(
items: ReadItem[], items: ReadItem[],
filter: ReadingProgressFilterType filter: ReadingProgressFilterType,
highlights?: Highlight[]
): ReadItem[] { ): 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) => { return items.filter((item) => {
const progress = item.readingProgress || 0 const progress = item.readingProgress || 0
const isMarked = item.markedAsRead || false const isMarked = item.markedAsRead || false
const hasHighlights = (articleHighlightCount.get(item.id) || 0) > 0 ||
(item.url && (articleHighlightCount.get(item.url) || 0) > 0)
switch (filter) { switch (filter) {
case 'unopened': case 'unopened':
@@ -21,6 +63,8 @@ export function filterByReadingProgress(
return progress > 0.10 && progress <= 0.94 && !isMarked return progress > 0.10 && progress <= 0.94 && !isMarked
case 'completed': case 'completed':
return progress >= 0.95 || isMarked return progress >= 0.95 || isMarked
case 'highlighted':
return hasHighlights
case 'all': case 'all':
default: default:
return true return true

View File

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

@@ -0,0 +1,15 @@
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { BlogPostPreview } from '../services/exploreService'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
export const toBlogPostPreview = (event: NostrEvent): BlogPostPreview => ({
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
})

View File

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