Compare commits

..

449 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
Gigi
d40c49edb0 chore: bump version to 0.7.2 2025-10-18 23:31:25 +02:00
Gigi
ce5d97fb1f Merge pull request #19 from dergigi/loading-improvements-etc
Implement cached-first loading with EventStore
2025-10-18 23:30:46 +02:00
Gigi
ffb8031a05 feat: implement cached-first loading with EventStore across app
- Add useStoreTimeline hook for reactive EventStore queries
- Add dedupe helpers for highlights and writings
- Explore: seed highlights and writings from store instantly
- Article sidebar: seed article-specific highlights from store
- External URLs: seed URL-specific highlights from store
- Profile pages: seed other-profile highlights and writings from store
- Remove debug logging
- All data loads from cache first, then updates with fresh data
- Follows DRY principles with single reusable hook
2025-10-18 23:03:48 +02:00
Gigi
d54e1072b8 feat: load highlights from event store for instant display
- Use eventStore.timeline() to query cached highlights
- Seed Explore page with cached highlights immediately
- Provides instant display of nostrverse highlights from store
- Fresh data still fetched in background and merged
- Follows applesauce pattern with useObservableMemo
2025-10-18 22:31:59 +02:00
Gigi
55defb645c debug: prefix all nostrverse logs with [NOSTRVERSE]
- Makes it easy to filter console logs
- Updated logs in nostrverseService.ts and Explore.tsx
- All relevant logs now have consistent prefix
2025-10-18 22:25:02 +02:00
Gigi
1ba9595542 debug: add console logging for nostrverse highlights
- Log highlight counts by source (mine, friends, nostrverse)
- Log classified highlights by level
- Log visibility filter state and results
- Helps diagnose why nostrverse content isn't appearing
2025-10-18 22:22:34 +02:00
Gigi
340913f15f fix: force React to remount tab content when switching tabs
- Add key prop based on activeTab to wrapper div
- Forces complete unmount/remount of content when switching tabs
- Prevents DOM element reuse that was causing blog posts to bleed into highlights tab
2025-10-18 22:20:27 +02:00
Gigi
1d6595f754 fix: deduplicate blog posts by author:d-tag instead of event ID
- Use consistent deduplication key (author:d-tag) for replaceable events
- Prevents duplicate blog posts when same article has multiple event IDs
- Streaming updates now properly replace older versions with newer ones
- Fixes issue where same blog post card appeared multiple times
2025-10-18 22:19:17 +02:00
Gigi
6099e3c6a4 feat: store nostrverse content in centralized event store
- Add eventStore parameter to fetchNostrverseBlogPosts
- Add eventStore parameter to fetchNostrverseHighlights
- Pass eventStore from Explore component to nostrverse fetchers
- Store all nostrverse blog posts and highlights in event store
- Enables offline access to nostrverse content
2025-10-18 22:08:22 +02:00
Gigi
ed75bc6059 feat: store article-specific highlights in centralized event store
- Pass eventStore to fetchHighlightsForArticle in useBookmarksData
- Pass eventStore to fetchHighlightsForUrl in useExternalUrlLoader
- All fetched highlights now persist in the centralized event store
- Enables offline access and consistent state management
2025-10-18 22:05:22 +02:00
Gigi
dcfc08287e refactor: use centralized controllers in highlights sidebar
- Subscribe to highlightsController for user's own highlights
- Subscribe to contactsController for followed pubkeys
- Merge controller highlights with article-specific highlights
- Remove duplicate fetching logic for contacts and own highlights
- Maintain article-specific highlight fetching for context-aware display
2025-10-18 22:01:44 +02:00
Gigi
35b2168f9a fix: get initial highlights state immediately from controller
The subscription pattern only fires on *changes*, not initial state.
When Me component mounts, we need to immediately get the current
highlights from the controller, not wait for a change event.

Before:
- Subscribe to controller
- Wait for controller to emit (only happens on changes)
- Meanwhile, myHighlights stays []

After:
- Get initial state immediately: highlightsController.getHighlights()
- Then subscribe to future updates
- myHighlights is populated right away

This ensures highlights are always available when navigating to
/me/highlights, even if the controller hasn't emitted any new events.
2025-10-18 21:56:27 +02:00
Gigi
f8a9079e5f fix: don't manually set highlights in loadHighlightsTab for own profile
The real issue: loadHighlightsTab was calling setHighlights(myHighlights)
before the controller subscription had populated myHighlights, resulting
in setting highlights to an empty array.

Solution: For own profile, let the sync effect handle setting highlights.
The controller subscription + sync effect is the single source of truth.
Only fetch highlights manually when viewing other users' profiles.

Flow for own profile:
1. Controller subscription populates myHighlights
2. Sync effect (useEffect) updates local highlights state
3. No manual setting needed in loadHighlightsTab

This ensures highlights are always synced from the controller, never
from a stale/empty initial value.
2025-10-18 21:54:27 +02:00
Gigi
780996c7c5 fix: prevent "No highlights yet" flash on /me/highlights
Fix issue where "No highlights yet" message would show briefly when
navigating to /me/highlights even when user has many highlights.

Root cause:
- Sync effect only ran when myHighlights.length > 0
- Local highlights state could be empty during navigation
- "No highlights yet" condition didn't check myHighlightsLoading

Changes:
- Remove length check from sync effect (always sync myHighlights)
- Add myHighlightsLoading check to "No highlights yet" condition
- Now shows skeleton or content, never false empty state

The controller always has the highlights loaded, so we should always
sync them to local state regardless of length.
2025-10-18 21:52:25 +02:00
Gigi
809437faa6 style: make Explore a section title like Zap Splits
Add section-title class to Explore heading to match other section headings.
2025-10-18 21:49:17 +02:00
Gigi
36f14811ae refactor: add dedicated Explore section in settings
Create new ExploreSettings component and organize explore-related settings.

Changes:
- Create src/components/Settings/ExploreSettings.tsx
- Move "Default Explore Scope" from ReadingDisplaySettings to ExploreSettings
- Add ExploreSettings to Settings.tsx above Zap Splits section
- Better organization: explore settings now in dedicated section

Settings order:
1. Theme
2. Reading Display
3. Explore (new)
4. Zap Splits
5. Layout & Behavior
6. PWA
7. Relays
2025-10-18 21:47:34 +02:00
Gigi
8b95af9c49 feat: add default explore scope setting
Add user setting to control default visibility scope in /explore page.

Changes:
- Add defaultExploreScopeNostrverse/Friends/Mine to UserSettings type
- Add "Default Explore Scope" setting in ReadingDisplaySettings UI
- Update Explore component to use defaultExploreScope settings
- Set default to friends-only (nostrverse: false, friends: true, mine: false)

Users can now configure which content types (nostrverse/friends/mine)
are visible by default when visiting the explore page, separate from
the highlight visibility settings.
2025-10-18 21:45:04 +02:00
Gigi
236ade3d2f style: remove background color from explore scope filter buttons
Remove background color from .highlight-level-toggles bar in /explore page.
The visibility filter buttons (nostrverse, friends, mine) now have no
background, making the UI cleaner.
2025-10-18 21:42:53 +02:00
Gigi
c2e882ec31 refactor: simplify highlights state management - remove prop drilling
Remove unnecessary prop drilling of myHighlights/myHighlightsLoading.
Components now subscribe directly to highlightsController (DRY principle).

Changes:
- Explore: Subscribe to controller directly, no props needed
- Me: Subscribe to controller directly, no props needed
- Bookmarks: Remove myHighlights props (no longer passes through)
- App: Remove highlights state, controller manages it internally

Benefits:
-  Simpler code (no prop drilling through 3 layers)
-  More DRY (single source of truth in controller)
-  Consistent with applesauce patterns (like useActiveAccount)
-  Less boilerplate (removed ~30 lines of prop passing)
-  Controller encapsulates all state management

Pattern: Components import and subscribe to controller directly,
just like they use Hooks.useActiveAccount() or other applesauce hooks.
2025-10-18 21:39:33 +02:00
Gigi
0a382e77b9 feat: show skeleton placeholders while highlights are loading
- Pass myHighlightsLoading state from controller through App → Bookmarks → Explore/Me
- Update Explore showSkeletons logic to include myHighlightsLoading
- Update Me showSkeletons logic to include myHighlightsLoading for own profile
- Sync myHighlights to Me component via useEffect for real-time updates
- Remove highlightsController import from Me (now uses props)

Benefits:
- Better UX with skeleton placeholders instead of empty/spinner states
- Consistent loading experience across Explore and Me pages
- Clear visual feedback when highlights are loading from controller
- Smooth transition from skeleton to actual content
2025-10-18 21:34:44 +02:00
Gigi
a1fd4bfc94 feat: use highlights controller in Explore page
- Pass myHighlights from controller through App.tsx → Bookmarks → Explore
- Merge controller highlights with friends/nostrverse highlights
- Seed Explore with myHighlights immediately (no re-fetch needed)
- Eliminate redundant fetching of user's own highlights
- Improve performance and consistency across the app

Benefits:
- User's highlights appear instantly in /explore (already loaded)
- No duplicate fetching of same data
- DRY principle - single source of truth for user highlights
- Better offline support (highlights from controller are in event store)
2025-10-18 21:28:42 +02:00
Gigi
530cc20cba feat: implement centralized highlights controller
- Create highlightsController with subscription API and event store integration
- Auto-load user highlights on app start (alongside bookmarks and contacts)
- Store highlight events in applesauce event store for offline support
- Update Me.tsx to use controller for own profile highlights
- Add optional eventStore parameter to all highlight fetch functions
- Pass eventStore through Debug component for persistent storage
- Implement incremental sync with localStorage-based lastSyncedAt tracking
- Add generation-based cancellation for in-flight requests
- Reset highlights on logout

Closes #highlights-controller
2025-10-18 21:19:57 +02:00
Gigi
a275c0a8e3 refactor: consolidate bookmarks and contacts auto-loading
- Combine both auto-load effects into single useEffect
- Load bookmarks and contacts together when account is ready
- Keep code DRY - same pattern, same timing, same place
- Both use their respective controllers
- Both check loading state before triggering
2025-10-18 21:03:01 +02:00
Gigi
cb43b748e4 temp: disable auto-load of contacts for testing
- Comment out contacts state and subscriptions
- Comment out auto-load effect
- Allows manual testing of contact loading in Debug page
- Remember to re-enable after testing
2025-10-18 20:59:14 +02:00
Gigi
ff9ce46448 refactor: simplify friends highlights loading to use cached contacts
- Remove redundant contact loading check
- Directly use contacts from centralized controller
- App.tsx already auto-loads contacts on login
- Clearer message indicating cached contacts are being used
- Faster execution since no contact loading needed
2025-10-18 20:58:14 +02:00
Gigi
1e6718fe1e fix: improve Load Friends button behavior in Debug
- Add local loading state for button (friendsButtonLoading)
- Clear friends list before loading to show streaming
- Set final result after controller completes
- Add error handling and logging
- Remove unused global friendsLoading subscription
- Button now properly shows loading state and results
2025-10-18 20:56:53 +02:00
Gigi
d6a913f2a6 feat: add centralized contacts controller
- Create contactsController similar to bookmarkController
- Manage friends/contacts list in one place across the app
- Auto-load contacts on login, cache results per pubkey
- Stream partial contacts as they arrive
- Update App.tsx to subscribe to contacts controller
- Update Debug.tsx to use centralized contacts instead of fetching directly
- Reset contacts on logout
- Contacts won't reload unnecessarily (cached by pubkey)
- Debug 'Load Friends' button forces reload to show streaming behavior
2025-10-18 20:51:03 +02:00
Gigi
8030e2fa00 feat: make friends highlights loading non-blocking
- Start fetching highlights immediately when partial contacts arrive
- Track seen authors to avoid duplicate queries
- Fire-and-forget pattern for partial fetches (like bookmark loading)
- Only await final batch for remaining authors
- Highlights stream in progressively as contacts are discovered
- Matches the non-blocking pattern used in Explore.tsx and bookmark loading
2025-10-18 20:47:35 +02:00
Gigi
1ff2f28566 chore: increase nostrverse highlights limit from 100 to 500
- Better for testing and debugging with more realistic data volumes
2025-10-18 20:43:37 +02:00
Gigi
78457335c6 refactor: simplify nostrverse highlights loading to direct query
- Use direct queryEvents with kind:9802 filter instead of service wrapper
- Add streaming with onEvent callback for immediate UI updates
- Track first event timing for performance analysis
- Remove unused fetchNostrverseHighlights import
2025-10-18 20:43:02 +02:00
Gigi
553feb10df feat: add debug buttons for highlight loading and Web of Trust
- Add three quick-load buttons: Load My Highlights, Load Friends Highlights, Load Nostrverse Highlights
- Add Web of Trust section with Load Friends button to display followed npubs
- Stream highlights with dedupe and timing metrics
- Display friends count and scrollable list of npubs
- All buttons respect loading states and account requirements
2025-10-18 20:39:57 +02:00
Gigi
ba5d7df3bd fix: show highlight button for all reading content
- Show highlight button when readerContent exists (both nostr articles and external URLs)
- Hide highlight button when browsing app pages like explore, settings, etc.
- Ensures highlighting is available for all readable content but not for navigation pages
2025-10-18 20:26:11 +02:00
Gigi
cf3ca2d527 feat: show highlight button only when viewing articles
- Only display the floating highlight button when currentArticle exists or selectedUrl is a nostr article
- Prevents highlight button from showing on external URLs, videos, or other content types
- Improves UX by showing highlight functionality only where it's relevant
2025-10-18 20:23:45 +02:00
Gigi
06763d5307 fix: resolve unused variable linting error in Debug.tsx 2025-10-18 20:23:18 +02:00
Gigi
a08e4fdc24 chore: bump version to 0.7.1 2025-10-18 20:22:03 +02:00
Gigi
bc7b4ae42d feat(debug): add time-to-first-event tracking for bookmarks
- Track and display time to first bookmark event arrival
- Mirror highlight loading metrics for consistency
- Shows how quickly local/fast relays respond
- Renamed 'load' stat to 'total' for clarity
- Clear first event timing on reset
2025-10-18 20:20:59 +02:00
Gigi
4dc1894ef3 feat(debug): default highlight loading to logged-in user
- Author mode now defaults to current user's pubkey if not specified
- Changed default mode from 'article' to 'author' for better UX
- Updated placeholder to show logged-in user's pubkey
- Updated description to clarify default behavior
- Makes 'Load Highlights' button immediately useful without input
2025-10-18 20:19:53 +02:00
Gigi
f00f26dfe0 feat(debug): add Highlight Loading section with streaming metrics
- Add query mode selector (Article/#a, URL/#r, Author)
- Stream highlight events as they arrive with onEvent callback
- Track timing metrics: total load time and time-to-first-event
- Display highlight summaries with content, tags, and metadata
- Support EOSE-based completion via queryEvents helper
- Mirror bookmark loading section UX for consistency
2025-10-18 10:05:56 +02:00
Gigi
2e59bc9375 feat(highlights): add optional session cache with TTL
- Add in-memory cache with 60s TTL for article/url/author queries
- Check cache before network fetch to reduce redundant queries
- Support force flag to bypass cache when needed
- Stream cached results through onHighlight callback for consistency
2025-10-18 10:04:13 +02:00
Gigi
0d50d05245 feat(highlights): refactor fetchers to use EOSE-based queryEvents
- Replace ad-hoc Rx timeout-based queries with centralized queryEvents helper
- Remove artificial timeouts (1200ms/6000ms) in favor of EOSE signals
- Use KINDS.Highlights consistently instead of hardcoded 9802
- Maintain streaming callbacks for instant UI updates
- Parallel queries for article #a and #e tags
- Local-first relay prioritization via queryEvents
2025-10-18 10:03:13 +02:00
Gigi
90c74a8e9d docs: update CHANGELOG.md for v0.7.0 2025-10-18 09:50:23 +02:00
Gigi
a4bad34a90 chore: bump version to 0.7.0 2025-10-18 09:48:48 +02:00
Gigi
84ff24e06a Merge pull request #18 from dergigi/bunker-support
feat: add bunker authentication, progressive bookmarks, and debug page
2025-10-18 09:48:08 +02:00
Gigi
aaf8a9d4fc fix: increase PWA cache limit to 3 MiB for larger bundles 2025-10-18 09:47:05 +02:00
Gigi
efa6d13726 feat: improve bunker error message formatting
- Add 'Failed:' prefix to error messages
- Add line breaks between error and signer suggestions
- Clearer visual separation of error and help text
2025-10-18 09:40:29 +02:00
Gigi
6116dd12bc feat: hide bookmark controls when logged out
- Only show heart/support button when logged out
- Hide refresh, grouping, and view mode buttons when not logged in
- Cleaner, simpler footer for logged out state
2025-10-18 09:37:17 +02:00
Gigi
210cdd41ec feat: rename Bunker button to Signer
Change button label from 'Bunker' to 'Signer' for better clarity and user-friendliness
2025-10-18 09:35:28 +02:00
Gigi
9378b3c9a9 feat: left-align error message text
- Add text-align: left to login-error
- Change align-items to flex-start for better multi-line text alignment
- Icon now aligns to top instead of center
2025-10-18 09:35:04 +02:00
Gigi
973409e82a feat: add signer suggestions to bunker URI validation error
- Show Amber and Aegis links when bunker URI format is invalid
- Consistent helpful messaging across all bunker errors
- Helps users even when they don't have the right format
2025-10-18 09:34:25 +02:00
Gigi
5d6f48b9a8 feat: add signer suggestions to bunker error messages
- Show helpful message when bunker connection fails
- Suggest Amber (Android) and Aegis (iOS) signers with links
- Links: Amber GitHub and Aegis TestFlight
- Similar pattern to extension error message
2025-10-18 09:33:18 +02:00
Gigi
4921427ad4 feat: simplify login description text
Change 'Connect your nostr npub' → 'Connect your npub'
npub is already nostr-specific, so 'nostr' is redundant
2025-10-18 09:23:20 +02:00
Gigi
ad8cad29d3 fix: ensure bunker input stays centered and constrained
- Add width: 100% to bunker-input-container and bunker-input
- Add box-sizing: border-box to properly calculate width with padding
- Prevents bunker dialog from extending beyond centered layout
2025-10-18 09:22:49 +02:00
Gigi
8d4a4a04a3 fix: catch 'Signer extension missing' error message
- Add check for 'Signer extension missing' error
- Add case-insensitive check for 'extension missing'
- Ensure nos2x link is shown when no extension is found
2025-10-18 09:21:45 +02:00
Gigi
1dc44930b4 feat: make error message links more obvious
- Add primary color and underline to links in error messages
- Increase font weight to 600 for better visibility
- Add hover state with color transition
- nos2x link now clearly stands out as clickable
2025-10-18 09:21:05 +02:00
Gigi
c77907f87a feat: improve extension login error messages
- Show specific message when no extension is found
- Show message when authentication is cancelled/denied
- Display actual error message for other failures
- Remove generic 'Login failed' message
2025-10-18 09:20:33 +02:00
Gigi
9345228e66 feat: add nos2x link to extension error message
- Update error message to mention 'like nos2x'
- Add clickable link to nos2x Chrome Web Store
- Change error type to support React nodes for richer messages
2025-10-18 09:19:20 +02:00
Gigi
811362175c feat: hide Extension button when Bunker input is shown
- Extension button now hidden when bunker input is visible
- Reduces visual clutter and confusion
- Clear focus on the active login method
2025-10-18 09:17:44 +02:00
Gigi
3d22e7a3cb feat: simplify button text to single words
- 'Extension Login' → 'Extension'
- 'Bunker Login' → 'Bunker'
Icons + context make the action clear, minimalist approach
2025-10-18 09:16:52 +02:00
Gigi
0b0d3c2859 feat: use nostr-native language in login description
Change 'Login to see' → 'Connect your nostr npub to see'
More specific and aligned with nostr terminology
2025-10-18 09:16:18 +02:00
Gigi
1f8d18071c feat: shorten login button text for cleaner UI
- 'Login with Extension' → 'Extension Login'
- 'Login with Bunker' → 'Bunker Login'
More concise and easier to scan
2025-10-18 09:15:13 +02:00
Gigi
a4afe59437 fix: properly display FontAwesome icons in login buttons
- Import and use FontAwesomeIcon component from @fortawesome/react-fontawesome
- Add puzzle piece icon (faPuzzlePiece) for Extension button
- Add shield icon (faShieldHalved) for Bunker button
- Add info circle icon (faCircleInfo) for error messages
- Update CSS to properly style SVG icons with correct sizing
2025-10-18 09:14:45 +02:00
Gigi
1fe3786a3d feat: update login title to be more personable
Change 'Welcome to Boris' to 'Hi! I'm Boris.' for a friendlier, more welcoming first impression
2025-10-18 09:13:05 +02:00
Gigi
42d265731f feat: hide login button and user icon when logged out
- Remove redundant login button from sidebar header
- Hide profile avatar when no active account
- Users can now only login through the main login screen
- Logout button only shown when logged in
- Clean up unused imports (useState, Accounts, faRightToBracket)
2025-10-18 09:12:22 +02:00
Gigi
e4b4b97874 feat: highlight 'your own highlights' in login copy
- Style 'your own highlights' text with user's mine highlight color
- Uses --highlight-color-mine CSS variable from settings
- Adds subtle padding and border-radius for clean highlight effect
2025-10-18 09:11:04 +02:00
Gigi
1870c307da feat: improve login UI with better copy and modern design
- Add welcoming title 'Welcome to Boris'
- Update description to highlight key features (bookmarks, long-form articles, highlights)
- Change button labels to 'Login with Extension' and 'Login with Bunker'
- Add FontAwesome icons to login buttons
- Create dedicated login.css with modern, mobile-first styling
- Improve bunker input UI with better spacing and visual hierarchy
- Use softer error styling with amber/warning colors instead of harsh red
- Add smooth transitions and hover effects
- Ensure mobile-optimized touch targets
2025-10-18 09:10:14 +02:00
Gigi
bcb6cfbe97 feat: auto-load bookmarks on login and page mount
Added centralized auto-loading effect that handles all scenarios:
- User logs in (activeAccount becomes available)
- Page loads with existing session
- User logs out and back in (bookmarks cleared by reset)

Watches activeAccount and relayPool, triggers when both ready and no
bookmarks loaded yet. Handles all login methods (extension, bunker) via
single reactive effect.
2025-10-18 01:05:29 +02:00
Gigi
6ba1ce27b7 fix: add extraRelays to EventLoader and AddressLoader
The loaders were initialized without extraRelays, so they had no relays
to fetch from. Added RELAYS config as extraRelays option for both loaders.

This ensures the loaders know where to query for events when hydrating
bookmarks in the background.
2025-10-18 00:58:34 +02:00
Gigi
2f620265f4 chore: clean up verbose debug logging in hydration methods
Removed excessive per-event logging from EventLoader and AddressLoader
subscriptions. Keep only essential logs:
- Initial hydration count
- Error logging

This reduces console noise while maintaining visibility into hydration
progress and errors.
2025-10-18 00:54:56 +02:00
Gigi
61ae31c6a2 refactor: replace manual batching with applesauce EventLoader and AddressLoader
Replaced manual queryEvents batching with applesauce built-in loaders.

Key Changes:
- EventLoader for regular events (by ID) - auto-batches and streams
- AddressLoader for addressable events (coordinates) - handles kind batching
- Added EventStore instance to BookmarkController
- Initialize loaders in start() method
- hydrateByIds and hydrateByCoordinates now synchronous
- Removed manual chunk, IDS_BATCH_SIZE, etc.

Benefits:
- Follows applesauce best practices from examples
- More reliable (no manual timeout logic)
- Better performance (intelligent batching)
- Streaming results (progressive updates)
- Built-in deduplication via EventStore

Pattern: merge pointers through loader, subscribe to stream results.
2025-10-18 00:54:18 +02:00
Gigi
b0fcb0e897 debug: add detailed logging to diagnose hydration hanging
Added extensive logging to track queryEvents lifecycle:
- Log when queryEvents is called
- Log each event as it's received via onEvent callback
- Log when batch completes with event count
- Log errors if batch fails

This will help identify where the hydration is hanging - whether:
- queryEvents never returns
- No events are received
- Some batches fail silently

No functional changes, only diagnostic logging.
2025-10-18 00:49:05 +02:00
Gigi
3b08cd5d23 fix: remove setName filter from Amethyst bookmark grouping
Fixed issue where 489 kind:30001 bookmarks were not appearing in groups
because they had setName: undefined instead of setName: 'bookmark'.

Changes:
- Removed setName === 'bookmark' requirement from amethystPublic/Private filters
- Now all kind:30001 bookmarks are grouped correctly regardless of setName
- Removed debug logging that was added to diagnose the issue

Before: Only 40 bookmarks shown (26 NIP-51 + 7 standalone + 7 web)
After: All 522 bookmarks shown (26 NIP-51 + 489 Amethyst + 7 web)
2025-10-18 00:44:54 +02:00
Gigi
a3a00b8456 debug: add setName distribution logging for kind:30001 bookmarks
Added console log to show the distribution of setName values for all
kind:30001 bookmarks. This will help diagnose why 489 Amethyst bookmarks
aren't appearing in the amethystPublic/amethystPrivate groups.

Expected to see setName='bookmark' but need to verify what values are
actually present in the data.
2025-10-18 00:43:18 +02:00
Gigi
7fecc0c0c3 debug: add logging to diagnose bookmark grouping issue
Added console logs in groupIndividualBookmarks to show:
- Distribution of sourceKind values across all bookmarks
- Sample items with their sourceKind, isPrivate, setName, and id
- Count of items in each group after filtering

This will help identify why grouped view shows only ~40 bookmarks
while flat view shows 500+.
2025-10-18 00:36:40 +02:00
Gigi
93d0284fd6 feat: implement batched background hydration for bookmarks
Implemented efficient background event fetching with:

1. Batching constants:
- IDS_BATCH_SIZE = 100 (regular events)
- D_TAG_BATCH_SIZE = 50 (identifiers)
- AUTHORS_BATCH_SIZE = 50 (authors)

2. Utility functions:
- chunk<T>(arr, size) - split arrays into batches
- hydrationGeneration field - cancellation token

3. Two hydration methods:
- hydrateByIds: Fetches events by ID in batches of 100
- hydrateByCoordinates: Fetches addressable events by kind with 50×50 author×id batches

4. Progressive updates:
- Emit bookmarks instantly with placeholders (IDs only)
- Re-emit after each event arrives via onEvent callback
- All hydration runs in background (fire-and-forget)

5. Cancellation support:
- Increment hydrationGeneration on reset()/start()
- All hydration loops check generation and exit if changed
- Cleanly cancels in-flight fetching when user reloads

Benefits:
- No more hanging with 400+ bookmarked events
- Progressive UI updates as metadata loads
- Efficient relay usage with batched queries
- Clean cancellation on navigation/reload

All bookmarks appear instantly, titles/content hydrate progressively.
2025-10-18 00:34:26 +02:00
Gigi
94d5089e33 docs: clarify Amethyst bookmark structure in Amber.md
Updated documentation to explicitly state that:
- Amethyst bookmarks are stored in a SINGLE kind:30001 event with d-tag 'bookmark'
- This one event contains BOTH public (in tags) and private (in encrypted content) bookmarks
- When processed, it produces separate items with different isPrivate flags
- Example: 76 public + 416 private = 492 total bookmarks from one event

Added sections on:
- Event structure with d-tag requirement
- Processing flow showing how items are tagged
- UI grouping logic with setName check
- Why both public and private come from the same event
2025-10-18 00:22:23 +02:00
Gigi
5965bc1747 fix: check d-tag bookmark for Amethyst grouping 2025-10-18 00:21:33 +02:00
Gigi
0fbf80b04f chore: remove debug logging from bookmark grouping
Removed temporary console.log statements added for debugging. The issue has been identified and fixed - bookmarks were being filtered out by hasContent() when they only had IDs.
2025-10-18 00:18:33 +02:00
Gigi
2004ce76c9 fix: show bookmarks even when they only have IDs (no content yet)
Root cause: hasContent() was filtering out bookmarks that didn't have content text yet. When we skip event fetching for large collections (>100 IDs), bookmarks only have IDs as placeholders, causing 511/522 bookmarks to be filtered out.

Solution: Updated hasContent() to return true if bookmark has either:
- Valid content (original behavior)
- OR a valid ID (placeholder until events are fetched)

This allows all 522 bookmarks to appear in the sidebar immediately, showing IDs/URLs as placeholders until full event data loads.

Removed debug logging from bookmarkUtils as it served its purpose.
2025-10-18 00:18:13 +02:00
Gigi
90c79e34eb debug: add logging to bookmark grouping to diagnose missing bookmarks
Added console logs to groupIndividualBookmarks to see:
- Total items being grouped
- Count per group (nip51Public, nip51Private, amethystPublic, amethystPrivate, standaloneWeb)
- Sample of first 3 items with their sourceKind and isPrivate properties

This will help diagnose why 532 bookmarks are emitted but not appearing in sidebar.
2025-10-18 00:16:34 +02:00
Gigi
6ea0fd292c fix: skip background event fetching when there are too many IDs
Problem: With 400+ bookmarked events, trying to fetch all referenced events at once caused queryEvents to hang/timeout, making bookmarks appear to not load even though they were emitted.

Solution:
- Added MAX_IDS_TO_FETCH limit (100 IDs)
- Added MAX_COORDS_TO_FETCH limit (100 coordinates)
- If counts exceed limits, skip fetching and show bookmarks with IDs only
- Bookmarks still appear immediately with placeholder data (IDs)
- For smaller collections, metadata still loads in background

This fixes the hanging issue for users with large bookmark collections - all 532 bookmarks will now appear instantly in the sidebar (showing IDs), without waiting for potentially slow/hanging queryEvents calls.
2025-10-18 00:14:33 +02:00
Gigi
193c1f45d4 fix: include decrypted private bookmarks in sidebar
Root cause: When decryption completed, we were only storing counts, not the actual decrypted bookmark items. When buildAndEmitBookmarks ran, it would try to decrypt again or skip encrypted events entirely.

Changes:
- Renamed decryptedEvents to decryptedResults and changed type to store actual IndividualBookmark arrays
- Store full decrypted results (publicItems, privateItems, metadata) when decryption completes
- In buildAndEmitBookmarks, separate unencrypted and decrypted events
- Process only unencrypted events with collectBookmarksFromEvents
- Merge in stored decrypted results for encrypted events
- Updated filter to check decryptedResults map for encrypted events

This fixes the missing Amethyst bookmarks issue - all 416 private items should now appear in the sidebar after decryption completes.
2025-10-18 00:11:17 +02:00
Gigi
4da3a0347f feat: add bookmark grouping toggle (grouped by source vs flat chronological)
Changes:
- Updated groupIndividualBookmarks to group by source kind (10003, 30001, 39701) instead of content type
- Added toggle button in bookmark footer to switch between grouped and flat views
- Default mode is 'grouped by source' showing: My Bookmarks, Private Bookmarks, Amethyst Lists, Web Bookmarks
- Flat mode shows single 'All Bookmarks (X)' section sorted chronologically
- Preference persists to localStorage
- Implemented in both BookmarkList.tsx and Me.tsx

Files modified:
- src/utils/bookmarkUtils.tsx - New grouping logic
- src/components/BookmarkList.tsx - Added state, toggle button, conditional sections
- src/components/Me.tsx - Added state, toggle button, conditional sections
2025-10-17 23:55:15 +02:00
Gigi
795ef5016e feat: implement fully progressive, non-blocking bookmark loading
Changes:
- Emit bookmarks IMMEDIATELY with placeholders (IDs only)
- Fetch referenced events in background (non-blocking)
- Re-emit progressively as events load:
  1. First emit: IDs only (instant)
  2. Second emit: after fetching events by ID
  3. Third emit: after fetching addressable events

This solves the hanging issue by:
- Never blocking the initial display
- Making all event fetching happen in background Promises
- Updating the UI progressively as metadata loads

Sidebar will show bookmarks instantly with IDs, then titles/content will populate as events arrive.
2025-10-17 23:40:39 +02:00
Gigi
83693f7fb0 fix: skip event fetching to unblock sidebar population
Root cause: queryEvents() hangs when fetching referenced events by ID
Temporary fix: Skip event fetching entirely, show bookmark items without full metadata

The logs showed:
- [bookmark] 🔧 Fetching events by ID...
- (never completes, hangs indefinitely)

This blocked buildAndEmitBookmarks from completing and emitting to the sidebar.

TODO: Investigate why queryEvents with { ids: [...] } doesn't complete/timeout
2025-10-17 23:36:51 +02:00
Gigi
c55e20f341 debug: add granular logging to track buildAndEmitBookmarks flow
Added logging at every step of buildAndEmitBookmarks:
- After collectBookmarksFromEvents returns
- Before/after fetching events by ID
- Before/after fetching addressable events
- Before/after hydration and dedup
- Before/after enrichment and sorting
- Before creating final Bookmark object

This will show exactly where the process is hanging.
2025-10-17 23:34:28 +02:00
Gigi
1430d2fc47 refactor: use [bookmark] prefix for all bookmark logs
Changed all console logs to use [bookmark] prefix:
- Controller: all logs now use [bookmark] instead of [controller]
- App: all bookmark-related logs use [bookmark] instead of [app]

This allows filtering console with 'bookmark' to see only relevant logs for bookmark loading/debugging.
2025-10-17 23:32:10 +02:00
Gigi
3f24ccff74 debug: add detailed error logging to buildAndEmitBookmarks
Added logging at each step:
- Before calling collectBookmarksFromEvents
- After collectBookmarksFromEvents returns
- Detailed error info if it fails (message + stack)

This will show us exactly where the silent failure is happening.
2025-10-17 23:28:38 +02:00
Gigi
51b7e53385 debug: add extensive logging to track bookmark flow
Simplified to only show unencrypted bookmarks:
- Skip encrypted events entirely (no decrypt for now)
- This eliminates all parse errors

Added comprehensive logging:
- Controller: log when building, how many items, how many listeners, when emitting
- App: log when subscribing, when receiving bookmarks, when loading state changes

This will help identify where the disconnect is between controller and sidebar.
2025-10-17 23:27:04 +02:00
Gigi
8dbb18b1c8 fix: only build bookmarks from ready events (unencrypted or decrypted)
Filter events in buildAndEmitBookmarks to avoid parse errors:
- Unencrypted events: always included
- Encrypted events: only included if already decrypted

Progressive flow:
- Unencrypted event arrives → build bookmarks immediately
- Encrypted event arrives → wait for decrypt → then build bookmarks
- Each build only processes ready events (no parse errors)

Sidebar now populates with unencrypted bookmarks immediately, encrypted ones appear after decrypt.
2025-10-17 23:24:17 +02:00
Gigi
88bc7f690e feat: add progressive bookmark updates via callback pattern
Changed bookmark controller to emit updates progressively:
- Unencrypted events: immediate buildAndEmitBookmarks call
- Encrypted events: buildAndEmitBookmarks after decrypt completes
- Each update emits new bookmark list to subscribers

Removed coalescing/scheduling logic (scheduleBookmarkUpdate):
- Direct callback pattern is simpler and more predictable
- Updates happen exactly when events are ready

Progressive sidebar population now works correctly without parse errors.
2025-10-17 23:19:32 +02:00
Gigi
29ef21a1fa fix: restore Debug page decrypt display via onDecryptComplete callback
Added onDecryptComplete callback to controller:
- Controller emits decrypt results (eventId, publicCount, privateCount)
- Debug subscribes to see decryption progress
- setDecryptedEvents updated with decrypt results for UI display

Debug page now shows decrypted content counts for encrypted bookmark lists (like kind:30001 Amethyst-style NIP-04 bookmarks).
2025-10-17 23:14:10 +02:00
Gigi
7a75982715 fix: make controller onEvent non-blocking for queryEvents completion
Changed onEvent callback from async to synchronous:
- Removed await inside onEvent that was blocking observable
- Decryption now fires in background using .then()/.catch()
- Allows queryEvents to complete (EOSE) and trigger final bookmark build

This matches the working Debug pattern and allows bookmarks to appear in sidebar.
2025-10-17 23:12:19 +02:00
Gigi
f95f8f4bf1 refactor: remove deprecated bookmark service files
Deleted bookmarkService.ts and bookmarkStream.ts:
- All functionality now consolidated in bookmarkController.ts
- No more duplication of streaming/decrypt logic
- Single source of truth for bookmark loading
2025-10-17 23:09:23 +02:00
Gigi
9eef5855a9 feat: create shared bookmark controller for Debug-driven loading
Created bookmarkController.ts singleton:
- Encapsulates Debug's working streaming/decrypt logic
- API: start(), onRawEvent(), onBookmarks(), onLoading(), reset()
- Live deduplication, sequential decrypt, progressive updates

Updated App.tsx:
- Removed automatic loading triggers (useEffect)
- Subscribe to controller for bookmarks/loading state
- Manual refresh calls controller.start()

Updated Debug.tsx:
- Uses controller.start() instead of internal loader
- Subscribes to onRawEvent for UI display (unchanged)
- Pressing 'Load Bookmarks' now populates app sidebar

No automatic loads on login/mount. App passively receives updates from Debug-driven controller.
2025-10-17 23:08:36 +02:00
Gigi
2e70745bab fix: make bookmarksLoading optional in Me component
Made bookmarksLoading prop optional in MeProps since it's not currently used.
Reserved for future use when we want to show centralized loading state.

All linting and type checks now pass.
2025-10-17 22:52:16 +02:00
Gigi
8a971dfe52 refactor: pass bookmarks as props to Me component
Updated Me.tsx to receive bookmarks from centralized App state:
- Added bookmarks and bookmarksLoading to MeProps
- Removed local bookmarks state
- Removed bookmark caching (now handled at App level)

Updated Bookmarks.tsx to pass bookmarks props to Me component:
- Both 'me' and 'profile' views receive centralized bookmarks

All bookmark data now flows from App.tsx -> Bookmarks.tsx -> Me.tsx with no duplicate fetching or local state.
2025-10-17 22:50:07 +02:00
Gigi
a004e96eca feat: extract bookmark streaming helpers and centralize loading
Created bookmarkStream.ts with shared helpers:
- getEventKey: deduplication logic
- hasEncryptedContent: encryption detection
- loadBookmarksStream: streaming with non-blocking decryption

Refactored bookmarkService.ts to use shared helpers:
- Uses loadBookmarksStream for consistent behavior with Debug page
- Maintains progressive loading via callbacks
- Added accountManager parameter to fetchBookmarks

Updated App.tsx to pass accountManager to fetchBookmarks:
- Progressive loading indicators via onProgressUpdate callback

All bookmark loading now uses the same battle-tested streaming logic as Debug page.
2025-10-17 22:47:20 +02:00
Gigi
ce2432632c refactor: consolidate bookmark loading into single centralized function
Removed duplicate bookmark loading logic from Debug page:
- Debug 'Load Bookmarks' button now calls centralized onRefreshBookmarks
- Removed redundant state (bookmarkEvents, bookmarkStats, decryptedEvents)
- Removed unused helper functions (getKindName, getEventSize, etc.)
- Cleaned up imports (Helpers, queryEvents, collectBookmarksFromEvents)
- Simplified UI to show timing only, bookmarks visible in sidebar

Now there's truly ONE place for bookmark loading (bookmarkService.ts),
called from App.tsx and used throughout the app. Debug page's button
is now the same as clicking refresh in the bookmark sidebar.
2025-10-17 22:28:35 +02:00
Gigi
56b3100c8e fix: correct TypeScript types in Debug component
Fixed type error in Debug.tsx:
- Changed highlightVisibility from string to proper HighlightVisibility object
- Used 'support' prop instead of invalid 'children' prop for ThreePaneLayout
- Set showSupport={true} to properly render debug content

All linting and type checks now pass.
2025-10-17 22:19:09 +02:00
Gigi
327d65a128 feat: add bookmarks sidebar to Debug page
Added ThreePaneLayout to Debug page so bookmarks are visible:
- Debug page now has same layout as other pages
- Shows bookmarks sidebar on the left
- Debug content in the main pane
- Can compare centralized app bookmarks with Debug bookmarks side-by-side

This makes it easy to verify that centralized bookmark loading
works the same as the Debug page implementation.
2025-10-17 22:17:11 +02:00
Gigi
e5a7a07deb fix: bookmark loading completing properly now
Fixed critical issue where async operations in onEvent callback
were blocking the queryEvents observable from completing:

Changes:
1. Removed async/await from onEvent callback
   - Now just collects events synchronously
   - No blocking operations in the stream

2. Moved auto-decryption to after query completes
   - Batch process encrypted events after EOSE
   - Sequential decryption (cleaner, more predictable)

3. Simplified useEffect triggers in App.tsx
   - Removed duplicate mount + account change effects
   - Single effect handles both cases

Result: Query now completes properly, bookmarks load and display.
2025-10-17 22:13:58 +02:00
Gigi
5bd57573be debug: add detailed logging for bookmark loading
Added comprehensive console logs to diagnose bookmark loading issue:
- [app] prefix for all bookmark-related logs
- Log account pubkey being used
- Log each event as it arrives
- Log auto-decrypt attempts
- Log final processing steps
- Log when no bookmarks found

This will help identify where the bookmark loading is failing.
2025-10-17 22:11:47 +02:00
Gigi
c2223e6b08 feat: centralize bookmark loading with streaming and auto-decrypt
Implemented centralized bookmark loading system:
- Bookmarks loaded in App.tsx with streaming + auto-decrypt pattern
- Load triggers: login, app mount, manual refresh only
- No redundant fetching on route changes

Changes:
1. bookmarkService.ts: Refactored fetchBookmarks for streaming
   - Events stream with onEvent callback
   - Auto-decrypt encrypted content (NIP-04/NIP-44) as events arrive
   - Progressive UI updates during loading

2. App.tsx: Added centralized bookmark state
   - bookmarks and bookmarksLoading state in AppRoutes
   - loadBookmarks function with streaming support
   - Load on mount if account exists (app reopen)
   - Load when activeAccount changes (login)
   - handleRefreshBookmarks for manual refresh
   - Pass props to all Bookmarks components

3. Bookmarks.tsx: Accept bookmarks as props
   - Receive bookmarks, bookmarksLoading, onRefreshBookmarks
   - Pass onRefreshBookmarks to useBookmarksData

4. useBookmarksData.ts: Simplified to accept bookmarks as props
   - Removed bookmark fetching logic
   - Removed handleFetchBookmarks function
   - Accept onRefreshBookmarks callback
   - Use onRefreshBookmarks in handleRefreshAll

5. Me.tsx: Removed fallback bookmark loading
   - Removed fetchBookmarks import and calls
   - Use bookmarks directly from props (centralized source)

Benefits:
- Single source of truth for bookmarks
- No duplicate fetching across components
- Streaming + auto-decrypt for better UX
- Simpler, more maintainable code
- DRY principle: one place for bookmark loading
2025-10-17 22:06:33 +02:00
Gigi
d1ffc8c3f9 feat: auto-decrypt bookmarks as they arrive
Simplified bookmark loading by chaining loading and decryption:
- Events with encrypted content are automatically decrypted as they arrive
- Removed separate "Decrypt" button - now automatic
- Removed individual decrypt buttons - happens automatically
- Removed handleDecryptSingleEvent and related state
- Cleaner UI with just "Load Bookmarks" and "Clear" buttons

Benefits:
- Simpler, more intuitive UX
- DRY - single flow instead of 2-step process
- Shows decryption results inline as events stream in
- Uses same collectBookmarksFromEvents for consistency

Each event with encrypted content (NIP-04 or NIP-44) is decrypted
immediately in the onEvent callback, with results displayed inline.
2025-10-17 21:44:55 +02:00
Gigi
5a5cd14df5 docs: add Amethyst-style bookmarks section to Amber.md
Documented kind:30001 bookmark format used by Amethyst:
- Public bookmarks in event tags
- Private bookmarks in encrypted content (NIP-04 or NIP-44)

Explained why explicit NIP-04 detection (?iv= check) is required:
- Helpers.hasHiddenContent() only detects NIP-44
- Without NIP-04 detection, private bookmarks never get decrypted

Added example event structure and implementation notes for both
display logic and decryption logic.
2025-10-17 21:41:25 +02:00
Gigi
2fb25da9d6 fix: detect and decrypt NIP-04 encrypted bookmark content
Added explicit NIP-04 detection in bookmarkProcessing.ts:
- Check for ?iv= in content (NIP-04 format)
- Previously only checked Helpers.hasHiddenContent() (NIP-44 only)
- Now decrypts both NIP-04 and NIP-44 encrypted bookmarks

This fixes individual bookmark decryption returning 0 private items
despite having encrypted content.
2025-10-17 21:39:15 +02:00
Gigi
21228cd212 refactor: unify debug logging under [bunker] prefix
Changed all debug console logs to use [bunker] prefix with emojis:
- 🔵 Individual decrypt clicked
- 🔓 Decrypting event (with details)
-  Event decrypted (with results)
- ⚠️  Warnings (no account, 0 private items)
-  Errors

Now users can filter console by 'bunker' to see all relevant logs.
2025-10-17 21:38:23 +02:00
Gigi
e0b86a84ba debug: add detailed logging for individual bookmark decryption
Added extensive debug logging to help diagnose decryption issues:
- Event details (kind, content length, encryption type)
- Signer information (type, availability)
- Warning when 0 private items found despite encrypted content

This will help identify why decryption might be failing silently.
2025-10-17 21:34:47 +02:00
Gigi
c3a4e41968 fix: detect NIP-04 encrypted content in bookmark events
Added explicit detection for NIP-04 encrypted content format:
- NIP-04: base64 content with ?iv= suffix
- NIP-44: detected by Helpers.hasHiddenContent()
- Encrypted tags: detected by Helpers.hasHiddenTags()

Created hasEncryptedContent() helper that checks all three cases.
Now properly shows padlock emoji and decrypt button for events with
NIP-04 encrypted content (like the example with ?iv=5KzDXv09...).
2025-10-17 21:31:21 +02:00
Gigi
f3205843ac fix: use consistent encrypted content detection for padlock and decrypt button
Fixed mismatch between padlock display and decrypt button visibility:
- Both now use Helpers.hasHiddenContent() and Helpers.hasHiddenTags()
- Previously padlock showed for ANY content, button only for encrypted
- Now both correctly detect actual encrypted content

This ensures decrypt buttons appear whenever padlocks are shown.
2025-10-17 21:27:02 +02:00
Gigi
9a03dd312f feat: add individual decrypt buttons for bookmark events
Added per-event decryption on debug page:
- Small 'decrypt' button appears on events with encrypted content
- Shows spinner while decrypting individual event
- Displays decryption results (public/private counts) inline
- Button disappears after successful decryption

Uses Helpers.hasHiddenContent() and Helpers.hasHiddenTags() to detect
which events need decryption.

Allows testing individual event decryption without batch operation.
2025-10-17 21:25:28 +02:00
Gigi
b711b21048 feat: show correct connection type on debug page
Updated debug page to display the actual account type:
- Browser Extension (type: 'extension')
- Bunker Connection (type: 'nostr-connect')
- Account Connection (fallback)

Changes:
- Section title now reflects active account type
- Connection status message updated accordingly
- No longer always shows 'Bunker Connection' regardless of type

Makes it clear to users which authentication method they're using.
2025-10-17 21:21:39 +02:00
Gigi
8eaba04d91 refactor: disable account queue globally
Set accounts.disableQueue = true on AccountManager during initialization:
- Applies to all accounts automatically
- No need for temporary queue toggling in individual operations
- Makes all bunker requests instant (no internal queueing)

Removed temporary queue disabling from bookmarkProcessing.ts since
it's now globally disabled.

Updated Amber.md to document the global approach.

This eliminates the root cause of decrypt hangs - requests no longer
wait in an internal queue for previous requests to complete.
2025-10-17 21:19:21 +02:00
Gigi
0785b034e4 perf: use shorter timeouts for debug page bookmark loading
Reduced timeouts to trust EOSE from fast relays:
- Local: 800ms (down from 1200ms)
- Remote: 2000ms (down from 6000ms)

The query completes when relays send EOSE, not when timeout expires.
Fast relays send EOSE in <1 second, so total time should be much less
than the previous 6-second wait.

Result: Debug page bookmark loading now completes in ~1-2 seconds instead of always 6 seconds.
2025-10-17 21:06:05 +02:00
Gigi
47e698f197 feat: stream bookmark events on debug page
Implemented live streaming of bookmark events as they arrive from relays:
- Events appear immediately as relays respond
- Live deduplication of replaceable events (30003, 30001, 10003)
- Keep newest version when duplicates found
- Web bookmarks (39701) not deduplicated (each unique)

Benefits:
- Much more responsive UI - see events immediately
- Better user experience with progress visibility
- Deduplication happens in real-time

Uses queryEvents onEvent callback to process events as they stream in.
2025-10-17 21:01:10 +02:00
Gigi
3a752a761a refactor: remove artificial timeouts from bookmark decryption
Removed all withTimeout wrappers - now matches debug page behavior:
- Direct decrypt calls with no artificial timeouts
- Let operations fail naturally and quickly
- Bunker responds instantly (success or rejection)

No timeouts needed because:
1. Account queue is disabled (requests sent immediately)
2. Only decrypting truly encrypted content (no wasted attempts)
3. Bunker either succeeds quickly or fails quickly

This makes bookmark decryption instant, just like the debug page
encryption/decryption tests.
2025-10-17 20:54:45 +02:00
Gigi
f6cc49c07a fix: only decrypt events with actual encrypted content
Use applesauce Helpers.hasHiddenContent() instead of checking for
any content. This properly detects encrypted content and avoids
sending unnecessary decrypt requests to Amber for events that just
have plain text content.

Before: (evt.content && evt.content.length > 0)
After: Helpers.hasHiddenContent(evt)

Result:
- Only events with encrypted content sent to Amber
- Reduces unnecessary decrypt requests
- Faster bookmark loading
2025-10-17 20:53:25 +02:00
Gigi
5c4fca9cc9 docs: document critical queue disabling requirement in Amber.md
Added findings about applesauce-accounts queue issue:
- Queue MUST be disabled for batch decrypt operations
- Default queueing blocks all requests until first completes
- This was the primary cause of hangs and timeouts
- Updated performance improvements section with all optimizations
- Updated conclusion with key learnings

Ref: https://hzrd149.github.io/applesauce/typedoc/classes/applesauce-accounts.BaseAccount.html#disablequeue
2025-10-17 20:47:13 +02:00
Gigi
536a7ce1fa fix: disable account queue during batch decrypt operations
The applesauce BaseAccount queues requests by default, waiting for
each to complete before sending the next. This caused decrypt requests
to timeout before ever reaching Amber/bunker.

Solution:
- Set disableQueue=true before batch operations
- All decrypt requests sent immediately
- Restore original queue state after completion

This should fix the hanging/timeout issue where Amber never saw
the decrypt requests because they were stuck in the account's queue.

Ref: https://hzrd149.github.io/applesauce/typedoc/classes/applesauce-accounts.BaseAccount.html#disablequeue
2025-10-17 20:46:00 +02:00
Gigi
61072aef40 refactor: remove concurrent decryption in favor of sequential
Removed mapWithConcurrency hack:
- Simpler code with plain for loop
- More predictable behavior
- Better for bunker signers (network round-trips)
- Each decrypt happens in order, no race conditions

Sequential processing is cleaner and works better with remote signers.
2025-10-17 20:44:48 +02:00
Gigi
b7ec1fcf06 fix: add 5s timeout and smart encryption detection for bookmarks
Changes:
- Use 5-second timeout instead of 30 seconds
- Detect encryption method from content format
- NIP-04 has '?iv=' in content, NIP-44 doesn't
- Try the likely method first to avoid unnecessary timeouts
- Falls back to other method if first fails

This should:
- Prevent hanging forever (5s timeout)
- Be much faster than 30s when wrong method is tried
- Usually decrypt instantly when right method is used first
2025-10-17 20:42:46 +02:00
Gigi
d2fd8fb8fe perf: remove 30s timeout from bookmark decryption
The withTimeout wrapper was causing decrypt operations to wait
30 seconds before failing, even when bunker rejection was instant.

Now uses the same direct approach as the debug page encryption tests:
- No artificial timeouts
- Fast natural failures
- Should reduce decrypt time from 30s+ to near-instant

Fixes slow bookmark loading when bunker doesn't support nip44.
2025-10-17 20:40:07 +02:00
Gigi
68ee1b3122 feat: add clear button for bookmark data
- Right-aligned clear button in the same row as load/decrypt
- Clears events, stats, and timing data
- Disabled when no data to clear
2025-10-17 20:35:17 +02:00
Gigi
a37735fc1c refactor: show decrypted bookmarks above loaded events 2025-10-17 20:33:25 +02:00
Gigi
de0f587174 refactor: display bookmark event stats on separate lines 2025-10-17 20:21:24 +02:00
Gigi
f977561779 feat: restore padlock emoji for encrypted content indicator 2025-10-17 20:21:07 +02:00
Gigi
043ea168fb feat: display full event ID for easy copy/paste 2025-10-17 20:20:48 +02:00
Gigi
5336bafed4 refactor: remove emojis from bookmark event display 2025-10-17 20:20:24 +02:00
Gigi
c51291bf81 feat: add performance timing to bookmark loading and decryption
- Track load and decrypt operation durations
- Display live timing with spinner during operations
- Show completed timing in milliseconds
- Uses same Stat component as encryption timing
2025-10-17 20:19:48 +02:00
Gigi
489e48fe4d feat: enhance bookmark event display with detailed info
- Show kind names (Simple List, Replaceable List, etc)
- Display data size in human-readable format (B, KB, MB)
- Show count of public bookmarks per event
- Indicate presence of encrypted content
- Show d-tag and title for better identification
2025-10-17 20:17:58 +02:00
Gigi
744a145e9f fix: resolve linting errors in App.tsx and async.ts 2025-10-17 20:09:20 +02:00
Gigi
7ad925dbd3 feat: add bookmark loading and decryption section to debug page 2025-10-17 20:08:08 +02:00
Gigi
a69298a3a9 perf(bunker): non-blocking bookmark decryption with concurrency limit
- Add withTimeout and mapWithConcurrency helpers in utils/async.ts
- Refactor collectBookmarksFromEvents to decrypt with 6-way concurrency
- Public bookmarks collected immediately, private decrypted in parallel
- Each decrypt wrapped with 30s timeout safety net
- Document non-blocking publish and concurrent decrypt in Amber.md
2025-10-17 13:27:50 +02:00
Gigi
2c3aff0407 perf(bunker): make NIP-46 publish non-blocking at app wiring level; resolve immediately and let responses drive timing/results 2025-10-17 13:09:42 +02:00
Gigi
aad35d41db fix(debug): return benign object from fire-and-forget publish so timing UI remains stable 2025-10-17 13:06:17 +02:00
Gigi
cc6189a5d9 perf(bunker): fire-and-forget NIP-46 publish in app wrapper so UI isn’t blocked waiting on relay publish; encryption/decryption results now display immediately on /debug 2025-10-17 13:04:59 +02:00
Gigi
18bf8f9a2c ui(debug): use existing color pattern for red disconnect button with proper styling and hover effects 2025-10-17 12:56:47 +02:00
Gigi
37f3a32a1c fix(debug): use inline red styling for disconnect button since btn-danger class doesn't exist 2025-10-17 12:56:06 +02:00
Gigi
c9678564a5 ui(debug): change disconnect button to red (btn-danger) for better visual indication 2025-10-17 12:54:26 +02:00
Gigi
721c18c509 ui(debug): add Reset button to restore default payload text 2025-10-17 12:53:44 +02:00
Gigi
9e30fe683b ui(debug): left-align encrypt and decrypt buttons in both NIP-44 and NIP-04 sections 2025-10-17 12:53:20 +02:00
Gigi
7fff50c146 ui(debug): move Encrypt/Decrypt buttons above Encrypted text in both NIP-44 and NIP-04 sections 2025-10-17 12:52:40 +02:00
Gigi
fc1c845b67 ui(debug): change 'cipher' labels to 'Encrypted:' for better clarity 2025-10-17 12:52:12 +02:00
Gigi
c2ec1f3677 ui(debug): move Clear logs button below Show all checkbox 2025-10-17 12:51:37 +02:00
Gigi
0cbd357856 ui(debug): right-align all buttons using justify-end 2025-10-17 12:51:21 +02:00
Gigi
26ea9ed547 fix(lint): remove unused global variable declarations from Debug component 2025-10-17 12:50:49 +02:00
Gigi
9cbbecb32c ui(debug): increase debug logs height from max-h-96 to max-h-192 (2x taller) 2025-10-17 12:49:59 +02:00
Gigi
db12c89731 ui(debug): add character-wrap (break-all) to ciphertext textboxes 2025-10-17 12:49:28 +02:00
Gigi
6f413deb90 ui(debug): increase ciphertext textarea height to 5 lines (h-20) 2025-10-17 12:48:57 +02:00
Gigi
0127e2dc86 ui(debug): change page title from 'Bunker Debug' to 'Debug' 2025-10-17 12:48:25 +02:00
Gigi
7743928702 ui(debug): increase log area height from max-h-64 to max-h-96 (3x taller) 2025-10-17 12:48:01 +02:00
Gigi
bf76150fc1 ui(debug): show spinner in place of millisecond number during measurement 2025-10-17 12:47:36 +02:00
Gigi
c62107172b ui(debug): make ciphertext and plaintext fields multiline with proper whitespace handling 2025-10-17 12:47:13 +02:00
Gigi
a253587dfa ui(debug): add subtle background to payload textarea for better editability indication 2025-10-17 12:46:57 +02:00
Gigi
1938533d53 ui(debug): replace animated timing with spinner during measurement 2025-10-17 12:46:43 +02:00
Gigi
28943c55bd style(debug): update ciphertext and plaintext display to match logs textbox style 2025-10-17 12:46:21 +02:00
Gigi
791bbb68b6 fix(debug): implement proper stopwatch timing that counts up from 0ms in real-time 2025-10-17 12:44:29 +02:00
Gigi
ec8adcc794 refactor(debug): move plaintext display below buttons for better visual flow 2025-10-17 12:43:06 +02:00
Gigi
68058e7661 refactor(debug): move encrypt buttons next to decrypt buttons for better UX 2025-10-17 12:42:22 +02:00
Gigi
416c62369c refactor: extract VersionFooter component to eliminate duplication between debug and settings 2025-10-17 12:41:39 +02:00
Gigi
a19dd53423 feat(debug): add live performance timing with digital stopwatch display 2025-10-17 12:40:22 +02:00
Gigi
79ec33b79a style(debug): format NIP specifications as NIP-44 and NIP-04 2025-10-17 12:37:59 +02:00
Gigi
be881b957c feat(debug): update log description to 'Recent bunker logs:' 2025-10-17 12:36:50 +02:00
Gigi
244872e9f2 style(debug): move debug logs controls below the log output 2025-10-17 12:36:36 +02:00
Gigi
1397f7f0f4 style(debug): apply settings page styling structure and layout 2025-10-17 12:36:10 +02:00
Gigi
96424dd65c fix: resolve all linting issues - replace empty catch blocks and fix explicit any types 2025-10-17 12:33:53 +02:00
Gigi
9efc5459fb feat(debug): replace debug logs button with proper HTML checkbox element 2025-10-17 12:32:53 +02:00
Gigi
7e02168e54 feat(debug): make debug logs button show toggleable checkmark (✓/☐) 2025-10-17 12:32:29 +02:00
Gigi
f8e6b3e828 refactor(debug): move time measurements to dedicated Performance Timing section 2025-10-17 12:32:12 +02:00
Gigi
c06176bfc9 feat(debug): add bunker login section as first section of debug page 2025-10-17 12:31:31 +02:00
Gigi
e2a1701000 refactor(debug): move debug logs section to end with improved layout 2025-10-17 12:30:14 +02:00
Gigi
d7703ceef4 style(debug): use regular HTML checkmark instead of FontAwesome icon 2025-10-17 12:29:09 +02:00
Gigi
93daabc673 style(debug): improve cipher text wrapping with overflowWrap anywhere 2025-10-17 12:28:43 +02:00
Gigi
9264245944 style(debug): make Clear logs button a proper secondary button 2025-10-17 12:28:14 +02:00
Gigi
f56423040b feat(debug): add checkmark icon to debug logs button when enabled 2025-10-17 12:28:04 +02:00
Gigi
4b91504a50 feat(debug): clarify button text to 'Show all applesauce debug logs' 2025-10-17 12:27:45 +02:00
Gigi
1f0f7fef5e feat(debug): update title to 'Bunker Debug' for clarity 2025-10-17 12:27:25 +02:00
Gigi
6aced653fb feat(debug): add clock icon to time measurements for better visual clarity 2025-10-17 12:27:14 +02:00
Gigi
0899482869 style(debug): make Encrypt (nip04) and Clear buttons proper secondary buttons 2025-10-17 12:26:51 +02:00
Gigi
1bdfa1e6e1 style(debug): apply same max-width as reading view to debug page 2025-10-17 12:26:31 +02:00
Gigi
f22a8f15c0 style(debug): improve debug page styling and layout consistency 2025-10-17 12:22:31 +02:00
Gigi
bf6394fc7d feat(debug): add version and git commit footer to /debug page 2025-10-17 12:20:43 +02:00
Gigi
6f08586e8f feat(debug): improve layout/readability with sections, code boxes, and stats badges 2025-10-17 12:19:09 +02:00
Gigi
d60a4a24ad feat(debug): show encrypt/decrypt durations for nip04/nip44 on /debug page 2025-10-17 12:14:59 +02:00
Gigi
51069f3623 feat(debug): add debug toggle and clear logs; disable account queueing for nostr-connect 2025-10-17 12:12:25 +02:00
Gigi
1407af22e3 feat(debug): interactive /debug page (manual nip04/nip44 encrypt/decrypt, live logs); add DebugBus and wire signer logs 2025-10-17 10:50:20 +02:00
Gigi
ea6220277d feat(debug): add /debug page with NIP-46 encrypt→decrypt probes for nip04/nip44 2025-10-17 10:37:45 +02:00
Gigi
fbffa03dad docs(amber): summarize bunker decrypt investigation, evidence, and next steps 2025-10-17 09:48:11 +02:00
Gigi
a74760d804 chore(bunker): increase decrypt timeouts (probe 10s, bookmark decrypt 30s) 2025-10-17 09:36:13 +02:00
Gigi
c4b0a712d2 chore(bunker): log NIP-46 method from event content to debug decrypt calls 2025-10-17 09:34:31 +02:00
Gigi
1fecf9c7f4 fix(bunker): accept remote===pubkey for Amber; remove invalid-state warning 2025-10-17 01:26:32 +02:00
Gigi
7be21203d9 chore(types): cast through unknown for protected publish/subscription access in debug wrappers 2025-10-17 01:25:21 +02:00
Gigi
f65f2c6597 chore(lint): remove explicit any types, add deps for useEffect, and type relay logging 2025-10-17 01:24:41 +02:00
Gigi
227def4328 chore(lint): replace empty catch blocks with warnings; keep strict rules 2025-10-17 01:22:53 +02:00
Gigi
b506624f57 fix(bunker): use encrypt→decrypt roundtrip for nip44/nip04 probe to avoid false timeouts 2025-10-17 01:19:37 +02:00
Gigi
fbb6a0a153 fix(bunker): merge signer.relays with app RELAYS to include local Amber relays 2025-10-17 01:13:03 +02:00
Gigi
528de32689 fix(bunker): wire NostrConnectSigner to RelayPool publish/subscription statics for NIP-46 responses 2025-10-17 01:07:35 +02:00
Gigi
230e5380ca chore(bunker): expand debug logs for NIP-46 publish/subscribe (tags, content length) 2025-10-17 01:05:13 +02:00
Gigi
349237d097 fix(bunker): preserve signer context when wrapping publish/subscription for decrypt responses 2025-10-17 01:01:44 +02:00
Gigi
d4df9f0424 chore: commit pending changes to App and LoginOptions 2025-10-17 00:55:47 +02:00
Gigi
2f68e84002 debug(bunker): log NIP-46 request body preview (method, params, content slice)
- Helps align our request shape with Amber's expected BunkerRequest format
2025-10-17 00:53:58 +02:00
Gigi
b18dcc29cd revert: do not block when remote === user pubkey
- Amber may legally use user pubkey as remote id
- Remove validation and warning that caused false negatives
2025-10-17 00:45:39 +02:00
Gigi
680169e312 fix(bunker): validate bunker URI - remote must differ from user pubkey
- Prevents invalid state where Amber remote equals user pubkey
- Show actionable error to generate fresh connect link in Amber
2025-10-17 00:42:14 +02:00
Gigi
11753c4515 debug(bunker): add post-connect decrypt probe (nip04/nip44) with timeout
- Verifies Amber responds to NIP-46 decrypt after connect
- Logs probe results under [bunker]; non-blocking to UX
2025-10-17 00:29:52 +02:00
Gigi
bd29dfd65f chore(bunker): warn if remote pubkey equals user pubkey (invalid state)
- Add sanity check and toast guidance to reconnect via Amber
- Helps catch misconfigured bunker URIs that would never respond to requests
2025-10-17 00:26:54 +02:00
Gigi
4b1ae838e5 chore: add Amber to .gitignore 2025-10-17 00:23:58 +02:00
Gigi
85599d3103 fix(bunker): guarded connect with explicit permissions on restore
- Pass getDefaultBunkerPermissions() to connect() to ensure decrypt perms
- Keeps existing reconnection safeguards and logging
- Aims to make Amber accept decrypt requests after restore
2025-10-17 00:21:46 +02:00
Gigi
4603c5a258 fix(bunker): guarded connect after subscription to enable decrypt
- After opening subscription, call connect() once per session if remote is present
- Helps Amber authorize decrypt ops; safe-guarded and logged
- Keep isConnected=true for subsequent requireConnection() paths
2025-10-17 00:19:21 +02:00
Gigi
ec45fbc5e8 debug(bunker): log signer publish/subscribe calls and relay connectivity
- Wrap NostrConnectSigner publish/subscription to log relays and filters
- Log relayPool connectivity snapshot before bookmark decryption
- Helps diagnose decrypt requests not reaching Amber
2025-10-17 00:17:00 +02:00
Gigi
53400334b2 Revert "fix: skip bookmark decryption for bunker signers"
This reverts commit af4ff7081a.
2025-10-17 00:12:20 +02:00
Gigi
af4ff7081a fix: skip bookmark decryption for bunker signers
- Bunker (NIP-46) signers don't reliably support async decrypt operations
- Skip attempting to decrypt private bookmarks when using bunker
- Users can still see all public bookmarks
- Use extension signer for access to encrypted private bookmarks
- Prevents 15+ second hangs waiting for decrypt responses that won't come
2025-10-17 00:11:20 +02:00
Gigi
7f21b8ed76 fix: add startup delay to allow bunker subscription to fully establish
- Small 100ms delay after opening signer subscription
- Ensures the subscription is ready to receive decrypt responses
- May fix timeout issues with bunker decrypt operations
2025-10-17 00:09:27 +02:00
Gigi
55e44dcc9c debug: increase decrypt timeout to 15 seconds
- Give bunker operations more time to respond
- Will help determine if this is a timing issue or a fundamental limitation
- Still logging timeout errors for visibility
2025-10-17 00:05:53 +02:00
Gigi
59dac947ab fix: actually reorder bunker relay addition before signer recreation
- Previous commit had wrong message, code wasn't actually changed
- Now properly add relays to pool before creating NostrConnectSigner
- Ensures publishMethod/subscriptionMethod have full relay list available
2025-10-17 00:00:57 +02:00
Gigi
7d33c3c024 fix: add bunker relays to pool BEFORE recreating signer
- Bunker relays must be in pool when signer sets up publishMethod/subscriptionMethod
- Previously added after signer recreation, leaving pool incomplete
- This should fix decrypt operations that rely on publishMethod being set up correctly
- Same fix pattern as we used for signing
2025-10-16 23:59:14 +02:00
Gigi
38a014ef84 debug: verify subscriptionMethod and publishMethod on recreated signer
- Check if recreated NostrConnectSigner has methods needed for decrypt operations
- This will help identify if the issue is missing publishMethod for sending decrypt requests
- Or missing subscriptionMethod for receiving responses
2025-10-16 23:57:32 +02:00
Gigi
f451348430 debug: add logging to bookmark decrypt error handling
- Log nip04/nip44 decrypt errors instead of silently ignoring
- Will help identify why bookmark decryption is timing out with bunker
- Timeout errors will now be visible in console
2025-10-16 23:55:30 +02:00
Gigi
685aaf43b0 fix: add timeout to bookmark decryption to prevent hanging
- Wrap nip04/nip44 decrypt calls with 5 second timeout
- Prevents UI from hanging if decrypt request doesn't receive response
- Allows graceful degradation instead of infinite wait
- With bunker, decrypt responses may not arrive if perms/relay issues
2025-10-16 23:54:31 +02:00
Gigi
d6a20b5272 debug: add [bunker] prefix to bookmark decryption logging
- Better filtering of bunker-related logs
- Track when signer candidate is being selected
2025-10-16 23:50:16 +02:00
Gigi
d8d7a19fa1 fix: pass account.signer to EventFactory instead of full account
- EventFactory expects an EventSigner interface with signEvent method
- account.signer is the actual NostrConnectSigner instance
- Add debug logging to trace signer type
- This should fix signing hanging when using bunker
2025-10-16 23:46:25 +02:00
Gigi
63626fae3a fix: recreate NostrConnectSigner with pool on account restore
- Restored signers from JSON don't have pool context
- Recreate signer with pool passed explicitly to fix subscriptionMethod binding
- This ensures signing requests are properly sent/received through the pool
- Fixes hanging on signing after page reload
2025-10-16 23:44:43 +02:00
Gigi
de09ef2935 fix: avoid adding duplicate bunker relays to pool
- Only add bunker relays that aren't already in the pool
- Prevents duplicate subscriptions that could cause signing hangs
- Improves stability when account is reconnected
2025-10-16 23:43:03 +02:00
Gigi
bcb28a63a7 refactor: cleanup after bunker signing implementation
- Remove reconnectBunkerSigner function, inline logic into App.tsx for better control
- Clean up try-catch wrapper in highlightCreationService, signing now works reliably
- Remove extra logging from signing process (already has [bunker] prefix logs)
- Simplify nostrConnect.ts to just export permissions helper
- Update api/article-og.ts to use local relay config instead of import
- All bunker signing tests now passing 
2025-10-16 23:39:31 +02:00
Gigi
a479903ce3 debug: log signer state before signing 2025-10-16 23:34:59 +02:00
Gigi
567d105261 fix: restore isConnected = true so signing doesn't hang
- Without this, requireConnection() tries to connect() again
- That breaks the entire signing flow
- Mark signer as connected after opening subscription
2025-10-16 23:33:31 +02:00
Gigi
83743c5a9f fix: remove decrypt queue that was blocking highlight signing
- The global decrypt queue in bookmarkProcessing was getting stuck
- Caused all NIP-46 operations to hang indefinitely
- Decrypt already has per-call timeouts; queue was unnecessary
- Highlights should now sign immediately without waiting for bookmarks
2025-10-16 23:30:18 +02:00
Gigi
0b8f88ea1d revert(highlight): avoid pre-connect; rely on requireConnection during sign
- Remove manual connect/open in highlight flow
- Prevent side-effects that may interfere with pending requests
2025-10-16 23:28:06 +02:00
Gigi
fadc755930 fix(highlight): ensure NIP-46 signer is open/connected before signing
- Pre-open subscription and connect() if bunker signer present
- Restores reliable highlight signing with Amber (NIP-46)
2025-10-16 23:26:28 +02:00
Gigi
f67f171e64 fix(bookmarks): serialize decrypt/unlock NIP-46 operations
- Queue decrypt/unlock to avoid overlapping requests hanging the provider
- Keep timeouts and detailed [bunker] logs
- Should stop decrypt flood from blocking highlight signing
2025-10-16 23:21:52 +02:00
Gigi
449c59015e refactor(api): import RELAYS from central config to keep DRY
- Remove duplicated relay array from api/article-og.ts
- Import from src/config/relays.ts instead
2025-10-16 23:20:57 +02:00
Gigi
4d697e6a79 chore(relays): update RELAYS list (include relay.nsec.app early)
- Aligns app relay set with commonly used relays
- May improve connectivity and latency for NIP-46 roundtrips
2025-10-16 23:20:05 +02:00
Gigi
04ae70873a fix: restore direct pool bindings for NIP-46 methods
- Revert logging wrappers around subscription/publish
- Use pool.subscription.bind(pool) and pool.publish.bind(pool)
- Avoid any side effects interfering with signer requests
2025-10-16 23:18:37 +02:00
Gigi
2f8a64826a debug: restore [bunker] logs around highlight signing
- Log before/after factory.sign for highlights
- Surface errors to console for fast diagnosis
2025-10-16 23:16:59 +02:00
Gigi
11cb3542ee fix: revert forced connect on reconnection to restore signing
- Remove connect(undefined, permissions) on restore
- Let requireConnection() trigger connect per op
- Keeps highlights signing working as before while we debug decrypt
2025-10-16 23:11:08 +02:00
Gigi
905296621c fix: pass permissions on reconnect to ensure decrypt allowed
- Call signer.connect(undefined, permissions) when restoring account
- Ensures bunker re-grants decrypt (nip04/nip44) if needed
- Keeps implementation aligned with applesauce examples
2025-10-16 23:06:06 +02:00
Gigi
769484bc0d debug: log NIP-46 subscribe/publish traffic
- Wrap subscriptionMethod/publishMethod to log relays, filters, responses
- Helps confirm decrypt/sign requests are actually sent and on which relays
- Continue using applesauce-recommended binding pattern
2025-10-16 22:58:41 +02:00
Gigi
27ff4cef22 fix: properly connect NostrConnectSigner on reconnection
- Call signer.connect() instead of forcing isConnected
- Add [bunker] logs for connect lifecycle
- Should unblock nip44/nip04 decrypt calls that were timing out
2025-10-16 22:55:17 +02:00
Gigi
a352e2616e fix: prevent decrypt hangs with timeout + fallback
- Wrap nip44/nip04 decrypt and unlockHiddenTags in timeouts
- Fallback nip44->nip04 if nip44 hangs/fails
- Add detailed [bunker] logs for each stage
- Keeps UI responsive while debugging bunker responses
2025-10-16 22:51:58 +02:00
Gigi
77cbb9394f refactor: simplify bunker implementation following applesauce patterns
- Remove bunkerFixVersion migration logic
- Simplify account loading to match applesauce examples
- Simplify reconnectBunkerSigner (no waiting, no complex logging)
- Direct nip04/nip44 exposure from signer (like ExtensionAccount)
- Clean up bookmark service account checking
- Keep debug logs for now until verified working
2025-10-16 22:48:46 +02:00
Gigi
39c8b3dfe4 fix: auto-clear old bunker accounts that were created with wrong setup
- Old bunker accounts were created before proper method binding
- Add version check to clear nostr-connect accounts once
- Preserves extension accounts
- Users will need to reconnect bunker (one-time migration)
2025-10-16 22:45:56 +02:00
Gigi
7bd11e695e fix: use proper NostrConnectSigner setup per applesauce examples
- Was setting NostrConnectSigner.pool (wrong approach)
- Should set subscriptionMethod and publishMethod directly
- Follows the pattern from applesauce/packages/examples/src/examples/signers/bunker.tsx
- This is the correct way to wire up the signer with the relay pool
2025-10-16 22:44:56 +02:00
Gigi
a76b703d36 fix: cache wrapped nip04/nip44 objects instead of using getters
- Getters were returning new objects each time
- Code was getting reference then calling decrypt on it
- Now assign wrapped objects directly as properties
- This ensures our logging wrappers are actually used
2025-10-16 22:42:47 +02:00
Gigi
df51173405 debug: wrap nip04/nip44 methods with [bunker] logging
- Log when decrypt/encrypt methods are called
- Log when they complete or fail
- Show pubkey and ciphertext/plaintext lengths
- This will tell us if decrypt is hanging in the signer or never returning
2025-10-16 22:41:04 +02:00
Gigi
a79d7f9eaf debug: enable NostrConnectSigner logging to diagnose decrypt hang
- Add detailed logging for signer subscription opening
- Enable debug logs for NostrConnectSigner via localStorage
- This will show if requests are being sent and responses received
- Helps diagnose why decrypt requests hang indefinitely
2025-10-16 22:40:00 +02:00
Gigi
1032a46456 fix: wait for bunker relay connections before marking signer ready
- Decryption was hanging because relay connections weren't established
- NostrConnectSigner sends requests via relays but pool wasn't connected
- Now wait for at least one bunker relay to be connected (5s timeout)
- Prevents decrypt/sign requests from being sent to unconnected relays
- Adds detailed logging for connection status
2025-10-16 22:37:45 +02:00
Gigi
ae997758ab debug: add detailed [bunker] logs for bookmark decryption
- Log account properties and nip04/nip44 availability
- Log signer fallback logic
- Log each decryption attempt (nip44 and nip04)
- Log success/failure for hidden tags and content decryption
- Helps diagnose why bunker decryption isn't working
2025-10-16 22:36:00 +02:00
Gigi
91a827324d fix: expose nip04/nip44 on NostrConnectAccount for bookmark decryption
- NostrConnectSigner has nip04/nip44 but not exposed at account level
- ExtensionAccount exposes these via getters, NostrConnectAccount didn't
- Add properties dynamically during reconnection for compatibility
- Enables private bookmark decryption with bunker accounts
2025-10-16 22:34:18 +02:00
Gigi
bf849c9faa refactor: clean up bunker implementation for better maintainability
- Extract reconnectBunkerSigner into reusable helper function
- Reduce excessive debug logging in App.tsx (90+ lines → 30 lines)
- Simplify account restoration logic with cleaner conditionals
- Remove verbose signing logs from highlightCreationService
- Keep only essential error logs for debugging
- Follows DRY principles and applesauce patterns
2025-10-16 22:32:06 +02:00
Gigi
118ab46ac0 fix: add bunker relays to relay pool for signing requests
- NostrConnectSigner uses its own relay list for signing requests
- Pool must be connected to bunker relays to send/receive requests
- Add bunker relays to pool when reconnecting after page load
- This fixes signing hanging indefinitely
2025-10-16 22:28:54 +02:00
Gigi
d2f2b689f9 fix: create and setup pool BEFORE loading accounts from localStorage
- NostrConnectAccount.fromJSON needs NostrConnectSigner.pool to be set
- Move pool creation and setup before accounts.fromJSON()
- This fixes 'Missing subscriptionMethod' error on page reload
- Now bunker accounts can be properly restored from localStorage
2025-10-16 22:25:15 +02:00
Gigi
5229e45566 fix: remove unused getDefaultBunkerPermissions import from App.tsx
- Import was no longer needed after removing connect() call
- Fixes eslint no-unused-vars error
- All linter and type checks now pass
2025-10-16 22:22:16 +02:00
Gigi
b17043e85d debug: add detailed logging for account restoration from localStorage
- Log raw accounts JSON from localStorage
- Log parsed account count and types
- Log active ID lookup and restoration steps
- This will help diagnose why accounts aren't persisting across refresh
2025-10-16 22:21:05 +02:00
Gigi
19ca909ef5 fix: setup pool and relays BEFORE bunker reconnection subscription
- Move NostrConnectSigner.pool assignment before active account subscription
- Move pool.group(RELAYS) before subscription
- This ensures pool is ready when bunker signer tries to send requests
- The subscription can fire immediately, so pool must be configured first
- Add log to confirm pool assignment
2025-10-16 22:17:48 +02:00
Gigi
f7ff309b6e fix: set isConnected=true after opening restored bunker signer
- After page reload, signer is restored with isConnected=false
- When signing, requireConnection() would call connect() again without permissions
- Now we set isConnected=true after open() to prevent re-connection
- The bunker remembers permissions from initial connection
- This ensures signing works after page refresh
2025-10-16 22:16:06 +02:00
Gigi
ea5a8486b9 fix: don't call connect() again on restored bunker signer
- fromBunkerURI() already calls connect() with permissions during login
- Calling connect() again breaks the connection state
- Just call open() to ensure subscription is active
- This matches the pattern in applesauce examples which don't reconnect
- Log final signer status including relays for debugging
2025-10-16 22:15:02 +02:00
Gigi
58897b3436 fix: prevent double reconnection and add status checks after connect
- Track reconnected accounts to avoid double-connecting
- Log signer status after open() and connect() to verify state
- This should prevent the double reconnection issue
- Will help diagnose if connection is being lost immediately
2025-10-16 22:14:12 +02:00
Gigi
6a59ecfa47 debug: prefix all bunker logs with [bunker] for easy filtering
- Update App.tsx reconnection logs
- Update highlightCreationService signing logs
- Update LoginOptions error logs
- Makes it easy to filter console with 'bunker' keyword
2025-10-16 22:12:56 +02:00
Gigi
272066c6e0 debug: add comprehensive logging for bunker reconnection and signing
- Add detailed logs for active account changes and bunker detection
- Log signer status (listening, isConnected, hasRemote)
- Log each step of reconnection process
- Add signing attempt logs in highlightCreationService
- This will help diagnose where the signing process hangs
2025-10-16 22:08:14 +02:00
Gigi
0426c9d3b0 fix: correct Accounts import in App.tsx
- Import Accounts from 'applesauce-accounts' instead of 'applesauce-accounts/accounts'
- Fixes TypeScript error TS2305
- All linter and type checks now pass
2025-10-16 21:58:08 +02:00
Gigi
c22419ba0e fix: ensure bunker signer reconnects with permissions on app restore
- Create centralized getDefaultBunkerPermissions() in nostrConnect service
- Update LoginOptions to use centralized permissions
- Add bunker reconnection logic in App.tsx on active account change
- Reconnect bunker signer with open() and connect() when restored from localStorage
- Surface permission errors to users via toast in useHighlightCreation
- Ensures highlights, reactions, settings, and bookmarks work after page reload with bunker
2025-10-16 21:56:31 +02:00
Gigi
8278fed2fb fix: request NIP-46 permissions for bunker signing
- Add explicit signing permissions for event kinds: 5, 7, 17, 9802, 30078, 39701, 0
- Add encryption/decryption permissions: nip04_encrypt/decrypt, nip44_encrypt/decrypt
- Improve error messages when bunker permissions are missing or denied
- Add debug logging hint for bunker permission issues in write service
- This ensures highlights, reactions, settings, reading positions, and web bookmarks all work with bunker
2025-10-16 21:47:59 +02:00
Gigi
b24a65b490 feat: add Login with Bunker authentication option
- Wire NostrConnectSigner to RelayPool in App.tsx
- Create LoginOptions component with Extension and Bunker login flows
- Show LoginOptions in BookmarkList when user is logged out
- Add applesauce-accounts and applesauce-signers to vite optimizeDeps
- Support NIP-46 bunker:// URI authentication alongside extension login
2025-10-16 21:17:34 +02:00
Gigi
fb509fabd8 style(settings): add proper spacing around middot separator between version and commit 2025-10-16 20:59:27 +02:00
Gigi
d21285123f feat(settings): separate version and commit links - version links to release, commit links to commit 2025-10-16 20:59:09 +02:00
Gigi
1029b6be0c feat(settings): link version to GitHub release page instead of commit 2025-10-16 20:57:57 +02:00
Gigi
3fff9455a1 docs: update CHANGELOG.md for v0.6.24 2025-10-16 20:00:22 +02:00
Gigi
8c6232e029 chore(release): bump version to 0.6.24 2025-10-16 19:59:48 +02:00
Gigi
f6c562e9be fix(types): add global declarations for build-time defines and fix eslint issues 2025-10-16 19:58:57 +02:00
Gigi
a92b14e877 docs: update CHANGELOG.md for v0.6.23 2025-10-16 19:57:11 +02:00
Gigi
b69a956247 chore(release): bump version to 0.6.23 2025-10-16 19:54:35 +02:00
Gigi
82a8dcf6eb chore(settings): link short commit hash to GitHub and remove timestamp/branch 2025-10-16 19:35:20 +02:00
Gigi
8e19e22289 feat(settings): display app version and git commit in settings footer 2025-10-16 19:32:18 +02:00
Gigi
e167b57810 fix(api): align article-og relay usage to RelayPool.request and remove open/close 2025-10-16 19:20:54 +02:00
Gigi
ba3b82e6b5 chore(app): add RouteDebug gated by ?debug=1 to log route state 2025-10-16 19:19:33 +02:00
Gigi
b5edfbb2c9 chore(api): add structured debug logs to article-og handler with ?debug=1 2025-10-16 19:17:12 +02:00
Gigi
48048f877a fix(vercel): limit /a/:naddr rewrite to bots 2025-10-16 19:16:29 +02:00
Gigi
bd1afc54c3 docs: update CHANGELOG.md for v0.6.22 2025-10-16 16:02:02 +02:00
Gigi
a2c4bed0f5 chore: bump version to 0.6.22 2025-10-16 16:01:19 +02:00
Gigi
9bad49fe5f feat(vercel): add rewrite rule for article OG endpoint
Route /a/:naddr requests to /api/article-og for dynamic social preview tags.
2025-10-16 16:00:36 +02:00
Gigi
2aa6536496 Merge pull request #17 from dergigi/social-preview
Add dynamic social preview for article deep-links
2025-10-16 15:58:52 +02:00
Gigi
bd6d8a0342 chore(api): remove debug logging from article-og endpoint 2025-10-16 15:50:00 +02:00
Gigi
dc8e86bc57 fix(api): use history.replaceState before redirecting to SPA
Set the browser history to /a/{naddr} before redirecting to /, so when the SPA loads it sees the correct URL path.
2025-10-16 15:41:22 +02:00
Gigi
32b843908e debug: add logging and debug endpoint to article-og
Add console logging for debugging and ?debug=1 query param to see request details in browser.
2025-10-16 15:34:50 +02:00
Gigi
5a71480459 fix(api): add base tag for proper asset loading
Use named parameter syntax in Vercel rewrite and add <base href="/"> tag to ensure assets load correctly from root when serving index.html through the API.
2025-10-16 15:27:13 +02:00
Gigi
17455aa47b fix(api): serve index.html to browsers with preserved URL
Instead of redirecting, serve the static index.html file directly. The Vercel rewrite preserves the /a/{naddr} URL, allowing client-side SPA routing to work correctly.
2025-10-16 15:20:10 +02:00
Gigi
4cc32c27de fix(api): detect crawlers and redirect browsers to SPA
Browsers get 302 redirect to / where the SPA handles routing client-side with the original /a/{naddr} URL preserved. Crawlers/bots get the full HTML with OG meta tags.
2025-10-16 14:43:29 +02:00
Gigi
99bfe209a5 fix(api): use meta refresh instead of SPA boot in OG endpoint
Browsers will immediately redirect to / and load the SPA client-side, while crawlers/bots ignore meta refresh and only see the OG meta tags.
2025-10-16 14:38:17 +02:00
Gigi
0a28bfbd50 fix(api): replace any type with Filter from nostr-tools 2025-10-16 14:32:35 +02:00
Gigi
ba9fb109f6 refactor(api): DRY improvements for article OG endpoint
- Extract fetchEventsFromRelays helper to eliminate duplication
- Add setCacheHeaders helper for consistent header setting
- Parallelize article and profile fetching for faster response
- Move relayPool.close() to finally block to prevent leaks
- Remove redundant cacheKey variable and sorting
2025-10-16 14:31:39 +02:00
Gigi
ec9d2fcb49 chore(meta): add social preview image to homepage OG tags 2025-10-16 14:23:44 +02:00
Gigi
f841043e03 chore(assets): add default social preview image (1200x630) 2025-10-16 14:22:04 +02:00
Gigi
94dc95e1f0 feat(api): dynamic OG HTML for /a/{naddr} using relay metadata 2025-10-16 14:21:49 +02:00
Gigi
32a5145d8f chore(vercel): route /a/* to article OG handler 2025-10-16 14:20:58 +02:00
Gigi
a856e8ca26 docs: update CHANGELOG.md for v0.6.21 2025-10-16 09:57:13 +02:00
Gigi
d54306cf92 chore: bump version to 0.6.21 2025-10-16 09:56:06 +02:00
Gigi
9fdb96b64e Merge pull request #16 from dergigi/reading-progress-filters-part-two
feat: add reading progress filters and reads/links tabs
2025-10-16 09:55:32 +02:00
Gigi
c50aa3a243 fix: resolve TypeScript errors from merge
- Remove unused readingPositions and markedAsReadIds from useBookmarksData
- Remove eventStore parameter from useBookmarksData call
- Add reads and links fields to MeCache interface
2025-10-16 09:53:20 +02:00
Gigi
adef1a922c chore: remove completed plan file 2025-10-16 09:49:43 +02:00
Gigi
99df4d6761 chore: merge master into reading-progress-filters-part-two
Resolved conflicts by keeping feature branch changes:
- Kept /me/reads and /me/links routes (not /me/archive)
- Kept ReadingProgressFilters component and readingProgressUtils
- Kept readsService, linksService, and readingDataProcessor
- Restored files that were renamed/deleted in master
2025-10-16 09:49:13 +02:00
Gigi
5f6a414953 fix: resolve all linter errors and type issues
- Remove unused state variables (readsMap, linksMap) by using only setters
- Move VALID_FILTERS constant outside component to fix exhaustive-deps warning
- Remove unused isReading variable in ReadingProgressIndicator
- Remove unused extractUrlFromBookmark function and IndividualBookmark import
- Fix type errors in linksFromBookmarks by extracting metadata from tags instead of non-existent properties
2025-10-16 09:36:17 +02:00
Gigi
ed17a68986 refactor: simplify filter icon colors to blue (except green for completed) 2025-10-16 09:33:04 +02:00
Gigi
bedf3daed1 feat: add URL routing for reading progress filters 2025-10-16 09:32:30 +02:00
Gigi
2c913cf7e8 feat: color reading progress filter icons when active 2025-10-16 09:30:16 +02:00
Gigi
aff5bff03b refactor: use neutral text color for 'started' reading progress state 2025-10-16 09:29:41 +02:00
Gigi
e90f902f0b feat: add amber color for 'started' reading progress state (0-10%) 2025-10-16 09:28:06 +02:00
Gigi
d763aa5f15 fix: merge reading progress even when timestamp is older than bookmark 2025-10-16 09:20:24 +02:00
Gigi
9d6b1f6f84 fix: call onItem callback directly for items already in reads map 2025-10-16 09:18:32 +02:00
Gigi
9eb2f35dbf debug: add console logging to trace reading progress enrichment 2025-10-16 09:13:34 +02:00
Gigi
5f33ad3ba0 fix(reads): use setState callback pattern for background enrichment
- Replace closure over tempMap with setState callback pattern
- Ensures we always work with latest state when merging progress
- Prevents stale closure issues that block state updates
- Apply same fix to both reads and links tabs
- Fixes reading progress not updating in UI
2025-10-16 09:13:19 +02:00
Gigi
3db4855532 fix(reads): use naddr format for IDs to match reading positions
- Convert bookmark coordinates to naddr format in deriveReadsFromBookmarks
- Reading positions store progress with naddr as ID
- Using naddr format enables proper merging of reading progress data
- Simplify getReadItemUrl to use item.id directly (already naddr)
- Fixes reading progress not showing in /me/reads tab
2025-10-16 09:11:21 +02:00
Gigi
3305be1da5 feat(reads): extract image, summary, and published date from bookmark tags
- Extract metadata from tags same way BookmarkItem does (DRY)
- Add image tag extraction for article images
- Add summary tag extraction for article summaries
- Add published_at tag extraction for publish dates
- Images and summaries now display in /me/reads tab
2025-10-16 09:08:57 +02:00
Gigi
fe55e87496 fix: remove unused import from readsFromBookmarks 2025-10-16 09:06:06 +02:00
Gigi
f78f1a3460 fix(reads): use bookmark.content for article titles
- IndividualBookmark doesn't have separate title/event fields
- After hydration, article titles are stored in content field
- Simplified extraction logic to just use bookmark.content
2025-10-16 09:06:00 +02:00
Gigi
e73d89739b fix(reads): extract article titles from events using applesauce helpers
- Use getArticleTitle, getArticleSummary, getArticleImage, getArticlePublished from Helpers
- Extract metadata from bookmark.event when available
- Fallback to bookmark fields if event not hydrated
- Fixes 'Untitled' articles in Reads tab
2025-10-16 09:01:51 +02:00
Gigi
7e2b4b46c9 feat(me): populate reads/links from bookmarks instantly
- Add deriveReadsFromBookmarks helper to convert 30023 bookmarks to ReadItems
- Add deriveLinksFromBookmarks helper for web bookmarks (39701) and URLs
- Update loadReadsTab to show bookmarked articles immediately, enrich in background
- Update loadLinksTab to show bookmarked links immediately, enrich in background
- Background enrichment merges reading progress only for displayed items
- Preserve existing pull-to-refresh and empty state logic
2025-10-16 08:45:31 +02:00
Gigi
fddf79e0c6 feat: add named kind constants, streaming updates, and fix reads/links tabs
- Create src/config/kinds.ts with named Nostr kind constants
- Add streaming support to fetchAllReads and fetchLinks with onItem callbacks
- Update all services to use KINDS constants instead of magic numbers
- Add mergeReadItem utility for DRY state management
- Add fallbackTitleFromUrl for external links without titles
- Relax validation to allow external items without titles
- Update Me.tsx to use streaming with Map-based state for reads/links
- Fix refresh to merge new data instead of clearing state
- Fix empty states for Reads and Links tabs (no more infinite skeletons)
- Services updated: readsService, linksService, libraryService, bookmarkService, exploreService, highlights/fetchByAuthor
2025-10-16 08:27:10 +02:00
Gigi
cf2098a723 Merge pull request #15 from dergigi/revert-14-reading-progress-filters
Revert "Add reading progress filters and split Reads/Links tabs"
2025-10-16 08:06:06 +02:00
Gigi
5568437663 Revert "Add reading progress filters and split Reads/Links tabs" 2025-10-16 08:05:20 +02:00
Gigi
7bfd7fdf6c Merge pull request #14 from dergigi/reading-progress-filters
Add reading progress filters and split Reads/Links tabs
2025-10-16 01:46:32 +02:00
Gigi
e6876d141f fix: show skeletons during initial tab load for reads/links 2025-10-16 01:43:36 +02:00
Gigi
5bb81b3c22 fix: always show skeletons for reads/links when no data
Removed empty state messages like "No articles in your reads" and
"No links yet" - now just show loading skeletons until data arrives.

This is simpler and prevents showing empty states while data is still
being fetched in the background.

Users will only see:
- Skeletons when no data (loading or truly empty)
- "No articles/links match this filter" when filtered out
- Actual content when data is available
2025-10-16 01:40:37 +02:00
Gigi
1e8e58fa05 fix: show loading skeletons correctly for reads and links tabs
The bug was that showSkeletons checked if ANY tab had data, so if you
had highlights or bookmarks, it would never show skeletons for reads/links
even while they were still loading.

Fix: Each tab now checks its own loading state (loading && tabData.length === 0)
instead of using the shared showSkeletons variable.

This makes the logic simple and clear:
1. If loading AND no data → show skeletons
2. If not loading AND no data → show empty state
3. If has data but filtered out → show no match message
4. Otherwise → show content
2025-10-16 01:39:03 +02:00
Gigi
f44e36e4bf refactor: make code more DRY by extracting shared utilities
- Create readingProgressUtils.ts with filterByReadingProgress function
- Create readingDataProcessor.ts with shared processing functions:
  - processReadingPositions
  - processMarkedAsRead
  - filterValidItems
  - sortByReadingActivity
- Refactor readsService.ts to use shared utilities
- Refactor linksService.ts to use shared utilities
- Eliminate 100+ lines of duplicated code
- Simplify Me.tsx filter logic to 2 lines

Benefits:
- Single source of truth for reading progress filtering
- Easier to maintain and modify
- Less code duplication across services
- More testable with isolated utility functions
2025-10-16 01:36:28 +02:00
Gigi
11c7564f8c feat: split Reads tab into Reads and Links
- Reads: Only Nostr-native articles (kind:30023)
- Links: Only external URLs with reading progress
- Create linksService.ts for fetching external URL links
- Update readsService to filter only Nostr articles
- Add Links tab between Reads and Writings with same filtering
- Add /me/links route
- Update meCache to include links field
- Both tabs support reading progress filters
- Lazy loading for both tabs

This provides clear separation between native Nostr content and external web links.
2025-10-16 01:33:04 +02:00
Gigi
a064376bd8 fix: filter out 'Untitled' items from Reads tab
- Exclude Nostr articles without event data (can't fetch title)
- Exclude external URLs without proper titles
- Prevents cluttering Reads with items that have no meaningful title
- Only shows items we can properly identify and display
2025-10-16 01:25:31 +02:00
Gigi
292e8e9bda fix: only show external URLs in Reads if they have reading progress
- External URLs with 0% progress are now filtered out
- External URLs only appear if readingProgress > 0 OR marked as read
- Nostr articles still show even at 0% (bookmarked articles)
- Keeps Reads tab focused on actual reading activity for external links
2025-10-16 01:24:50 +02:00
Gigi
951a3699ca fix: replace spinners with skeleton placeholders in Me tabs
- Replace spinner in highlights tab with 'No highlights yet' message
- Replace spinner in reading-list tab with 'No bookmarks yet' message
- Only show these messages when loading is complete and arrays are empty
- Remove unused faSpinner import
- Consistent with skeleton placeholder pattern used elsewhere
2025-10-16 01:21:31 +02:00
Gigi
860ec70b1c feat: implement lazy loading for Me component tabs
- Add loadedTabs state to track which tabs have been loaded
- Create tab-specific loading functions (loadHighlightsTab, loadWritingsTab, loadReadingListTab, loadReadsTab)
- Only load data for active tab on mount and tab switches
- Show cached data immediately, refresh in background when revisiting tabs
- Update pull-to-refresh to only reload the active tab
- Show loading skeletons only on first load of each tab
- Works for both /me (own profile) and /p/ (other profiles)

This reduces initial load time from 30+ seconds to 2-5 seconds by only fetching data for the active tab.
2025-10-16 01:19:06 +02:00
Gigi
2b69c72939 refactor: simplify loading state to use unified logic
- Remove separate loadingReads state
- Keep single loading state true until ALL data is loaded
- Matches existing pattern used in other tabs
- Keeps code DRY and simple
2025-10-16 01:08:56 +02:00
Gigi
b98d774cbf fix: filter out reads without timestamps
- Exclude items without readingTimestamp or markedAt from reads
- Prevents 'Just Now' items from appearing in the reads list
- Only show reads with valid activity timestamps
2025-10-16 01:06:27 +02:00
Gigi
8972571a18 fix: keep showing skeletons while reads are loading
- Add separate loadingReads state to track reads fetching
- Show skeletons during the entire reads loading period
- Set loading=false after public data (highlights/writings) completes
- Prevents showing 'No articles match this filter' while reads are being fetched
2025-10-16 01:05:42 +02:00
Gigi
ab5d5dca58 debug: add logging to reads filtering 2025-10-16 00:59:28 +02:00
Gigi
e383356af1 feat: rename Archive to Reads and expand functionality
- Create new readsService to aggregate all read content from multiple sources
- Include bookmarked articles, reading progress tracked articles, and manually marked-as-read items
- Update Me component to use new reads service
- Update routes from /me/archive to /me/reads
- Update meCache to use ReadItem[] instead of BlogPostPreview[]
- Update filter logic to use actual reading progress data
- Support both Nostr-native articles and external URLs in reads
- Fetch and display article metadata from multiple sources
- Sort by most recent reading activity
2025-10-16 00:45:16 +02:00
Gigi
165d10c49b feat: split 'To read' filter into 'Unopened' and 'Started'
- Add 'unopened' filter (no progress, 0%) - uses fa-envelope icon
- Add 'started' filter (0-10% progress) - uses fa-envelope-open icon
- Remove 'to-read' filter
- Use classic/regular variant for envelope icons
- Update filter logic in BookmarkList and Me components
- New filter ranges:
  - Unopened: 0% (never opened)
  - Started: 0-10% (opened but not read far)
  - Reading: 11-94%
  - Completed: 95-100%
2025-10-16 00:13:34 +02:00
Gigi
e0869c436b fix: adjust 'Reading' filter to 11-94% range
- Change 'reading' filter from 10-95% to 11-94%
- Creates clearer boundaries between filters:
  - To read: 0-10%
  - Reading: 11-94%
  - Completed: 95-100%
2025-10-16 00:10:20 +02:00
Gigi
95432fc276 fix: reading position filters now work correctly in bookmarks
- Match marked-as-read event IDs to bookmark coordinate IDs
- Use eventStore to lookup events and build coordinates from them
- Add both event ID and coordinate format to markedAsReadIds set
- This fixes filtering of bookmarked articles by reading progress
- Apply same fix to both Bookmarks and Explore components
2025-10-15 23:54:44 +02:00
Gigi
1982d25fa8 feat: add fancy animation to Mark as Read button
- Icon spins 360° with bounce effect (scale up during spin)
- Button background changes to vibrant green gradient (#10b981)
- Green pulsing box-shadow effect on activation
- Button scales up slightly on click for emphasis
- Holds green state for 1.5 seconds
- Smoothly fades to gray after animation
- Final state is gray button to indicate marked status
- Uses cubic-bezier easing for modern, smooth feel
- Total animation duration: 2.5 seconds
- Prevents interaction during animation
2025-10-15 23:39:14 +02:00
Gigi
2fc64b6028 feat: change 'To read' filter to show 0-10% progress
- Update 'to-read' filter range from 0-5% to 0-10%
- Update 'reading' filter to start at 10% instead of 5%
- Adjust filter comments to reflect new ranges
2025-10-15 23:37:59 +02:00
Gigi
6e8686a49d feat: treat marked-as-read articles as 100% progress
- Fetch marked-as-read articles in useBookmarksData and Explore
- Pass markedAsReadIds through component chain (Bookmarks -> ThreePaneLayout -> BookmarkList)
- Display 100% progress for marked articles in all views (Archive, Bookmarks, Explore)
- Update filter logic to treat marked articles as completed
- Marked articles show green 100% progress bar
- Marked articles only appear in 'completed' or 'all' filters
- Remove reading position tracking from Me.tsx (not needed when all are marked)
- Clean up unused imports and variables
2025-10-15 23:36:05 +02:00
Gigi
fd5ce80a06 feat: add auto-mark as read at 100% reading progress
- Add autoMarkAsReadAt100 setting (default: false)
- Add checkbox in Layout & Behavior settings
- Automatically mark article as read after 2 seconds at 100% progress
- Trigger same animation as manual mark as read button
- Move isNostrArticle computation earlier for useCallback deps
- Move handleMarkAsRead to useCallback for use in auto-mark effect
2025-10-15 23:28:50 +02:00
Gigi
ac4185e2cc feat: merge 'Completed' and 'Marked as Read' filters into one
- Remove 'marked' filter type from ReadingProgressFilterType
- Update ReadingProgressFilters component to show only 4 filters
- Keep checkmark icon for unified 'Completed' filter
- Completed filter now shows both:
  - Articles with 95%+ reading progress
  - Articles manually marked as read (no position data or 0%)
- Remove unused faBooks icon import
- Update filter logic in BookmarkList and Me components
2025-10-15 23:22:40 +02:00
Gigi
9217077283 fix: replace spinners with skeletons during refresh in archive/writings tabs
- Changed spinner to empty state message only when not loading
- During refresh, keeps showing cached content or skeletons
- Archive: shows 'No articles in your archive' only when done loading
- Writings: shows 'No articles written yet' only when done loading
- Prevents jarring transition from skeletons to spinner during refresh
2025-10-15 23:20:54 +02:00
Gigi
b7c14b5c7c fix: restore top padding to reading progress filters
- Remove padding-top: 0 override
- Now has equal spacing top and bottom (0.5rem)
2025-10-15 23:18:31 +02:00
Gigi
9b3cc41770 refactor: rename ArchiveFilters to ReadingProgressFilters
- More accurate naming: filters are based on reading progress/position
- Renamed component: ArchiveFilters -> ReadingProgressFilters
- Renamed type: ArchiveFilterType -> ReadingProgressFilterType
- Renamed variables: archiveFilter -> readingProgressFilter
- Renamed CSS class: archive-filters-wrapper -> reading-progress-filters-wrapper
- Updated all imports and references in BookmarkList and Me components
- Updated comments to reflect reading progress filtering
2025-10-15 23:17:55 +02:00
Gigi
4c4bd2214c feat: add top border to archive filters in bookmarks sidebar
- Matches the style of bookmark type filters at top
- Visually separates archive filters from bookmarks content
2025-10-15 23:14:56 +02:00
Gigi
93c31650f4 fix: remove double border between archive filters and view controls
- Add archive-filters-wrapper class
- Remove border-bottom from bookmark-filters in wrapper
- Prevents double border (bookmark-filters border-bottom + view-mode-controls border-top)
2025-10-15 23:14:20 +02:00
Gigi
7f0d99fc29 fix: remove duplicate border between archive filters and view controls
- Remove borderTop from archive filters div
- Keep only the border from view-mode-controls CSS
2025-10-15 23:12:26 +02:00
Gigi
eb6dbe1644 feat: add archive filters to bookmarks sidebar
- Add ArchiveFilters component to bookmarks sidebar
- Filter buttons shown above view-mode-controls row
- Filters: All, To Read (0-5%), Reading (5-95%), Completed (95%+), Marked
- Only shown when kind:30023 articles are present
- Filters only apply to kind:30023 articles
- Other bookmark types (videos, notes, web) remain visible
2025-10-15 23:10:31 +02:00
Gigi
474da25f77 fix: add autoScrollToPosition to useEffect dependency array
- Fixes react-hooks/exhaustive-deps warning
- Ensures effect reruns when auto-scroll setting changes
2025-10-15 23:08:21 +02:00
Gigi
02eaa1c8f8 feat: show reading progress in Explore and Bookmarks sidebar
- Add reading position loading to Explore component
- Add reading position loading to useBookmarksData hook
- Display progress bars in Explore tab blog posts
- Display progress bars in Bookmarks large preview view
- Progress shown as colored bar (green for completed, orange for in-progress)
- Only shown for kind:30023 articles with saved reading positions
- Requires syncReadingPosition setting to be enabled
2025-10-15 23:07:18 +02:00
Gigi
8800791723 feat: add auto-scroll to reading position setting
- Add autoScrollToPosition setting (default: true)
- Add checkbox in Layout & Behavior settings
- Only auto-scroll when setting is enabled
- Allows users to disable auto-scrolling while keeping sync enabled
2025-10-15 22:53:47 +02:00
Gigi
6758b9678b fix: update 'To Read' filter to show 0-5% progress articles
- Filter now shows articles with 0-5% reading progress
- Excludes manually marked as read articles (those without position data)
- Updates comment to reflect new logic
2025-10-15 22:51:40 +02:00
Gigi
85649ae283 Merge pull request #13 from dergigi/sync-reading-position
Add reading position sync and archive enhancements
2025-10-15 22:45:13 +02:00
114 changed files with 8654 additions and 1376 deletions

1
.gitignore vendored
View File

@@ -11,4 +11,5 @@ dist
# Reference Projects
applesauce
primal-web-app
Amber

155
Amber.md Normal file
View File

@@ -0,0 +1,155 @@
## Boris ↔ Amber bunker: current findings
- **Environment**
- Client: Boris (web) using `applesauce` stack (`NostrConnectSigner`, `RelayPool`).
- Bunker: Amber (mobile).
- We restored a `nostr-connect` account from localStorage and re-wired the signer to the app `RelayPool` before use.
## What we changed client-side
- **Signer wiring**
- Bound `NostrConnectSigner.subscriptionMethod/publishMethod` to the app `RelayPool` at startup.
- After deserialization, recreated the signer with pool context and merged its relays with app `RELAYS` (includes local relays).
- Opened the signer subscription and performed a guarded `connect()` with default permissions including `nip04_encrypt/decrypt` and `nip44_encrypt/decrypt`.
- **Account queue disabling (CRITICAL)**
- `applesauce-accounts` `BaseAccount` queues requests by default - each request waits for the previous one to complete before being sent.
- This caused batch decrypt operations to hang: first request would timeout waiting for user interaction, blocking all subsequent requests in the queue.
- **Solution**: Set `accounts.disableQueue = true` globally on the `AccountManager` in `App.tsx` during initialization. This applies to all accounts.
- Without this, Amber never sees decrypt requests because they're stuck in the account's internal queue.
- Reference: https://hzrd149.github.io/applesauce/typedoc/classes/applesauce-accounts.BaseAccount.html#disablequeue
- **Probes and timeouts**
- Initial probe tried `decrypt('invalid-ciphertext')` → timed out.
- Switched to roundtrip probes: `encrypt(self, ... )` then `decrypt(self, cipher)` for both nip-44 and nip-04.
- Increased probe timeout from 3s → 10s; increased bookmark decrypt timeout from 15s → 30s.
- **Logging**
- Added logs for publish/subscribe and parsed the NIP-46 request content length.
- Confirmed NIP46 request events are kind `24133` with a single `p` tag (expected). The method is inside the encrypted content, so it prints as `method: undefined` (expected).
## Evidence from logs (client)
```
[bunker] ✅ Wired NostrConnectSigner to RelayPool publish/subscription
[bunker] 🔗 Signer relays merged with app RELAYS: (19) [...]
[bunker] subscribe via signer: { relays: [...], filters: [...] }
[bunker] ✅ Signer subscription opened
[bunker] publish via signer: { relays: [...], kind: 24133, tags: [['p', <remote>]], contentLength: 260|304|54704 }
[bunker] 🔎 Probe nip44 roundtrip (encrypt→decrypt)… → probe timeout after 10000ms
[bunker] 🔎 Probe nip04 roundtrip (encrypt→decrypt)… → probe timeout after 10000ms
bookmarkProcessing.ts: ❌ nip44.decrypt failed: Decrypt timeout after 30000ms
bookmarkProcessing.ts: ❌ nip04.decrypt failed: Decrypt timeout after 30000ms
```
Notes:
- Final signer status shows `listening: true`, `isConnected: true`, and requests are published to 19 relays (includes Ambers).
## Evidence from Amber (device)
- Activity screen shows multiple entries for: “Encrypt data using nip 4” and “Encrypt data using nip 44” with green checkmarks.
- No entries for “Decrypt data using nip 4” or “Decrypt data using nip 44”.
## Interpretation
- Transport and publish paths are working: Boris is publishing NIP46 requests (kind 24133) and Amber receives them (ENCRYPT activity visible).
- The persistent failure is specific to DECRYPT handling: Amber does not show any DECRYPT activity and Boris receives no decrypt responses within 1030s windows.
- Client-side wiring is likely correct (subscription open, permissions requested, relays merged). The remaining issue appears provider-side in Ambers NIP46 decrypt handling or permission gating.
## Repro steps (quick)
1) Revoke Boris in Amber.
2) Reconnect with a fresh bunker URI; approve signing and both encrypt/decrypt scopes for nip04 and nip44.
3) Keep Amber unlocked and foregrounded.
4) Reload Boris; observe:
- Logs showing `publish via signer` for kind 24133.
- In Amber, activity should include “Decrypt data using nip 4/44”.
If DECRYPT entries still dont appear:
- This points to Ambers NIP46 provider not executing/authorizing `nip04_decrypt`/`nip44_decrypt` methods, or not publishing responses.
## Suggestions for Amber-side debugging
- Verify permission gating allows `nip04_decrypt` and `nip44_decrypt` (not just encrypt).
- Confirm the provider recognizes NIP46 methods `nip04_decrypt` and `nip44_decrypt` in the decrypted payload and routes them to decrypt routines.
- Ensure the response event is published back to the same relays and correctly addressed to the client (`p` tag set and content encrypted back to client pubkey).
- Add activity logging for “Decrypt …” attempts and failures to surface denial/exception states.
## Performance improvements (post-debugging)
### Non-blocking publish wiring
- **Problem**: Awaiting `pool.publish()` completion blocks until all relay sends finish (can take 30s+ with timeouts).
- **Solution**: Wrapped `NostrConnectSigner.publishMethod` at app startup to fire-and-forget publish Observable/Promise; responses still arrive via signer subscription.
- **Result**: Encrypt/decrypt operations complete in <2s as seen in `/debug` page (NIP-44: ~900ms enc, ~700ms dec; NIP-04: ~1s enc, ~2s dec).
### Bookmark decryption optimization
- **Problem #1**: Sequential decrypt of encrypted bookmark events blocks UI and takes long with multiple events.
- **Problem #2**: 30-second timeouts on `nip44.decrypt` meant waiting 30s per event if bunker didn't support nip44.
- **Problem #3**: Account request queue blocked all decrypt requests until first one completed (waiting for user interaction).
- **Solution**:
- Removed all artificial timeouts - let decrypt fail naturally like debug page does.
- Added smart encryption detection (NIP-04 has `?iv=`, NIP-44 doesn't) to try the right method first.
- **Disabled account queue globally** (`accounts.disableQueue = true`) in `App.tsx` so all requests are sent immediately.
- Process sequentially (removed concurrent `mapWithConcurrency` hack).
- **Result**: Bookmark decryption is near-instant, limited only by bunker response time and user approval speed.
## Amethyst-style bookmarks (kind:30001)
**Important**: Amethyst bookmarks are stored in a **SINGLE** `kind:30001` event with d-tag `"bookmark"` that contains BOTH public AND private bookmarks in different parts of the event.
### Event structure:
- **Event kind**: `30001` (NIP-51 bookmark set)
- **d-tag**: `"bookmark"` (identifies this as the Amethyst bookmark list)
- **Public bookmarks**: Stored in event `tags` (e.g., `["e", "..."]`, `["a", "..."]`)
- **Private bookmarks**: Stored in encrypted `content` field (NIP-04 or NIP-44)
### Example event:
```json
{
"kind": 30001,
"tags": [
["d", "bookmark"], // Identifies this as Amethyst bookmarks
["e", "102a2fe..."], // Public bookmark (76 total)
["a", "30023:..."] // Public bookmark
],
"content": "lvOfl7Qb...?iv=5KzDXv09..." // NIP-04 encrypted (416 private bookmarks)
}
```
### Processing:
When this single event is processed:
1. **Public tags** 76 bookmark items with `sourceKind: 30001, isPrivate: false, setName: "bookmark"`
2. **Encrypted content** 416 bookmark items with `sourceKind: 30001, isPrivate: true, setName: "bookmark"`
3. Total: 492 bookmarks from one event
### Encryption detection:
- The encrypted `content` field contains a JSON array of private bookmark tags
- `Helpers.hasHiddenContent()` from `applesauce-core` only detects **NIP-44** encrypted content
- **NIP-04** encrypted content must be detected explicitly by checking for `?iv=` in the content string
- Both detection methods are needed in:
1. **Display logic** (`Debug.tsx` - `hasEncryptedContent()`) - to show padlock emoji and decrypt button
2. **Decryption logic** (`bookmarkProcessing.ts`) - to schedule decrypt jobs
### Grouping:
In the UI, these are separated into two groups:
- **Amethyst Lists**: `sourceKind === 30001 && !isPrivate && setName === 'bookmark'` (public items)
- **Amethyst Private**: `sourceKind === 30001 && isPrivate && setName === 'bookmark'` (private items)
Both groups come from the same event, separated by whether they were in public tags or encrypted content.
### Why this matters:
This dual-storage format (public + private in one event) is why we need explicit NIP-04 detection. Without it, `Helpers.hasHiddenContent()` returns `false` and the encrypted content is never decrypted, resulting in 0 private bookmarks despite having encrypted data.
## Current conclusion
- Client is configured and publishing requests correctly; encryption proves endtoend path is alive.
- Non-blocking publish keeps operations fast (~1-2s for encrypt/decrypt).
- **Account queue is GLOBALLY DISABLED** - this was the primary cause of hangs/timeouts.
- Smart encryption detection (both NIP-04 and NIP-44) and no artificial timeouts make operations instant.
- Sequential processing is cleaner and more predictable than concurrent hacks.
- Relay queries now trust EOSE signals instead of arbitrary timeouts, completing in 1-2s instead of 6s.
- The missing DECRYPT activity in Amber was partially due to requests never being sent (stuck in queue). With queue disabled globally, Amber receives all decrypt requests immediately.
- **Amethyst-style bookmarks** require explicit NIP-04 detection (`?iv=` check) since `Helpers.hasHiddenContent()` only detects NIP-44.

View File

@@ -7,6 +7,409 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [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
### Added
- Login with Bunker (NIP-46) authentication support
- Support for remote signing via Nostr Connect protocol
- Bunker URI input with validation and error handling
- Automatic reconnection on app restore with proper permissions
- Signer suggestions in error messages (Amber, nsec.app, Nostrum)
- Debug page (`/debug`) for diagnostics and testing
- Interactive NIP-04 and NIP-44 encryption/decryption testing
- Live performance timing with stopwatch display
- Bookmark loading and decryption diagnostics
- Real-time bunker logs with filtering and clearing
- Version and git commit footer
- Progressive bookmark loading with streaming updates
- Non-blocking, progressive bookmark updates via callback pattern
- Batched background hydration using EventLoader and AddressLoader
- Auto-decrypt bookmarks as they arrive from relays
- Individual decrypt buttons for encrypted bookmark events
- Bookmark grouping toggle (grouped by source vs flat chronological)
- Toggle between grouped view and flat chronological list
- Amethyst-style bookmark detection and grouping
- Display bookmarks even when they only have IDs (content loads in background)
### Changed
- Improved login UI with better copy and modern design
- Personable title and nostr-native language
- Highlighted 'your own highlights' in login copy
- Simplified button text to single words (Extension, Signer)
- Hide login button and user icon when logged out
- Hide Extension button when Bunker input is shown
- Auto-load bookmarks on login and page mount
- Enhanced bunker error messages
- Formatted error messages with signer suggestions
- Links to nos2x, Amber, nsec.app, and Nostrum signers
- Better error handling for missing signer extensions
- Centered and constrained bunker input field
- Centralized bookmark loading architecture
- Single shared bookmark controller for consistent loading
- Unified bookmark loading with streaming and auto-decrypt
- Consolidated bookmark loading into single centralized function
- Bookmarks passed as props throughout component tree
- Renamed UI elements for clarity
- "Bunker" button renamed to "Signer"
- Hide bookmark controls when logged out
- Settings version footer improvements
- Separate links for version (to GitHub release) and commit (to commit page)
- Proper spacing around middot separator
### Fixed
- NIP-46 bunker signing and decryption
- NostrConnectSigner properly reconnects with permissions on app restore
- Bunker relays added to relay pool for signing requests
- Proper setup of pool and relays before bunker reconnection
- Expose nip04/nip44 on NostrConnectAccount for bookmark decryption
- Cache wrapped nip04/nip44 objects instead of using getters
- Wait for bunker relay connections before marking signer ready
- Validate bunker URI (remote must differ from user pubkey)
- Accept remote===pubkey for Amber compatibility
- Bookmark loading and decryption
- Bookmarks load and complete properly with streaming
- Auto-decrypt private bookmarks with NIP-04 detection
- Include decrypted private bookmarks in sidebar
- Skip background event fetching when there are too many IDs
- Only build bookmarks from ready events (unencrypted or decrypted)
- Restore Debug page decrypt display via onDecryptComplete callback
- Make controller onEvent non-blocking for queryEvents completion
- Proper timeout handling for bookmark decryption (no hanging)
- Smart encryption detection with consistent padlock display
- Sequential decryption instead of concurrent to avoid queue issues
- Add extraRelays to EventLoader and AddressLoader
- PWA cache limit increased to 3 MiB for larger bundles
- Extension login error messages with nos2x link
- TypeScript and linting errors throughout
- Replace empty catch blocks with warnings
- Fix explicit any types
- Add missing useEffect dependencies
- Resolve all linting issues in App.tsx, Debug.tsx, and async utilities
### Performance
- Non-blocking NIP-46 operations
- Fire-and-forget NIP-46 publish for better UI responsiveness
- Non-blocking bookmark decryption with sequential processing
- Make controller onEvent non-blocking for queryEvents completion
- Optimized bookmark loading
- Batched background hydration using EventLoader and AddressLoader
- Progressive, non-blocking bookmark loading with streaming
- Shorter timeouts for debug page bookmark loading
- Remove artificial delays from bookmark decryption
### Refactored
- Centralized bookmark controller architecture
- Extract bookmark streaming helpers and centralize loading
- Consolidated bookmark loading into single function
- Remove deprecated bookmark service files
- Share bookmark controller between components
- Debug page organization
- Extract VersionFooter component to eliminate duplication
- Structured sections with proper layout and styling
- Apply settings page styling structure
- Simplified bunker implementation following applesauce patterns
- Clean up bunker implementation for better maintainability
- Import RELAYS from central config (DRY principle)
- Update RELAYS list with relay.nsec.app
### Documentation
- Comprehensive Amber.md documentation
- Amethyst-style bookmarks section
- Bunker decrypt investigation summary
- Critical queue disabling requirement
- NIP-46 setup and troubleshooting
## [0.6.24] - 2025-01-16
### Fixed
- TypeScript global declarations for build-time defines
- Added proper type declarations for `__APP_VERSION__`, `__GIT_COMMIT__`, `__GIT_BRANCH__`, `__BUILD_TIME__`, and `__GIT_COMMIT_URL__`
- Resolved ESLint no-undef errors for build-time injected variables
- Added Node.js environment hint to Vite configuration
## [0.6.23] - 2025-01-16
### Fixed
- Deep-link refresh redirect issue for nostr-native articles
- Limited `/a/:naddr` rewrite to bot user-agents only in Vercel configuration
- Real browsers now hit the SPA directly, preventing redirect to root path
- Bot crawlers still receive proper OpenGraph metadata for social sharing
### Added
- Version and git commit information in Settings footer
- Displays app version and short commit hash with link to GitHub
- Build-time metadata injection via Vite configuration
- Subtle footer styling with selectable text
### Changed
- Article OG handler now uses proper RelayPool.request() API
- Aligned with applesauce RelayPool interface
- Removed deprecated open/close methods
- Fixed TypeScript linting errors
### Technical
- Added debug logging for route state and article OG handler
- Gated by `?debug=1` query parameter for production testing
- Structured logging for troubleshooting deep-link issues
- Temporary debug components for validation
## [0.6.22] - 2025-10-16
### Added
- Dynamic OpenGraph and Twitter Card meta tags for article deep-links
- Social media platforms display article title, author, cover image, and summary when sharing `/a/{naddr}` links
- Serverless endpoint fetches article metadata from Nostr relays (kind:30023) and author profiles (kind:0)
- User-agent detection serves appropriate content to crawlers vs browsers
- Falls back to default social preview image when articles have no cover image
- Social preview image for homepage and article links
- Added `boris-social-1200.png` as default OpenGraph image (1200x630)
- Homepage now includes social preview image in meta tags
### Changed
- Article deep-links now properly preserve URL when loading in browser
- Uses `history.replaceState()` to maintain correct article path
- Browser navigation works correctly on refresh and new tab opens
### Fixed
- Vercel rewrite configuration for article routes
- Routes `/a/:naddr` to serverless OG endpoint for dynamic meta tags
- Regular SPA routing preserved for browser navigation
## [0.6.21] - 2025-10-16
### Added
- Reading position sync across devices using Nostr Kind 30078 (NIP-78)
- Automatically saves and syncs reading position as you scroll
- Visual reading progress indicator on article cards
- Reading progress shown in Explore and Bookmarks sidebar
- Auto-scroll to last reading position setting (configurable in Settings)
- Reading position displayed as colored progress bar on cards
- Reading progress filters for organizing articles
- Filter by reading state: Unopened, Started (0-10%), Reading (11-94%), Completed (95-100% or marked as read)
- Filter icons colored when active (blue for most, green for completed)
- URL routing support for reading progress filters
- Reading progress filters available in Archive tab and bookmarks sidebar
- Reads and Links tabs on `/me` page
- Reads tab shows nostr-native articles with reading progress
- Links tab shows external URLs with reading progress
- Both tabs populate instantly from bookmarks for fast loading
- Lazy loading for improved performance
- Auto-mark as read at 100% reading progress
- Articles automatically marked as read when scrolled to end
- Marked-as-read articles treated as 100% progress
- Fancy checkmark animation on Mark as Read button
- Click-to-open article navigation on highlights
- Clicking highlights in Explore and Me pages opens the source article
- Automatically scrolls to highlighted text position
### Changed
- Renamed Archive to Reads with expanded functionality
- Merged 'Completed' and 'Marked as Read' filters into one unified filter
- Simplified filter icon colors to blue (except green for completed)
- Started reading progress state (0-10%) uses neutral text color
- Replace spinners with skeleton placeholders during refresh in Archive/Reads/Links tabs
- Removed unused IEventStore import in ContentPanel
### Fixed
- Reading position calculation now accurately reaches 100%
- Reading position filters work correctly in bookmarks sidebar
- Filter out reads without timestamps or 'Untitled' items
- Show skeleton placeholders correctly during initial tab load
- External URLs in Reads tab only shown if they have reading progress
- Reading progress merges even when timestamp is older than bookmark
- Resolved all linter errors and TypeScript type issues
### Refactored
- Renamed ArchiveFilters component to ReadingProgressFilters
- Extracted shared utilities from readsFromBookmarks for DRY code
- Use setState callback pattern for background enrichment
- Use naddr format for article IDs to match reading positions
- Extract article titles, images, summaries from bookmark tags using applesauce helpers
## [0.6.20] - 2025-10-15
### Added
@@ -1641,7 +2044,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Optimize relay usage following applesauce-relay best practices
- Use applesauce-react event models for better profile handling
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.20...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.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.21]: https://github.com/dergigi/boris/compare/v0.6.20...v0.6.21
[0.6.20]: https://github.com/dergigi/boris/compare/v0.6.19...v0.6.20
[0.6.19]: https://github.com/dergigi/boris/compare/v0.6.18...v0.6.19
[0.6.18]: https://github.com/dergigi/boris/compare/v0.6.17...v0.6.18

298
api/article-og.ts Normal file
View File

@@ -0,0 +1,298 @@
import type { VercelRequest, VercelResponse } from '@vercel/node'
import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19'
import { NostrEvent, Filter } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
const { getArticleTitle, getArticleImage, getArticleSummary } = Helpers
// Relay configuration (from src/config/relays.ts)
const RELAYS = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.nostr.band',
'wss://relay.dergigi.com',
'wss://wot.dergigi.com',
'wss://relay.snort.social',
'wss://relay.current.fyi',
'wss://nostr-pub.wellorder.net',
'wss://purplepag.es',
'wss://relay.primal.net'
]
type CacheEntry = {
html: string
expires: number
}
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
const memoryCache = new Map<string, CacheEntry>()
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
function setCacheHeaders(res: VercelResponse, maxAge: number = 86400): void {
res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=604800`)
res.setHeader('Content-Type', 'text/html; charset=utf-8')
}
interface ArticleMetadata {
title: string
summary: string
image: string
author: string
published?: number
}
async function fetchEventsFromRelays(
relayPool: RelayPool,
relayUrls: string[],
filter: Filter,
timeoutMs: number
): Promise<NostrEvent[]> {
const events: NostrEvent[] = []
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => resolve(), timeoutMs)
// `request` emits NostrEvent objects directly
relayPool.request(relayUrls, filter).subscribe({
next: (event) => {
events.push(event)
},
error: () => resolve(),
complete: () => {
clearTimeout(timeout)
resolve()
}
})
})
// Sort by created_at and return most recent first
return events.sort((a, b) => b.created_at - a.created_at)
}
async function fetchArticleMetadata(naddr: string): Promise<ArticleMetadata | null> {
const relayPool = new RelayPool()
try {
// Decode naddr
const decoded = nip19.decode(naddr)
if (decoded.type !== 'naddr') {
return null
}
const pointer = decoded.data as AddressPointer
// Determine relay URLs
const relayUrls = pointer.relays && pointer.relays.length > 0 ? pointer.relays : RELAYS
// Fetch article and profile in parallel
const [articleEvents, profileEvents] = await Promise.all([
fetchEventsFromRelays(relayPool, relayUrls, {
kinds: [pointer.kind],
authors: [pointer.pubkey],
'#d': [pointer.identifier || '']
}, 5000),
fetchEventsFromRelays(relayPool, relayUrls, {
kinds: [0],
authors: [pointer.pubkey]
}, 3000)
])
if (articleEvents.length === 0) {
return null
}
const article = articleEvents[0]
// Extract article metadata
const title = getArticleTitle(article) || 'Untitled Article'
const summary = getArticleSummary(article) || 'Read this article on Boris'
const image = getArticleImage(article) || '/boris-social-1200.png'
// Extract author name from profile
let authorName = pointer.pubkey.slice(0, 8) + '...'
if (profileEvents.length > 0) {
try {
const profileData = JSON.parse(profileEvents[0].content)
authorName = profileData.display_name || profileData.name || authorName
} catch {
// Use fallback
}
}
return {
title,
summary,
image,
author: authorName,
published: article.created_at
}
} catch (err) {
console.error('Failed to fetch article metadata:', err)
return null
} finally {
// No explicit close needed; pool manages connections internally
}
}
function generateHtml(naddr: string, meta: ArticleMetadata | null): string {
const baseUrl = 'https://read.withboris.com'
const articleUrl = `${baseUrl}/a/${naddr}`
const title = meta?.title || 'Boris Nostr Bookmarks'
const description = meta?.summary || 'Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.'
const image = meta?.image?.startsWith('http') ? meta.image : `${baseUrl}${meta?.image || '/boris-social-1200.png'}`
const author = meta?.author || 'Boris'
return `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0f172a" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>${escapeHtml(title)}</title>
<meta name="description" content="${escapeHtml(description)}" />
<link rel="canonical" href="${articleUrl}" />
<!-- Open Graph / Social Media -->
<meta property="og:type" content="article" />
<meta property="og:url" content="${articleUrl}" />
<meta property="og:title" content="${escapeHtml(title)}" />
<meta property="og:description" content="${escapeHtml(description)}" />
<meta property="og:image" content="${escapeHtml(image)}" />
<meta property="og:site_name" content="Boris" />
${meta?.published ? `<meta property="article:published_time" content="${new Date(meta.published * 1000).toISOString()}" />` : ''}
<meta property="article:author" content="${escapeHtml(author)}" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="${articleUrl}" />
<meta name="twitter:title" content="${escapeHtml(title)}" />
<meta name="twitter:description" content="${escapeHtml(description)}" />
<meta name="twitter:image" content="${escapeHtml(image)}" />
</head>
<body>
<noscript>
<p>Redirecting to <a href="/">Boris</a>...</p>
</noscript>
</body>
</html>`
}
function isCrawler(userAgent: string | undefined): boolean {
if (!userAgent) return false
const crawlers = [
'bot', 'crawl', 'spider', 'slurp', 'facebook', 'twitter', 'linkedin',
'whatsapp', 'telegram', 'slack', 'discord', 'preview'
]
const ua = userAgent.toLowerCase()
return crawlers.some(crawler => ua.includes(crawler))
}
export default async function handler(req: VercelRequest, res: VercelResponse) {
const naddr = (req.query.naddr as string | undefined)?.trim()
if (!naddr) {
return res.status(400).json({ error: 'Missing naddr parameter' })
}
const userAgent = req.headers['user-agent'] as string | undefined
const isCrawlerRequest = isCrawler(userAgent)
const debugEnabled = req.query.debug === '1' || req.headers['x-boris-debug'] === '1'
if (debugEnabled) {
res.setHeader('X-Boris-Debug', '1')
}
// If it's a regular browser (not a bot), serve HTML that loads SPA
// Use history.replaceState to set the URL before the SPA boots
if (!isCrawlerRequest) {
const articlePath = `/a/${naddr}`
// Serve a minimal HTML that sets up the URL and loads the SPA
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Boris - Loading Article...</title>
<script>
// Set the URL to the article path before SPA loads
if (window.location.pathname !== '${articlePath}') {
history.replaceState(null, '', '${articlePath}');
}
</script>
${debugEnabled ? `<script>console.debug('article-og', { mode: 'browser', naddr: '${naddr}', path: location.pathname, referrer: document.referrer });</script>` : ''}
<script>
// Redirect to index.html which will load the SPA
// The history state is already set, so SPA will see the correct URL
window.location.replace('/');
</script>
</head>
<body>
<div id="root"></div>
</body>
</html>`
res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
if (debugEnabled) {
// Debug mode enabled
}
return res.status(200).send(html)
}
// Check cache for bots/crawlers
const now = Date.now()
const cached = memoryCache.get(naddr)
if (cached && cached.expires > now) {
setCacheHeaders(res)
if (debugEnabled) {
// Debug mode enabled
}
return res.status(200).send(cached.html)
}
try {
// Fetch metadata
const meta = await fetchArticleMetadata(naddr)
// Generate HTML
const html = generateHtml(naddr, meta)
// Cache the result
memoryCache.set(naddr, { html, expires: now + WEEK_MS })
// Send response
setCacheHeaders(res)
if (debugEnabled) {
// Debug mode enabled
}
return res.status(200).send(html)
} catch (err) {
console.error('Error generating article OG HTML:', err)
// Fallback to basic HTML with SPA boot
const html = generateHtml(naddr, null)
setCacheHeaders(res, 3600)
if (debugEnabled) {
// Debug mode enabled
}
return res.status(200).send(html)
}
}

View File

@@ -18,6 +18,7 @@
<meta property="og:url" content="https://read.withboris.com/" />
<meta property="og:title" content="Boris - Nostr Bookmarks" />
<meta property="og:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
<meta property="og:image" content="https://read.withboris.com/boris-social-1200.png" />
<meta property="og:site_name" content="Boris" />
<!-- Twitter Card -->
@@ -25,6 +26,7 @@
<meta name="twitter:url" content="https://read.withboris.com/" />
<meta name="twitter:title" content="Boris - Nostr Bookmarks" />
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
<meta name="twitter:image" content="https://read.withboris.com/boris-social-1200.png" />
<!-- Default to system theme until settings load from Nostr -->
<script>

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 819 KiB

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

@@ -1,19 +1,33 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
import { EventStoreProvider, AccountsProvider, Hooks } from 'applesauce-react'
import { EventStore } from 'applesauce-core'
import { AccountManager } from 'applesauce-accounts'
import { AccountManager, Accounts } from 'applesauce-accounts'
import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
import { RelayPool } from 'applesauce-relay'
import { NostrConnectSigner } from 'applesauce-signers'
import { getDefaultBunkerPermissions } from './services/nostrConnect'
import { createAddressLoader } from 'applesauce-loaders/loaders'
import Debug from './components/Debug'
import Bookmarks from './components/Bookmarks'
import RouteDebug from './components/RouteDebug'
import Toast from './components/Toast'
import { useToast } from './hooks/useToast'
import { useOnlineStatus } from './hooks/useOnlineStatus'
import { RELAYS } from './config/relays'
import { SkeletonThemeProvider } from './components/Skeletons'
import { DebugBus } from './utils/debugBus'
import { Bookmark } from './types/bookmarks'
import { bookmarkController } from './services/bookmarkController'
import { contactsController } from './services/contactsController'
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 ||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
@@ -21,15 +35,116 @@ const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
// AppRoutes component that has access to hooks
function AppRoutes({
relayPool,
eventStore,
showToast
}: {
relayPool: RelayPool
eventStore: EventStore | null
showToast: (message: string) => void
}) {
const accountManager = Hooks.useAccountManager()
const activeAccount = Hooks.useActiveAccount()
// Centralized bookmark state (fed by controller)
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [bookmarksLoading, setBookmarksLoading] = useState(false)
// Centralized contacts state (fed by controller)
const [contacts, setContacts] = useState<Set<string>>(new Set())
const [contactsLoading, setContactsLoading] = useState(false)
// Subscribe to bookmark controller
useEffect(() => {
const unsubBookmarks = bookmarkController.onBookmarks((bookmarks) => {
setBookmarks(bookmarks)
})
const unsubLoading = bookmarkController.onLoading((loading) => {
setBookmarksLoading(loading)
})
return () => {
unsubBookmarks()
unsubLoading()
}
}, [])
// Subscribe to contacts controller
useEffect(() => {
const unsubContacts = contactsController.onContacts((contacts) => {
setContacts(contacts)
})
const unsubLoading = contactsController.onLoading((loading) => {
setContactsLoading(loading)
})
return () => {
unsubContacts()
unsubLoading()
}
}, [])
// Auto-load bookmarks, contacts, and highlights when account is ready (on login or page mount)
useEffect(() => {
if (activeAccount && relayPool) {
const pubkey = (activeAccount as { pubkey?: string }).pubkey
// Load bookmarks
if (bookmarks.length === 0 && !bookmarksLoading) {
bookmarkController.start({ relayPool, activeAccount, accountManager })
}
// Load contacts
if (pubkey && contacts.size === 0 && !contactsLoading) {
contactsController.start({ relayPool, pubkey })
}
// Load highlights (controller manages its own state)
if (pubkey && eventStore && !highlightsController.isLoadedFor(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])
// 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)
const handleRefreshBookmarks = useCallback(async () => {
if (!relayPool || !activeAccount) {
return
}
bookmarkController.reset()
await bookmarkController.start({ relayPool, activeAccount, accountManager })
}, [relayPool, activeAccount, accountManager])
const handleLogout = () => {
accountManager.clearActive()
bookmarkController.reset() // Clear bookmarks via controller
contactsController.reset() // Clear contacts via controller
highlightsController.reset() // Clear highlights via controller
readingProgressController.reset() // Clear reading progress via controller
showToast('Logged out successfully')
}
@@ -41,6 +156,9 @@ function AppRoutes({
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
/>
}
/>
@@ -50,6 +168,9 @@ function AppRoutes({
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
/>
}
/>
@@ -59,6 +180,9 @@ function AppRoutes({
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
/>
}
/>
@@ -68,6 +192,9 @@ function AppRoutes({
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
/>
}
/>
@@ -77,6 +204,9 @@ function AppRoutes({
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
/>
}
/>
@@ -86,6 +216,9 @@ function AppRoutes({
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
/>
}
/>
@@ -99,6 +232,9 @@ function AppRoutes({
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
/>
}
/>
@@ -108,15 +244,45 @@ function AppRoutes({
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
/>
}
/>
<Route
path="/me/archive"
path="/me/reads"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
/>
}
/>
<Route
path="/me/reads/:filter"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
/>
}
/>
<Route
path="/me/links"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
/>
}
/>
@@ -126,6 +292,9 @@ function AppRoutes({
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
/>
}
/>
@@ -135,6 +304,9 @@ function AppRoutes({
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
/>
}
/>
@@ -144,6 +316,22 @@ function AppRoutes({
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
/>
}
/>
<Route
path="/debug"
element={
<Debug
relayPool={relayPool}
eventStore={eventStore}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
onLogout={handleLogout}
/>
}
/>
@@ -165,23 +353,59 @@ function App() {
const store = new EventStore()
const accounts = new AccountManager()
// Disable request queueing globally - makes all operations instant
// Queue causes requests to wait for user interaction which blocks batch operations
accounts.disableQueue = true
// Register common account types (needed for deserialization)
registerCommonAccountTypes(accounts)
// Create relay pool and set it up BEFORE loading accounts
// NostrConnectAccount.fromJSON needs this to restore the signer
const pool = new RelayPool()
// Wire the signer to use this pool; make publish non-blocking so callers don't
// wait for every relay send to finish. Responses still resolve the pending request.
NostrConnectSigner.subscriptionMethod = pool.subscription.bind(pool)
NostrConnectSigner.publishMethod = (relays: string[], event: unknown) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result: any = pool.publish(relays, event as any)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (result && typeof (result as any).subscribe === 'function') {
// Subscribe to the observable but ignore completion/errors (fire-and-forget)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
try { (result as any).subscribe({ complete: () => { /* noop */ }, error: () => { /* noop */ } }) } catch { /* ignore */ }
}
// Return an already-resolved promise so upstream await finishes immediately
return Promise.resolve()
}
// Create a relay group for better event deduplication and management
pool.group(RELAYS)
// Load persisted accounts from localStorage
try {
const json = JSON.parse(localStorage.getItem('accounts') || '[]')
const accountsJson = localStorage.getItem('accounts')
const json = JSON.parse(accountsJson || '[]')
await accounts.fromJSON(json)
console.log('Loaded', accounts.accounts.length, 'accounts from storage')
// Load active account from storage
const activeId = localStorage.getItem('active')
if (activeId && accounts.getAccount(activeId)) {
accounts.setActive(activeId)
console.log('Restored active account:', activeId)
if (activeId) {
const account = accounts.getAccount(activeId)
if (account) {
accounts.setActive(activeId)
} else {
console.warn('[bunker] ⚠️ Active ID found but account not in list')
}
} else {
// No active account ID in localStorage
}
} catch (err) {
console.error('Failed to load accounts from storage:', err)
console.error('[bunker] ❌ Failed to load accounts from storage:', err)
}
// Subscribe to accounts changes and persist to localStorage
@@ -198,12 +422,166 @@ function App() {
}
})
const pool = new RelayPool()
// Reconnect bunker signers when active account changes
// Keep track of which accounts we've already reconnected to avoid double-connecting
const reconnectedAccounts = new Set<string>()
// Create a relay group for better event deduplication and management
pool.group(RELAYS)
console.log('Created relay group with', RELAYS.length, 'relays (including local)')
console.log('Relay URLs:', RELAYS)
const bunkerReconnectSub = accounts.active$.subscribe(async (account) => {
if (account && account.type === 'nostr-connect') {
const nostrConnectAccount = account as Accounts.NostrConnectAccount<unknown>
// Disable applesauce account queueing so decrypt requests aren't serialized behind earlier ops
try {
if (!(nostrConnectAccount as unknown as { disableQueue?: boolean }).disableQueue) {
(nostrConnectAccount as unknown as { disableQueue?: boolean }).disableQueue = true
}
} catch (err) {
// Ignore queue disable errors
}
// Note: for Amber bunker, the remote signer pubkey is the user's pubkey. This is expected.
// Skip if we've already reconnected this account
if (reconnectedAccounts.has(account.id)) {
return
}
try {
// For restored signers, ensure they have the pool's subscription methods
// The signer was created in fromJSON without pool context, so we need to recreate it
const signerData = nostrConnectAccount.toJSON().signer
// Add bunker's relays to the pool BEFORE recreating the signer
// This ensures the pool has all relays when the signer sets up its methods
const bunkerRelays = signerData.relays || []
const existingRelayUrls = new Set(Array.from(pool.relays.keys()))
const newBunkerRelays = bunkerRelays.filter(url => !existingRelayUrls.has(url))
if (newBunkerRelays.length > 0) {
pool.group(newBunkerRelays)
} else {
// Bunker relays already in pool
}
const recreatedSigner = new NostrConnectSigner({
relays: signerData.relays,
pubkey: nostrConnectAccount.pubkey,
remote: signerData.remote,
signer: nostrConnectAccount.signer.signer, // Use the existing SimpleSigner
pool: pool
})
// Ensure local relays are included for NIP-46 request/response traffic (e.g., Amber bunker)
try {
const mergedRelays = Array.from(new Set([...(signerData.relays || []), ...RELAYS]))
recreatedSigner.relays = mergedRelays
} catch (err) { console.warn('[bunker] failed to merge signer relays', err) }
// Replace the signer on the account
nostrConnectAccount.signer = recreatedSigner
// Debug: log publish/subscription calls made by signer (decrypt/sign requests)
// IMPORTANT: bind originals to preserve `this` context used internally by the signer
const originalPublish = (recreatedSigner as unknown as { publishMethod: (relays: string[], event: unknown) => unknown }).publishMethod.bind(recreatedSigner)
;(recreatedSigner as unknown as { publishMethod: (relays: string[], event: unknown) => unknown }).publishMethod = (relays: string[], event: unknown) => {
try {
let method: string | undefined
const content = (event as { content?: unknown })?.content
if (typeof content === 'string') {
try {
const parsed = JSON.parse(content) as { method?: string; id?: unknown }
method = parsed?.method
} catch (err) { console.warn('[bunker] failed to parse event content', err) }
}
const summary = {
relays,
kind: (event as { kind?: number })?.kind,
method,
// include tags array for debugging (NIP-46 expects method tag)
tags: (event as { tags?: unknown })?.tags,
contentLength: typeof content === 'string' ? content.length : undefined
}
try { DebugBus.info('bunker', 'publish', summary) } catch (err) { console.warn('[bunker] failed to log to DebugBus', err) }
} catch (err) { console.warn('[bunker] failed to log publish summary', err) }
// Fire-and-forget publish: trigger the publish but do not return the
// Observable/Promise to upstream to avoid their awaiting of completion.
const result = originalPublish(relays, event)
if (result && typeof (result as { subscribe?: unknown }).subscribe === 'function') {
// Subscribe to the observable but ignore completion/errors (fire-and-forget)
try { (result as { subscribe: (h: { complete?: () => void; error?: (e: unknown) => void }) => unknown }).subscribe({ complete: () => { /* noop */ }, error: () => { /* noop */ } }) } catch { /* ignore */ }
}
// If it's a Promise, simply ignore it (no await) so it resolves in the background.
// Return a benign object so callers that probe for a "subscribe" property
// (e.g., applesauce makeRequest) won't throw on `"subscribe" in result`.
return {} as unknown as never
}
const originalSubscribe = (recreatedSigner as unknown as { subscriptionMethod: (relays: string[], filters: unknown[]) => unknown }).subscriptionMethod.bind(recreatedSigner)
;(recreatedSigner as unknown as { subscriptionMethod: (relays: string[], filters: unknown[]) => unknown }).subscriptionMethod = (relays: string[], filters: unknown[]) => {
try {
try { DebugBus.info('bunker', 'subscribe', { relays, filters }) } catch (err) { console.warn('[bunker] failed to log subscribe to DebugBus', err) }
} catch (err) { console.warn('[bunker] failed to log subscribe summary', err) }
return originalSubscribe(relays, filters)
}
// Just ensure the signer is listening for responses - don't call connect() again
// The fromBunkerURI already connected with permissions during login
if (!nostrConnectAccount.signer.listening) {
await nostrConnectAccount.signer.open()
} else {
// Signer already listening
}
// Attempt a guarded reconnect to ensure Amber authorizes decrypt operations
try {
if (nostrConnectAccount.signer.remote && !reconnectedAccounts.has(account.id)) {
const permissions = getDefaultBunkerPermissions()
await nostrConnectAccount.signer.connect(undefined, permissions)
}
} catch (e) {
console.warn('[bunker] ⚠️ Guarded connect() failed:', e)
}
// Give the subscription a moment to fully establish before allowing decrypt operations
// This ensures the signer is ready to handle and receive responses
await new Promise(resolve => setTimeout(resolve, 100))
// Fire-and-forget: probe decrypt path to verify Amber responds to NIP-46 decrypt
try {
const withTimeout = async <T,>(p: Promise<T>, ms = 10000): Promise<T> => {
return await Promise.race([
p,
new Promise<T>((_, rej) => setTimeout(() => rej(new Error(`probe timeout after ${ms}ms`)), ms)),
])
}
setTimeout(async () => {
const self = nostrConnectAccount.pubkey
// Try a roundtrip so the bunker can respond successfully
try {
await withTimeout(nostrConnectAccount.signer.nip44!.encrypt(self, 'probe-nip44'))
await withTimeout(nostrConnectAccount.signer.nip44!.decrypt(self, ''))
} catch (_err) {
// Ignore probe errors
}
try {
await withTimeout(nostrConnectAccount.signer.nip04!.encrypt(self, 'probe-nip04'))
await withTimeout(nostrConnectAccount.signer.nip04!.decrypt(self, ''))
} catch (_err) {
// Ignore probe errors
}
}, 0)
} catch (_err) {
// Ignore signer setup errors
}
// The bunker remembers the permissions from the initial connection
nostrConnectAccount.signer.isConnected = true
// Mark this account as reconnected
reconnectedAccounts.add(account.id)
} catch (error) {
console.error('[bunker] ❌ Failed to open signer:', error)
}
}
})
// Keep all relay connections alive indefinitely by creating a persistent subscription
// This prevents disconnection when no other subscriptions are active
@@ -212,7 +590,6 @@ function App() {
next: () => {}, // No-op, we don't care about events
error: (err) => console.warn('Keep-alive subscription error:', err)
})
console.log('🔗 Created keep-alive subscription for', RELAYS.length, 'relay(s)')
// Store subscription for cleanup
;(pool as unknown as { _keepAliveSubscription: typeof keepAliveSub })._keepAliveSubscription = keepAliveSub
@@ -233,6 +610,7 @@ function App() {
return () => {
accountsSub.unsubscribe()
activeSub.unsubscribe()
bunkerReconnectSub.unsubscribe()
// Clean up keep-alive subscription if it exists
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
if (poolWithSub._keepAliveSubscription) {
@@ -249,7 +627,7 @@ function App() {
return () => {
if (cleanup) cleanup()
}
}, [])
}, [isOnline, showToast])
// Monitor online/offline status
useEffect(() => {
@@ -284,7 +662,8 @@ function App() {
<AccountsProvider manager={accountManager}>
<BrowserRouter>
<div className="min-h-screen p-0 max-w-none m-0 relative">
<AppRoutes relayPool={relayPool} showToast={showToast} />
<AppRoutes relayPool={relayPool} eventStore={eventStore} showToast={showToast} />
<RouteDebug />
</div>
</BrowserRouter>
{toastMessage && (

View File

@@ -1,7 +1,6 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBookOpen, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
import { faBookmark } from '@fortawesome/free-regular-svg-icons'
import { faBookOpen, faBookmark, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
import { faBooks } from '../icons/customIcons'
export type ArchiveFilterType = 'all' | 'to-read' | 'reading' | 'completed' | 'marked'
@@ -22,17 +21,24 @@ const ArchiveFilters: React.FC<ArchiveFiltersProps> = ({ selectedFilter, onFilte
return (
<div className="bookmark-filters">
{filters.map(filter => (
<button
key={filter.type}
onClick={() => onFilterChange(filter.type)}
className={`filter-btn ${selectedFilter === filter.type ? 'active' : ''}`}
title={filter.label}
aria-label={`Filter by ${filter.label}`}
>
<FontAwesomeIcon icon={filter.icon} />
</button>
))}
{filters.map(filter => {
const isActive = selectedFilter === filter.type
// Only "completed" gets green color, everything else uses default blue
const activeStyle = isActive && filter.type === 'completed' ? { color: '#10b981' } : undefined
return (
<button
key={filter.type}
onClick={() => onFilterChange(filter.type)}
className={`filter-btn ${isActive ? 'active' : ''}`}
title={filter.label}
aria-label={`Filter by ${filter.label}`}
style={activeStyle}
>
<FontAwesomeIcon icon={filter.icon} />
</button>
)
})}
</div>
)
}

View File

@@ -24,9 +24,20 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
addSuffix: true
})
// Calculate progress percentage and determine color
// Calculate progress percentage and determine color (matching readingProgressUtils.ts logic)
const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0
const progressColor = progressPercent >= 95 ? '#10b981' : '#6366f1' // green if >=95%, blue otherwise
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)
}
// Debug log - reading progress shown as visual indicator
if (readingProgress !== undefined) {
// Reading progress display
}
return (
<Link

View File

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

View File

@@ -1,7 +1,7 @@
import React, { useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate, faHeart, faPlus } from '@fortawesome/free-solid-svg-icons'
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate, faHeart, faPlus, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons'
import { formatDistanceToNow } from 'date-fns'
import { RelayPool } from 'applesauce-relay'
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
@@ -13,7 +13,7 @@ import { ViewMode } from './Bookmarks'
import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator'
import { BookmarkSkeleton } from './Skeletons'
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet } from '../utils/bookmarkUtils'
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet, hasCreationDate } from '../utils/bookmarkUtils'
import { UserSettings } from '../services/settingsService'
import AddBookmarkModal from './AddBookmarkModal'
import { createWebBookmark } from '../services/webBookmarkService'
@@ -21,6 +21,11 @@ import { RELAYS } from '../config/relays'
import { Hooks } from 'applesauce-react'
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
import LoginOptions from './LoginOptions'
import { useEffect } from 'react'
import { readingProgressController } from '../services/readingProgressController'
import { nip19 } from 'nostr-tools'
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
interface BookmarkListProps {
bookmarks: Bookmark[]
@@ -64,7 +69,56 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
const friendsColor = settings?.highlightColorFriends || '#f97316'
const [showAddModal, setShowAddModal] = useState(false)
const [selectedFilter, setSelectedFilter] = useState<BookmarkFilterType>('all')
const [groupingMode, setGroupingMode] = useState<'grouped' | 'flat'>(() => {
const saved = localStorage.getItem('bookmarkGroupingMode')
return saved === 'flat' ? 'flat' : 'grouped'
})
const activeAccount = Hooks.useActiveAccount()
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
// Subscribe to reading progress updates
useEffect(() => {
// Get initial progress map
setReadingProgressMap(readingProgressController.getProgressMap())
// Subscribe to updates
const unsubProgress = readingProgressController.onProgress(setReadingProgressMap)
return () => {
unsubProgress()
}
}, [])
// Helper to get reading progress for a bookmark
const getBookmarkReadingProgress = (bookmark: IndividualBookmark): number | undefined => {
if (bookmark.kind === 30023) {
// For articles, use naddr as key
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
if (!dTag) return undefined
try {
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: bookmark.pubkey,
identifier: dTag
})
return readingProgressMap.get(naddr)
} catch (err) {
return undefined
}
}
// For web bookmarks and other types, try to use URL if available
const urls = extractUrlsFromContent(bookmark.content)
if (urls.length > 0) {
return readingProgressMap.get(urls[0])
}
return undefined
}
const toggleGroupingMode = () => {
const newMode = groupingMode === 'grouped' ? 'flat' : 'grouped'
setGroupingMode(newMode)
localStorage.setItem('bookmarkGroupingMode', newMode)
}
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
if (!activeAccount || !relayPool) {
@@ -89,6 +143,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
// Merge and flatten all individual bookmarks from all lists
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
.filter(hasContent)
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b))
// Apply filter
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
@@ -97,14 +152,18 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
const bookmarkSets = getBookmarkSets(filteredBookmarks)
// Group non-set bookmarks as before
// Group non-set bookmarks by source or flatten based on mode
const groups = groupIndividualBookmarks(bookmarksWithoutSet)
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
{ key: 'private', title: 'Private Bookmarks', items: groups.privateItems },
{ key: 'public', title: 'Public Bookmarks', items: groups.publicItems },
{ key: 'web', title: 'Web Bookmarks', items: groups.web },
{ key: 'amethyst', title: 'Legacy Bookmarks', items: groups.amethyst }
]
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
groupingMode === 'flat'
? [{ key: 'all', title: `All Bookmarks (${bookmarksWithoutSet.length})`, items: bookmarksWithoutSet }]
: [
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
{ key: 'amethyst-private', title: 'Private Lists', items: groups.amethystPrivate },
{ key: 'amethyst-public', title: 'My Lists', items: groups.amethystPublic },
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb }
]
// Add bookmark sets as additional sections
bookmarkSets.forEach(set => {
@@ -153,7 +212,9 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
/>
)}
{filteredBookmarks.length === 0 && allIndividualBookmarks.length > 0 ? (
{!activeAccount ? (
<LoginOptions />
) : filteredBookmarks.length === 0 && allIndividualBookmarks.length > 0 ? (
<div className="empty-state">
<p>No bookmarks match this filter.</p>
</div>
@@ -170,7 +231,6 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
<div className="empty-state">
<p>No bookmarks found.</p>
<p>Add bookmarks using your nostr client to see them here.</p>
<p>If you aren't on nostr yet, start here: <a href="https://nstart.me/" target="_blank" rel="noopener noreferrer">nstart.me</a></p>
</div>
)
) : (
@@ -204,6 +264,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
index={index}
onSelectUrl={onSelectUrl}
viewMode={viewMode}
readingProgress={getBookmarkReadingProgress(individualBookmark)}
/>
))}
</div>
@@ -222,40 +283,49 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
style={{ color: friendsColor }}
/>
</div>
<div className="view-mode-right">
{onRefresh && (
{activeAccount && (
<div className="view-mode-right">
{onRefresh && (
<IconButton
icon={faRotate}
onClick={onRefresh}
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'}
ariaLabel="Refresh bookmarks"
variant="ghost"
disabled={isRefreshing}
spin={isRefreshing}
/>
)}
<IconButton
icon={faRotate}
onClick={onRefresh}
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'}
ariaLabel="Refresh bookmarks"
icon={groupingMode === 'grouped' ? faLayerGroup : faBars}
onClick={toggleGroupingMode}
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
variant="ghost"
disabled={isRefreshing}
spin={isRefreshing}
/>
)}
<IconButton
icon={faList}
onClick={() => onViewModeChange('compact')}
title="Compact list view"
ariaLabel="Compact list view"
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faThLarge}
onClick={() => onViewModeChange('cards')}
title="Cards view"
ariaLabel="Cards view"
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faImage}
onClick={() => onViewModeChange('large')}
title="Large preview view"
ariaLabel="Large preview view"
variant={viewMode === 'large' ? 'primary' : 'ghost'}
/>
</div>
<IconButton
icon={faList}
onClick={() => onViewModeChange('compact')}
title="Compact list view"
ariaLabel="Compact list view"
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faThLarge}
onClick={() => onViewModeChange('cards')}
title="Cards view"
ariaLabel="Cards view"
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faImage}
onClick={() => onViewModeChange('large')}
title="Large preview view"
ariaLabel="Large preview view"
variant={viewMode === 'large' ? 'primary' : 'ghost'}
/>
</div>
)}
</div>
{showAddModal && (
<AddBookmarkModal

View File

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

View File

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

View File

@@ -23,6 +23,7 @@ interface LargeViewProps {
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
articleSummary?: string
contentTypeIcon: IconDefinition
readingProgress?: number // 0-1 reading progress (optional)
}
export const LargeView: React.FC<LargeViewProps> = ({
@@ -38,11 +39,22 @@ export const LargeView: React.FC<LargeViewProps> = ({
getAuthorDisplayName,
handleReadNow,
articleSummary,
contentTypeIcon
contentTypeIcon,
readingProgress
}) => {
const cachedImage = useImageCache(previewImage || undefined)
const isArticle = bookmark.kind === 30023
// Calculate progress display (matching readingProgressUtils.ts logic)
const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0
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 triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -92,6 +104,28 @@ export const LargeView: React.FC<LargeViewProps> = ({
</div>
)}
{/* Reading progress indicator for articles - shown only if there's progress */}
{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: `${progressPercent}%`,
background: progressColor,
transition: 'width 0.3s ease, background 0.3s ease'
}}
/>
</div>
)}
<div className="large-footer">
<span className="bookmark-type-large">
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />

View File

@@ -13,9 +13,11 @@ import { useHighlightCreation } from '../hooks/useHighlightCreation'
import { useBookmarksUI } from '../hooks/useBookmarksUI'
import { useRelayStatus } from '../hooks/useRelayStatus'
import { useOfflineSync } from '../hooks/useOfflineSync'
import { Bookmark } from '../types/bookmarks'
import ThreePaneLayout from './ThreePaneLayout'
import Explore from './Explore'
import Me from './Me'
import Profile from './Profile'
import Support from './Support'
import { classifyHighlights } from '../utils/highlightClassification'
@@ -24,9 +26,18 @@ export type ViewMode = 'compact' | 'cards' | 'large'
interface BookmarksProps {
relayPool: RelayPool | null
onLogout: () => void
bookmarks: Bookmark[]
bookmarksLoading: boolean
onRefreshBookmarks: () => Promise<void>
}
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
const Bookmarks: React.FC<BookmarksProps> = ({
relayPool,
onLogout,
bookmarks,
bookmarksLoading,
onRefreshBookmarks
}) => {
const { naddr, npub } = useParams<{ naddr?: string; npub?: string }>()
const location = useLocation()
const navigate = useNavigate()
@@ -52,7 +63,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
const meTab = location.pathname === '/me' ? 'highlights' :
location.pathname === '/me/highlights' ? 'highlights' :
location.pathname === '/me/reading-list' ? 'reading-list' :
location.pathname === '/me/archive' ? 'archive' :
location.pathname.startsWith('/me/reads') ? 'reads' :
location.pathname === '/me/links' ? 'links' :
location.pathname === '/me/writings' ? 'writings' : 'highlights'
// Extract tab from profile routes
@@ -151,8 +163,6 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
}, [navigationState, setIsHighlightsCollapsed, setSelectedHighlightId, navigate, location.pathname])
const {
bookmarks,
bookmarksLoading,
highlights,
setHighlights,
highlightsLoading,
@@ -165,12 +175,13 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
} = useBookmarksData({
relayPool,
activeAccount,
accountManager,
naddr,
externalUrl,
currentArticleCoordinate,
currentArticleEventId,
settings
settings,
eventStore,
onRefreshBookmarks
})
const {
@@ -233,6 +244,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
useExternalUrlLoader({
url: externalUrl,
relayPool,
eventStore,
setSelectedUrl,
setReaderContent,
setReaderLoading,
@@ -316,10 +328,10 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
) : undefined}
me={showMe ? (
relayPool ? <Me relayPool={relayPool} activeTab={meTab} /> : null
relayPool ? <Me relayPool={relayPool} eventStore={eventStore} activeTab={meTab} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} /> : null
) : undefined}
profile={showProfile && profilePubkey ? (
relayPool ? <Me relayPool={relayPool} activeTab={profileTab} pubkey={profilePubkey} /> : null
relayPool ? <Profile relayPool={relayPool} eventStore={eventStore} pubkey={profilePubkey} activeTab={profileTab} /> : null
) : undefined}
support={showSupport ? (
relayPool ? <Support relayPool={relayPool} eventStore={eventStore} settings={settings} /> : null

View File

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

1480
src/components/Debug.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,20 @@
import React, { useState, useEffect, useMemo } from 'react'
import React, { useState, useEffect, useMemo, useCallback } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate, faSpinner } from '@fortawesome/free-solid-svg-icons'
import IconButton from './IconButton'
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
import { Hooks } from 'applesauce-react'
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { nip19 } from 'nostr-tools'
import { IEventStore, Helpers } from 'applesauce-core'
import { nip19, NostrEvent } from 'nostr-tools'
import { useNavigate } from 'react-router-dom'
import { fetchContacts } from '../services/contactService'
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
import { fetchHighlightsFromAuthors } from '../services/highlightService'
import { fetchProfiles } from '../services/profileService'
import { fetchNostrverseBlogPosts, fetchNostrverseHighlights } from '../services/nostrverseService'
import { nostrverseHighlightsController } from '../services/nostrverseHighlightsController'
import { highlightsController } from '../services/highlightsController'
import { Highlight } from '../types/highlights'
import { UserSettings } from '../services/settingsService'
import BlogPostCard from './BlogPostCard'
@@ -22,6 +24,15 @@ import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator'
import { classifyHighlights } from '../utils/highlightClassification'
import { HighlightVisibility } from './HighlightsPanel'
import { KINDS } from '../config/kinds'
import { eventToHighlight } from '../services/highlightEventProcessor'
import { useStoreTimeline } from '../hooks/useStoreTimeline'
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
interface ExploreProps {
relayPool: RelayPool
@@ -41,14 +52,177 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
const [loading, setLoading] = useState(true)
const [refreshTrigger, setRefreshTrigger] = useState(0)
const [hasLoadedNostrverse, setHasLoadedNostrverse] = useState(false)
const [hasLoadedMine, setHasLoadedMine] = useState(false)
const [hasLoadedNostrverseHighlights, setHasLoadedNostrverseHighlights] = useState(false)
// Visibility filters (defaults from settings, or friends only)
// Get myHighlights directly from controller
const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
// 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)
const cachedHighlights = useStoreTimeline(eventStore, { kinds: [KINDS.Highlights] }, eventToHighlight, [])
const toBlogPostPreview = useCallback((event: NostrEvent): BlogPostPreview => ({
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
}), [])
const cachedWritings = useStoreTimeline(eventStore, { kinds: [30023] }, toBlogPostPreview, [])
// Visibility filters (defaults from settings or nostrverse when logged out)
const [visibility, setVisibility] = useState<HighlightVisibility>({
nostrverse: settings?.defaultHighlightVisibilityNostrverse ?? false,
friends: settings?.defaultHighlightVisibilityFriends ?? true,
mine: settings?.defaultHighlightVisibilityMine ?? false
nostrverse: activeAccount ? (settings?.defaultExploreScopeNostrverse ?? false) : true,
friends: settings?.defaultExploreScopeFriends ?? true,
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
useEffect(() => {
const unsubHighlights = highlightsController.onHighlights(setMyHighlights)
return () => {
unsubHighlights()
}
}, [])
// 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
useEffect(() => {
if (propActiveTab) {
@@ -58,30 +232,54 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
useEffect(() => {
const loadData = async () => {
if (!activeAccount) {
setLoading(false)
return
}
try {
// show spinner but keep existing data
// begin load, but do not block rendering
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
// Use functional update to check current state without creating dependency
const cachedPosts = getCachedPosts(activeAccount.pubkey)
if (cachedPosts && cachedPosts.length > 0) {
setBlogPosts(prev => prev.length === 0 ? cachedPosts : prev)
const memoryCachedPosts = activeAccount ? getCachedPosts(activeAccount.pubkey) : []
if (memoryCachedPosts && memoryCachedPosts.length > 0) {
setBlogPosts(prev => prev.length === 0 ? memoryCachedPosts : prev)
}
const cachedHighlights = getCachedHighlights(activeAccount.pubkey)
if (cachedHighlights && cachedHighlights.length > 0) {
setHighlights(prev => prev.length === 0 ? cachedHighlights : prev)
const memoryCachedHighlights = activeAccount ? getCachedHighlights(activeAccount.pubkey) : []
if (memoryCachedHighlights && memoryCachedHighlights.length > 0) {
setHighlights(prev => prev.length === 0 ? memoryCachedHighlights : prev)
}
// Seed with cached content from event store (instant display)
if (cachedHighlights.length > 0 || myHighlights.length > 0) {
const merged = dedupeHighlightsById([...cachedHighlights, ...myHighlights])
setHighlights(prev => {
const all = dedupeHighlightsById([...prev, ...merged])
return all.sort((a, b) => b.created_at - a.created_at)
})
}
// Seed with cached writings from event store
if (cachedWritings.length > 0) {
setBlogPosts(prev => {
const all = dedupeWritingsByReplaceable([...prev, ...cachedWritings])
return all.sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
})
}
// At this point, we have seeded any available data; lift the loading state
setLoading(false)
// Fetch the user's contacts (friends)
const contacts = await fetchContacts(
relayPool,
activeAccount.pubkey,
activeAccount?.pubkey || '',
(partial) => {
// Store followed pubkeys for highlight classification
setFollowedPubkeys(partial)
@@ -97,8 +295,31 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
relayUrls,
(post) => {
setBlogPosts((prev) => {
const exists = prev.some(p => p.event.id === post.event.id)
if (exists) return prev
// Deduplicate by author:d-tag (replaceable event key)
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 exists, only replace if this one is newer
if (existingIndex >= 0) {
const existing = prev[existingIndex]
if (post.event.created_at <= existing.event.created_at) {
return prev // Keep existing (newer or same)
}
// Replace with newer version
const next = [...prev]
next[existingIndex] = post
return next.sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
}
// New post, add it
const next = [...prev, post]
return next.sort((a, b) => {
const timeA = a.published || a.event.created_at
@@ -106,18 +327,36 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
return timeB - timeA
})
})
setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post))
if (activeAccount) setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post))
}
).then((all) => {
setBlogPosts((prev) => {
const byId = new Map(prev.map(p => [p.event.id, p]))
for (const post of all) byId.set(post.event.id, post)
const merged = Array.from(byId.values()).sort((a, b) => {
// Deduplicate by author:d-tag (replaceable event key)
const byKey = new Map<string, BlogPostPreview>()
// Add existing posts
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)
}
// Merge in new posts (keeping newer versions)
for (const post of all) {
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${post.author}:${dTag}`
const existing = byKey.get(key)
if (!existing || post.event.created_at > existing.event.created_at) {
byKey.set(key, post)
}
}
const merged = Array.from(byKey.values()).sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
setCachedPosts(activeAccount.pubkey, merged)
if (activeAccount) setCachedPosts(activeAccount.pubkey, merged)
return merged
})
})
@@ -133,14 +372,14 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
const next = [...prev, highlight]
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) => {
setHighlights((prev) => {
const byId = new Map(prev.map(h => [h.id, h]))
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)
setCachedHighlights(activeAccount.pubkey, merged)
if (activeAccount) setCachedHighlights(activeAccount.pubkey, merged)
return merged
})
})
@@ -154,65 +393,144 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
// Store final followed pubkeys
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 contactsArray = Array.from(contacts)
const [friendsPosts, friendsHighlights, nostrversePosts, nostriverseHighlights] = await Promise.all([
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls),
fetchHighlightsFromAuthors(relayPool, contactsArray),
fetchNostrverseBlogPosts(relayPool, relayUrls, 50),
fetchNostrverseHighlights(relayPool, 100)
])
// Use centralized writingsController for my posts (non-blocking)
// pull from writingsController; no need to store promise
setBlogPosts(prev => dedupeWritingsByReplaceable([...prev, ...writingsController.getWritings()]).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)))
setHasLoadedMine(true)
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
const allPosts = [...friendsPosts, ...nostrversePosts]
const postsByKey = new Map<string, BlogPostPreview>()
for (const post of allPosts) {
const key = `${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1] || ''}`
const existing = postsByKey.get(key)
if (!existing || post.event.created_at > existing.event.created_at) {
postsByKey.set(key, post)
}
}
const uniquePosts = Array.from(postsByKey.values()).sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
// Fire non-blocking fetches and merge as they resolve
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls)
.then((friendsPosts) => {
setBlogPosts(prev => {
const merged = dedupeWritingsByReplaceable([...prev, ...friendsPosts])
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
const allHighlights = [...friendsHighlights, ...nostriverseHighlights]
const highlightsByKey = new Map<string, Highlight>()
for (const highlight of allHighlights) {
highlightsByKey.set(highlight.id, highlight)
}
const uniqueHighlights = Array.from(highlightsByKey.values()).sort((a, b) => b.created_at - a.created_at)
fetchHighlightsFromAuthors(relayPool, contactsArray)
.then((friendsHighlights) => {
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
if (uniquePosts.length > 0) {
const authorPubkeys = Array.from(new Set(uniquePosts.map(p => p.author)))
fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(err => {
console.error('Failed to fetch author profiles:', err)
})
}
nostrversePostsPromise.then((nostrversePosts) => {
setBlogPosts(prev => dedupeWritingsByReplaceable([...prev, ...nostrversePosts]).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)))
}).catch(() => {})
// No blocking errors - let empty states handle messaging
setBlogPosts(uniquePosts)
setCachedPosts(activeAccount.pubkey, uniquePosts)
setHighlights(uniqueHighlights)
setCachedHighlights(activeAccount.pubkey, uniqueHighlights)
fetchNostrverseHighlights(relayPool, 100, eventStore || undefined)
.then((nostriverseHighlights) => {
setHighlights(prev => dedupeHighlightsById([...prev, ...nostriverseHighlights]).sort((a, b) => b.created_at - a.created_at))
}).catch(() => {})
} catch (err) {
console.error('Failed to load data:', err)
// No blocking error - user can pull-to-refresh
} finally {
setLoading(false)
// loading is already turned off after seeding
}
}
loadData()
// 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
const { isRefreshing, pullPosition } = usePullToRefresh({
onRefresh: () => {
@@ -249,10 +567,20 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
})
}, [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
const filteredBlogPosts = useMemo(() => {
const maxFutureTime = Date.now() / 1000 + (24 * 60 * 60) // 1 day from now
return blogPosts
return uniqueSortedPosts
.filter(post => {
// Filter out future dates
const publishedTime = post.published || post.event.created_at
@@ -276,7 +604,29 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
const level: 'mine' | 'friends' | 'nostrverse' = isMine ? 'mine' : isFriend ? 'friends' : 'nostrverse'
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 = () => {
switch (activeTab) {
@@ -302,6 +652,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
post={post}
href={getPostUrl(post)}
level={post.level}
readingProgress={getReadingProgress(post)}
/>
))}
</div>
@@ -319,7 +670,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
}
return classifiedHighlights.length === 0 ? (
<div className="explore-loading" style={{ gridColumn: '1/-1', display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
<span>No highlights to show for the selected scope.</span>
</div>
) : (
<div className="explore-grid">
@@ -338,7 +689,7 @@ 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 showSkeletons = loading && !hasData
@@ -367,7 +718,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
/>
<IconButton
icon={faNetworkWired}
onClick={() => setVisibility({ ...visibility, nostrverse: !visibility.nostrverse })}
onClick={() => toggleScope('nostrverse')}
title="Toggle nostrverse content"
ariaLabel="Toggle nostrverse content"
variant="ghost"
@@ -378,7 +729,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
/>
<IconButton
icon={faUserGroup}
onClick={() => setVisibility({ ...visibility, friends: !visibility.friends })}
onClick={() => toggleScope('friends')}
title={activeAccount ? "Toggle friends content" : "Login to see friends content"}
ariaLabel="Toggle friends content"
variant="ghost"
@@ -390,7 +741,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
/>
<IconButton
icon={faUser}
onClick={() => setVisibility({ ...visibility, mine: !visibility.mine })}
onClick={() => toggleScope('mine')}
title={activeAccount ? "Toggle my content" : "Login to see your content"}
ariaLabel="Toggle my content"
variant="ghost"
@@ -422,7 +773,9 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
</div>
</div>
{renderTabContent()}
<div key={activeTab}>
{renderTabContent()}
</div>
</div>
)
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,209 @@
import React, { useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faPuzzlePiece, faShieldHalved, faCircleInfo } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
import { Accounts } from 'applesauce-accounts'
import { NostrConnectSigner } from 'applesauce-signers'
import { getDefaultBunkerPermissions } from '../services/nostrConnect'
const LoginOptions: React.FC = () => {
const accountManager = Hooks.useAccountManager()
const [showBunkerInput, setShowBunkerInput] = useState(false)
const [bunkerUri, setBunkerUri] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<React.ReactNode | null>(null)
const handleExtensionLogin = async () => {
try {
setIsLoading(true)
setError(null)
const account = await Accounts.ExtensionAccount.fromExtension()
accountManager.addAccount(account)
accountManager.setActive(account)
} catch (err) {
console.error('Extension login failed:', err)
const errorMessage = err instanceof Error ? err.message : String(err)
// Check if extension is not installed
if (errorMessage.includes('Signer extension missing') || errorMessage.includes('window.nostr') || errorMessage.includes('not found') || errorMessage.includes('undefined') || errorMessage.toLowerCase().includes('extension missing')) {
setError(
<>
No browser extension found. Please install{' '}
<a href="https://chromewebstore.google.com/detail/nos2x/kpgefcfmnafjgpblomihpgmejjdanjjp" target="_blank" rel="noopener noreferrer">
nos2x
</a>
{' '}or another nostr extension.
</>
)
} else if (errorMessage.includes('denied') || errorMessage.includes('rejected') || errorMessage.includes('cancel')) {
setError('Authentication was cancelled or denied.')
} else {
setError(`Authentication failed: ${errorMessage}`)
}
} finally {
setIsLoading(false)
}
}
const handleBunkerLogin = async () => {
if (!bunkerUri.trim()) {
setError('Please enter a bunker URI')
return
}
if (!bunkerUri.startsWith('bunker://')) {
setError(
<>
Invalid bunker URI. Must start with bunker://. Don't have a signer? Give{' '}
<a href="https://github.com/greenart7c3/Amber" target="_blank" rel="noopener noreferrer">
Amber
</a>
{' '}or{' '}
<a href="https://testflight.apple.com/join/DUzVMDMK" target="_blank" rel="noopener noreferrer">
Aegis
</a>
{' '}a try.
</>
)
return
}
try {
setIsLoading(true)
setError(null)
// Create signer from bunker URI with default permissions
const permissions = getDefaultBunkerPermissions()
const signer = await NostrConnectSigner.fromBunkerURI(bunkerUri, { permissions })
// Get pubkey from signer
const pubkey = await signer.getPublicKey()
// Create account from signer
const account = new Accounts.NostrConnectAccount(pubkey, signer)
// Add to account manager and set active
accountManager.addAccount(account)
accountManager.setActive(account)
// Clear input on success
setBunkerUri('')
setShowBunkerInput(false)
} catch (err) {
console.error('[bunker] Login failed:', err)
const errorMessage = err instanceof Error ? err.message : 'Failed to connect to bunker'
// Check for permission-related errors
if (errorMessage.toLowerCase().includes('permission') || errorMessage.toLowerCase().includes('unauthorized')) {
setError('Your bunker connection is missing signing permissions. Reconnect and approve signing.')
} else {
// Show helpful message for bunker connection failures
setError(
<>
Failed: {errorMessage}
<br /><br />
Don't have a signer? Give{' '}
<a href="https://github.com/greenart7c3/Amber" target="_blank" rel="noopener noreferrer">
Amber
</a>
{' '}or{' '}
<a href="https://testflight.apple.com/join/DUzVMDMK" target="_blank" rel="noopener noreferrer">
Aegis
</a>
{' '}a try.
</>
)
}
} finally {
setIsLoading(false)
}
}
return (
<div className="empty-state login-container">
<div className="login-content">
<h2 className="login-title">Hi! I'm Boris.</h2>
<p className="login-description">
Connect your npub to see your bookmarks, explore long-form articles, and create <mark className="login-highlight">your own highlights</mark>.
</p>
<div className="login-buttons">
{!showBunkerInput && (
<button
onClick={handleExtensionLogin}
disabled={isLoading}
className="login-button login-button-primary"
>
<FontAwesomeIcon icon={faPuzzlePiece} />
<span>{isLoading ? 'Connecting...' : 'Extension'}</span>
</button>
)}
{!showBunkerInput ? (
<button
onClick={() => setShowBunkerInput(true)}
disabled={isLoading}
className="login-button login-button-secondary"
>
<FontAwesomeIcon icon={faShieldHalved} />
<span>Signer</span>
</button>
) : (
<div className="bunker-input-container">
<input
type="text"
placeholder="bunker://..."
value={bunkerUri}
onChange={(e) => setBunkerUri(e.target.value)}
disabled={isLoading}
className="bunker-input"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleBunkerLogin()
}
}}
/>
<div className="bunker-actions">
<button
onClick={handleBunkerLogin}
disabled={isLoading || !bunkerUri.trim()}
className="bunker-button bunker-connect"
>
{isLoading && showBunkerInput ? 'Connecting...' : 'Connect'}
</button>
<button
onClick={() => {
setShowBunkerInput(false)
setBunkerUri('')
setError(null)
}}
disabled={isLoading}
className="bunker-button bunker-cancel"
>
Cancel
</button>
</div>
</div>
)}
</div>
{error && (
<div className="login-error">
<FontAwesomeIcon icon={faCircleInfo} />
<span>{error}</span>
</div>
)}
<p className="login-footer">
New to nostr? Start here:{' '}
<a href="https://nstart.me/" target="_blank" rel="noopener noreferrer">
nstart.me
</a>
</p>
</div>
</div>
)
}
export default LoginOptions

View File

@@ -1,61 +1,130 @@
import React, { useState, useEffect } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner, faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare } 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 { IEventStore } from 'applesauce-core'
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools'
import { useNavigate } from 'react-router-dom'
import { useNavigate, useParams } from 'react-router-dom'
import { Highlight } from '../types/highlights'
import { HighlightItem } from './HighlightItem'
import { fetchHighlights } from '../services/highlightService'
import { fetchBookmarks } from '../services/bookmarkService'
import { fetchReadArticlesWithData } from '../services/libraryService'
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
import { RELAYS } from '../config/relays'
import { highlightsController } from '../services/highlightsController'
import { writingsController } from '../services/writingsController'
import { fetchAllReads, ReadItem } from '../services/readsService'
import { fetchLinks } from '../services/linksService'
import { BlogPostPreview } from '../services/exploreService'
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
import AuthorCard from './AuthorCard'
import BlogPostCard from './BlogPostCard'
import { BookmarkItem } from './BookmarkItem'
import IconButton from './IconButton'
import { ViewMode } from './Bookmarks'
import { getCachedMeData, setCachedMeData, updateCachedHighlights } from '../services/meCache'
import { getCachedMeData, updateCachedHighlights } from '../services/meCache'
import { faBooks } from '../icons/customIcons'
import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator'
import { groupIndividualBookmarks, hasContent } from '../utils/bookmarkUtils'
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
import { generateArticleIdentifier, loadReadingPosition } from '../services/readingPositionService'
import ArchiveFilters, { ArchiveFilterType } from './ArchiveFilters'
import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters'
import { filterByReadingProgress } from '../utils/readingProgressUtils'
import { deriveReadsFromBookmarks } from '../utils/readsFromBookmarks'
import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks'
import { mergeReadItem } from '../utils/readItemMerge'
import { readingProgressController } from '../services/readingProgressController'
interface MeProps {
relayPool: RelayPool
eventStore: IEventStore
activeTab?: TabType
pubkey?: string // Optional pubkey for viewing other users' profiles
bookmarks: Bookmark[] // From centralized App.tsx state
bookmarksLoading?: boolean // From centralized App.tsx state (reserved for future use)
}
type TabType = 'highlights' | 'reading-list' | 'archive' | 'writings'
type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => {
// Valid reading progress filters
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed', 'highlighted']
const Me: React.FC<MeProps> = ({
relayPool,
eventStore,
activeTab: propActiveTab,
bookmarks
}) => {
const activeAccount = Hooks.useActiveAccount()
const eventStore = Hooks.useEventStore()
const navigate = useNavigate()
const { filter: urlFilter } = useParams<{ filter?: string }>()
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
// Use provided pubkey or fall back to active account
const viewingPubkey = propPubkey || activeAccount?.pubkey
const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey)
// Only for own profile
const viewingPubkey = activeAccount?.pubkey
const [highlights, setHighlights] = useState<Highlight[]>([])
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [readArticles, setReadArticles] = useState<BlogPostPreview[]>([])
const [reads, setReads] = useState<ReadItem[]>([])
const [, setReadsMap] = useState<Map<string, ReadItem>>(new Map())
const [links, setLinks] = useState<ReadItem[]>([])
const [, setLinksMap] = useState<Map<string, ReadItem>>(new Map())
const [writings, setWritings] = useState<BlogPostPreview[]>([])
const [loading, setLoading] = useState(true)
const [viewMode, setViewMode] = useState<ViewMode>('cards')
const [loadedTabs, setLoadedTabs] = useState<Set<TabType>>(new Set())
// Get myHighlights directly from controller
const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
const [myHighlightsLoading, setMyHighlightsLoading] = useState(false)
// Get myWritings directly from controller
const [myWritings, setMyWritings] = useState<BlogPostPreview[]>([])
const [myWritingsLoading, setMyWritingsLoading] = useState(false)
const [refreshTrigger, setRefreshTrigger] = useState(0)
const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all')
const [archiveFilter, setArchiveFilter] = useState<ArchiveFilterType>('all')
const [readingPositions, setReadingPositions] = useState<Map<string, number>>(new Map())
const [groupingMode, setGroupingMode] = useState<'grouped' | 'flat'>(() => {
const saved = localStorage.getItem('bookmarkGroupingMode')
return saved === 'flat' ? 'flat' : 'grouped'
})
const toggleGroupingMode = () => {
const newMode = groupingMode === 'grouped' ? 'flat' : 'grouped'
setGroupingMode(newMode)
localStorage.setItem('bookmarkGroupingMode', newMode)
}
// Initialize reading progress filter from URL param
const initialFilter = urlFilter && VALID_FILTERS.includes(urlFilter as ReadingProgressFilterType)
? (urlFilter as ReadingProgressFilterType)
: 'all'
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
useEffect(() => {
// Get initial state immediately
setMyHighlights(highlightsController.getHighlights())
// Subscribe to updates
const unsubHighlights = highlightsController.onHighlights(setMyHighlights)
const unsubLoading = highlightsController.onLoading(setMyHighlightsLoading)
return () => {
unsubHighlights()
unsubLoading()
}
}, [])
// 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
useEffect(() => {
@@ -64,131 +133,229 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
}
}, [propActiveTab])
// Sync filter state with URL changes
useEffect(() => {
const loadData = async () => {
if (!viewingPubkey) {
setLoading(false)
return
}
const filterFromUrl = urlFilter && VALID_FILTERS.includes(urlFilter as ReadingProgressFilterType)
? (urlFilter as ReadingProgressFilterType)
: 'all'
setReadingProgressFilter(filterFromUrl)
}, [urlFilter])
try {
setLoading(true)
// Seed from cache if available to avoid empty flash (own profile only)
if (isOwnProfile) {
const cached = getCachedMeData(viewingPubkey)
if (cached) {
setHighlights(cached.highlights)
setBookmarks(cached.bookmarks)
setReadArticles(cached.readArticles)
}
}
// Fetch highlights and writings (public data)
const [userHighlights, userWritings] = await Promise.all([
fetchHighlights(relayPool, viewingPubkey),
fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
])
setHighlights(userHighlights)
setWritings(userWritings)
// Only fetch private data for own profile
if (isOwnProfile && activeAccount) {
const userReadArticles = await fetchReadArticlesWithData(relayPool, viewingPubkey)
setReadArticles(userReadArticles)
// Fetch bookmarks using callback pattern
let fetchedBookmarks: Bookmark[] = []
try {
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
fetchedBookmarks = newBookmarks
setBookmarks(newBookmarks)
})
} catch (err) {
console.warn('Failed to load bookmarks:', err)
setBookmarks([])
}
// Update cache with all fetched data
setCachedMeData(viewingPubkey, userHighlights, fetchedBookmarks, userReadArticles)
} else {
setBookmarks([])
setReadArticles([])
}
} catch (err) {
console.error('Failed to load data:', err)
// No blocking error - user can pull-to-refresh
} finally {
setLoading(false)
// Handler to change reading progress filter and update URL
const handleReadingProgressFilterChange = (filter: ReadingProgressFilterType) => {
setReadingProgressFilter(filter)
if (activeTab === 'reads') {
if (filter === 'all') {
navigate('/me/reads', { replace: true })
} else {
navigate(`/me/reads/${filter}`, { replace: true })
}
}
loadData()
}, [relayPool, viewingPubkey, isOwnProfile, activeAccount, refreshTrigger])
// Load reading positions for read articles (only for own profile)
}
// Subscribe to reading progress controller
useEffect(() => {
const loadPositions = async () => {
if (!isOwnProfile || !activeAccount || !relayPool || !eventStore || readArticles.length === 0) {
console.log('🔍 [Archive] Skipping position load:', {
isOwnProfile,
hasAccount: !!activeAccount,
hasRelayPool: !!relayPool,
hasEventStore: !!eventStore,
articlesCount: readArticles.length
})
return
}
// 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])
console.log('📊 [Archive] Loading reading positions for', readArticles.length, 'articles')
// Tab-specific loading functions
const loadHighlightsTab = async () => {
if (!viewingPubkey) return
// Highlights come from controller subscription (sync effect handles it)
setLoadedTabs(prev => new Set(prev).add('highlights'))
setLoading(false)
}
const positions = new Map<string, number>()
const loadWritingsTab = async () => {
if (!viewingPubkey) return
try {
// Use centralized controller
await writingsController.start({
relayPool,
eventStore,
pubkey: viewingPubkey,
force: refreshTrigger > 0
})
setLoadedTabs(prev => new Set(prev).add('writings'))
setLoading(false)
} catch (err) {
console.error('Failed to load writings:', err)
setLoading(false)
}
}
// Load positions for all read articles
await Promise.all(
readArticles.map(async (post) => {
try {
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: post.author,
identifier: dTag
})
const articleUrl = `nostr:${naddr}`
const identifier = generateArticleIdentifier(articleUrl)
const loadReadingListTab = async () => {
if (!viewingPubkey || !activeAccount) return
const hasBeenLoaded = loadedTabs.has('reading-list')
try {
if (!hasBeenLoaded) setLoading(true)
// Bookmarks come from centralized loading in App.tsx
setLoadedTabs(prev => new Set(prev).add('reading-list'))
} catch (err) {
console.error('Failed to load reading list:', err)
} finally {
if (!hasBeenLoaded) setLoading(false)
}
}
console.log('🔍 [Archive] Loading position for:', post.title?.slice(0, 50), 'identifier:', identifier.slice(0, 32))
const savedPosition = await loadReadingPosition(
relayPool,
eventStore,
activeAccount.pubkey,
identifier
)
if (savedPosition && savedPosition.position > 0) {
console.log('✅ [Archive] Found position:', Math.round(savedPosition.position * 100) + '%', 'for', post.title?.slice(0, 50))
positions.set(post.event.id, savedPosition.position)
} else {
console.log('❌ [Archive] No position found for:', post.title?.slice(0, 50))
}
} catch (error) {
console.warn('⚠️ [Archive] Failed to load reading position for article:', error)
const loadReadsTab = async () => {
if (!viewingPubkey || !activeAccount) return
const hasBeenLoaded = loadedTabs.has('reads')
try {
if (!hasBeenLoaded) setLoading(true)
// Derive reads from bookmarks immediately (bookmarks come from centralized loading in App.tsx)
const initialReads = deriveReadsFromBookmarks(bookmarks)
const initialMap = new Map(initialReads.map(item => [item.id, item]))
setReadsMap(initialMap)
setReads(initialReads)
setLoadedTabs(prev => new Set(prev).add('reads'))
if (!hasBeenLoaded) setLoading(false)
// Background enrichment: merge reading progress and mark-as-read
// Only update items that are already in our map
fetchAllReads(relayPool, viewingPubkey, bookmarks, (item) => {
setReadsMap(prevMap => {
// Only update if item exists in our current map
if (!prevMap.has(item.id)) {
return prevMap
}
const newMap = new Map(prevMap)
const merged = mergeReadItem(newMap, item)
if (merged) {
// Update reads array after map is updated
setReads(Array.from(newMap.values()))
return newMap
}
return prevMap
})
)
}).catch(err => console.warn('Failed to enrich reads:', err))
} catch (err) {
console.error('Failed to load reads:', err)
if (!hasBeenLoaded) setLoading(false)
}
}
console.log('📊 [Archive] Loaded positions for', positions.size, '/', readArticles.length, 'articles')
setReadingPositions(positions)
const loadLinksTab = async () => {
if (!viewingPubkey || !activeAccount) return
const hasBeenLoaded = loadedTabs.has('links')
try {
if (!hasBeenLoaded) setLoading(true)
// Derive links from bookmarks immediately (bookmarks come from centralized loading in App.tsx)
const initialLinks = deriveLinksFromBookmarks(bookmarks)
const initialMap = new Map(initialLinks.map(item => [item.id, item]))
setLinksMap(initialMap)
setLinks(initialLinks)
setLoadedTabs(prev => new Set(prev).add('links'))
if (!hasBeenLoaded) setLoading(false)
// Background enrichment: merge reading progress and mark-as-read
// Only update items that are already in our map
fetchLinks(relayPool, viewingPubkey, (item) => {
setLinksMap(prevMap => {
// Only update if item exists in our current map
if (!prevMap.has(item.id)) return prevMap
const newMap = new Map(prevMap)
if (mergeReadItem(newMap, item)) {
// Update links array after map is updated
setLinks(Array.from(newMap.values()))
return newMap
}
return prevMap
})
}).catch(err => console.warn('Failed to enrich links:', err))
} catch (err) {
console.error('Failed to load links:', err)
if (!hasBeenLoaded) setLoading(false)
}
}
// Load active tab data
useEffect(() => {
if (!viewingPubkey || !activeTab) {
setLoading(false)
return
}
loadPositions()
}, [readArticles, isOwnProfile, activeAccount, relayPool, eventStore])
// Load cached data immediately if available
const cached = getCachedMeData(viewingPubkey)
if (cached) {
setHighlights(cached.highlights)
// Bookmarks come from App.tsx centralized state, no local caching needed
setReads(cached.reads || [])
setLinks(cached.links || [])
}
// Pull-to-refresh
// Load data for active tab (refresh in background if already loaded)
switch (activeTab) {
case 'highlights':
loadHighlightsTab()
break
case 'writings':
loadWritingsTab()
break
case 'reading-list':
loadReadingListTab()
break
case 'reads':
loadReadsTab()
break
case 'links':
loadLinksTab()
break
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab, viewingPubkey, refreshTrigger, bookmarks])
// Sync myHighlights from controller
useEffect(() => {
setHighlights(myHighlights)
}, [myHighlights])
// Sync myWritings from controller
useEffect(() => {
setWritings(myWritings)
}, [myWritings])
// Pull-to-refresh - reload active tab without clearing state
const { isRefreshing, pullPosition } = usePullToRefresh({
onRefresh: () => {
// Just trigger refresh - loaders will merge new data
setRefreshTrigger(prev => prev + 1)
},
maximumPullLength: 240,
@@ -199,8 +366,8 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
const handleHighlightDelete = (highlightId: string) => {
setHighlights(prev => {
const updated = prev.filter(h => h.id !== highlightId)
// Update cache when highlight is deleted (own profile only)
if (isOwnProfile && viewingPubkey) {
// Update cache when highlight is deleted
if (viewingPubkey) {
updateCachedHighlights(viewingPubkey, updated)
}
return updated
@@ -217,6 +384,49 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
return `/a/${naddr}`
}
const getReadItemUrl = (item: ReadItem) => {
if (item.type === 'article') {
// ID is already in naddr format
return `/a/${item.id}`
} else if (item.url) {
return `/r/${encodeURIComponent(item.url)}`
}
return '#'
}
const convertReadItemToBlogPostPreview = (item: ReadItem): BlogPostPreview => {
if (item.event) {
return {
event: item.event,
title: item.title || 'Untitled',
summary: item.summary,
image: item.image,
published: item.published,
author: item.author || item.event.pubkey
}
}
// Create a mock event for external URLs
const mockEvent = {
id: item.id,
pubkey: item.author || '',
created_at: item.readingTimestamp || Math.floor(Date.now() / 1000),
kind: 1,
tags: [] as string[][],
content: item.title || item.url || 'Untitled',
sig: ''
} as const
return {
event: mockEvent as unknown as import('nostr-tools').NostrEvent,
title: item.title || item.url || 'Untitled',
summary: item.summary,
image: item.image,
published: item.published,
author: item.author || ''
}
}
const handleSelectUrl = (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => {
if (bookmark && bookmark.kind === 30023) {
// For kind:30023 articles, navigate to the article route
@@ -235,6 +445,42 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
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
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
@@ -245,39 +491,44 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
const groups = groupIndividualBookmarks(filteredBookmarks)
// Apply archive filter
const filteredReadArticles = readArticles.filter(post => {
const position = readingPositions.get(post.event.id)
switch (archiveFilter) {
case 'to-read':
// No position or 0% progress
return !position || position === 0
case 'reading':
// Has some progress but not completed (0 < position < 1)
return position !== undefined && position > 0 && position < 0.95
case 'completed':
// 95% or more read (we consider 95%+ as completed)
return position !== undefined && position >= 0.95
case 'marked':
// Manually marked as read (in archive but no reading position data)
// These are articles that were marked via the emoji reaction
return !position || position === 0
case 'all':
default:
return true
// 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 sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
{ key: 'private', title: 'Private Bookmarks', items: groups.privateItems },
{ key: 'public', title: 'Public Bookmarks', items: groups.publicItems },
{ key: 'web', title: 'Web Bookmarks', items: groups.web },
{ key: 'amethyst', title: 'Legacy Bookmarks', items: groups.amethyst }
]
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
const filteredReads = filterByReadingProgress(readsWithProgress, readingProgressFilter, highlights)
const filteredLinks = filterByReadingProgress(linksWithProgress, readingProgressFilter, highlights)
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
groupingMode === 'flat'
? [{ key: 'all', title: `All Bookmarks (${filteredBookmarks.length})`, items: filteredBookmarks }]
: [
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
{ key: 'amethyst-private', title: 'Private Lists', items: groups.amethystPrivate },
{ key: 'amethyst-public', title: 'My Lists', items: groups.amethystPublic },
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb }
]
// Show content progressively - no blocking error screens
const hasData = highlights.length > 0 || bookmarks.length > 0 || readArticles.length > 0 || writings.length > 0
const showSkeletons = loading && !hasData
const hasData = highlights.length > 0 || bookmarks.length > 0 || reads.length > 0 || links.length > 0 || writings.length > 0
const showSkeletons = (loading || myHighlightsLoading) && !hasData
const renderTabContent = () => {
switch (activeTab) {
@@ -291,9 +542,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
</div>
)
}
return highlights.length === 0 ? (
return highlights.length === 0 && !loading && !myHighlightsLoading ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
No highlights yet.
</div>
) : (
<div className="highlights-list me-highlights-list">
@@ -312,17 +563,17 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
if (showSkeletons) {
return (
<div className="bookmarks-list">
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
<div className="bookmarks-grid bookmarks-cards">
{Array.from({ length: 6 }).map((_, i) => (
<BookmarkSkeleton key={i} viewMode={viewMode} />
<BookmarkSkeleton key={i} viewMode="cards" />
))}
</div>
</div>
)
}
return allIndividualBookmarks.length === 0 ? (
return allIndividualBookmarks.length === 0 && !loading ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
No bookmarks yet.
</div>
) : (
<div className="bookmarks-list">
@@ -340,14 +591,15 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
sections.filter(s => s.items.length > 0).map(section => (
<div key={section.key} className="bookmarks-section">
<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) => (
<BookmarkItem
key={`${section.key}-${individualBookmark.id}-${index}`}
bookmark={individualBookmark}
index={index}
viewMode={viewMode}
viewMode="cards"
onSelectUrl={handleSelectUrl}
readingProgress={getBookmarkReadingProgress(individualBookmark)}
/>
))}
</div>
@@ -362,32 +614,19 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
borderTop: '1px solid var(--border-color)'
}}>
<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'}
icon={groupingMode === 'grouped' ? faLayerGroup : faBars}
onClick={toggleGroupingMode}
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
variant="ghost"
/>
</div>
</div>
)
case 'archive':
if (showSkeletons) {
case 'reads':
// Show loading skeletons only while initially loading
if (loading && !loadedTabs.has('reads')) {
return (
<div className="explore-grid">
{Array.from({ length: 6 }).map((_, i) => (
@@ -396,32 +635,84 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
</div>
)
}
return readArticles.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
</div>
) : (
// Show empty state if loaded but no reads
if (reads.length === 0 && loadedTabs.has('reads')) {
return (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No articles read yet.
</div>
)
}
// Show reads with filters
return (
<>
{readArticles.length > 0 && (
<ArchiveFilters
selectedFilter={archiveFilter}
onFilterChange={setArchiveFilter}
/>
)}
{filteredReadArticles.length === 0 ? (
<ReadingProgressFilters
selectedFilter={readingProgressFilter}
onFilterChange={handleReadingProgressFilterChange}
/>
{filteredReads.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No articles match this filter.
</div>
) : (
<div className="explore-grid">
{filteredReadArticles.map((post) => (
<BlogPostCard
key={post.event.id}
post={post}
href={getPostUrl(post)}
readingProgress={readingPositions.get(post.event.id)}
/>
))}
{filteredReads.map((item) => (
<BlogPostCard
key={item.id}
post={convertReadItemToBlogPostPreview(item)}
href={getReadItemUrl(item)}
readingProgress={item.readingProgress}
/>
))}
</div>
)}
</>
)
case 'links':
// Show loading skeletons only while initially loading
if (loading && !loadedTabs.has('links')) {
return (
<div className="explore-grid">
{Array.from({ length: 6 }).map((_, i) => (
<BlogPostSkeleton key={i} />
))}
</div>
)
}
// Show empty state if loaded but no links
if (links.length === 0 && loadedTabs.has('links')) {
return (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No links with reading progress yet.
</div>
)
}
// Show links with filters
return (
<>
<ReadingProgressFilters
selectedFilter={readingProgressFilter}
onFilterChange={handleReadingProgressFilterChange}
/>
{filteredLinks.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No links match this filter.
</div>
) : (
<div className="explore-grid">
{filteredLinks.map((item) => (
<BlogPostCard
key={item.id}
post={convertReadItemToBlogPostPreview(item)}
href={getReadItemUrl(item)}
readingProgress={item.readingProgress}
/>
))}
</div>
)}
</>
@@ -437,9 +728,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
</div>
)
}
return writings.length === 0 ? (
return writings.length === 0 && !loading && !myWritingsLoading ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
No articles written yet.
</div>
) : (
<div className="explore-grid">
@@ -448,6 +739,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
key={post.event.id}
post={post}
href={getPostUrl(post)}
readingProgress={getWritingReadingProgress(post)}
/>
))}
</div>
@@ -471,35 +763,39 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
<button
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
data-tab="highlights"
onClick={() => navigate(isOwnProfile ? '/me/highlights' : `/p/${propPubkey && nip19.npubEncode(propPubkey)}`)}
onClick={() => navigate('/me/highlights')}
>
<FontAwesomeIcon icon={faHighlighter} />
<span className="tab-label">Highlights</span>
</button>
{isOwnProfile && (
<>
<button
className={`me-tab ${activeTab === 'reading-list' ? 'active' : ''}`}
data-tab="reading-list"
onClick={() => navigate('/me/reading-list')}
>
<FontAwesomeIcon icon={faBookmark} />
<span className="tab-label">Bookmarks</span>
</button>
<button
className={`me-tab ${activeTab === 'archive' ? 'active' : ''}`}
data-tab="archive"
onClick={() => navigate('/me/archive')}
>
<FontAwesomeIcon icon={faBooks} />
<span className="tab-label">Archive</span>
</button>
</>
)}
<button
className={`me-tab ${activeTab === 'reading-list' ? 'active' : ''}`}
data-tab="reading-list"
onClick={() => navigate('/me/reading-list')}
>
<FontAwesomeIcon icon={faBookmark} />
<span className="tab-label">Bookmarks</span>
</button>
<button
className={`me-tab ${activeTab === 'reads' ? 'active' : ''}`}
data-tab="reads"
onClick={() => navigate('/me/reads')}
>
<FontAwesomeIcon icon={faBooks} />
<span className="tab-label">Reads</span>
</button>
<button
className={`me-tab ${activeTab === 'links' ? 'active' : ''}`}
data-tab="links"
onClick={() => navigate('/me/links')}
>
<FontAwesomeIcon icon={faLink} />
<span className="tab-label">Links</span>
</button>
<button
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
data-tab="writings"
onClick={() => navigate(isOwnProfile ? '/me/writings' : `/p/${propPubkey && nip19.npubEncode(propPubkey)}/writings`)}
onClick={() => navigate('/me/writings')}
>
<FontAwesomeIcon icon={faPenToSquare} />
<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

@@ -0,0 +1,55 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBookOpen, faCheckCircle, faAsterisk, faHighlighter } from '@fortawesome/free-solid-svg-icons'
import { faEnvelope, faEnvelopeOpen } from '@fortawesome/free-regular-svg-icons'
export type ReadingProgressFilterType = 'all' | 'unopened' | 'started' | 'reading' | 'completed' | 'highlighted'
interface ReadingProgressFiltersProps {
selectedFilter: ReadingProgressFilterType
onFilterChange: (filter: ReadingProgressFilterType) => void
}
const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selectedFilter, onFilterChange }) => {
const filters = [
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
{ type: 'unopened' as const, icon: faEnvelope, label: 'Unopened' },
{ type: 'started' as const, icon: faEnvelopeOpen, label: 'Started' },
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
{ type: 'highlighted' as const, icon: faHighlighter, label: 'Highlighted' },
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' }
]
return (
<div className="bookmark-filters">
{filters.map(filter => {
const isActive = selectedFilter === filter.type
// Only "completed" gets green color, "highlighted" gets yellow, everything else uses default blue
let activeStyle: Record<string, string> | undefined = undefined
if (isActive) {
if (filter.type === 'completed') {
activeStyle = { color: '#10b981' } // green
} else if (filter.type === 'highlighted') {
activeStyle = { color: '#fde047' } // yellow
}
}
return (
<button
key={filter.type}
onClick={() => onFilterChange(filter.type)}
className={`filter-btn ${isActive ? 'active' : ''}`}
title={filter.label}
aria-label={`Filter by ${filter.label}`}
style={activeStyle}
>
<FontAwesomeIcon icon={filter.icon} />
</button>
)
})}
</div>
)
}
export default ReadingProgressFilters

View File

@@ -19,6 +19,21 @@ export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> =
}) => {
const clampedProgress = Math.min(100, Math.max(0, progress))
// Determine reading state based on progress (matching readingProgressUtils.ts logic)
const progressDecimal = clampedProgress / 100
const isStarted = progressDecimal > 0 && progressDecimal <= 0.10
// Determine bar color based on state
let barColorClass = ''
let barColorStyle: string | undefined = 'var(--color-primary)' // Default blue
if (isComplete) {
barColorClass = 'bg-green-500'
barColorStyle = undefined
} else if (isStarted) {
barColorStyle = 'var(--color-text)' // Neutral text color (matches card titles)
}
// Calculate left and right offsets based on sidebar states (desktop only)
const leftOffset = isSidebarCollapsed
? 'var(--sidebar-collapsed-width)'
@@ -42,14 +57,10 @@ export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> =
style={{ backgroundColor: 'var(--color-border)' }}
>
<div
className={`h-full rounded-full transition-all duration-300 relative ${
isComplete
? 'bg-green-500'
: ''
}`}
className={`h-full rounded-full transition-all duration-300 relative ${barColorClass}`}
style={{
width: `${clampedProgress}%`,
backgroundColor: isComplete ? undefined : 'var(--color-primary)'
backgroundColor: barColorStyle
}}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-[shimmer_2s_infinite]" />
@@ -60,7 +71,9 @@ export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> =
className={`text-[0.625rem] font-normal min-w-[32px] text-right tabular-nums ${
isComplete ? 'text-green-500' : ''
}`}
style={{ color: isComplete ? undefined : 'var(--color-text-muted)' }}
style={{
color: isComplete ? undefined : isStarted ? 'var(--color-text)' : 'var(--color-text-muted)'
}}
>
{isComplete ? '✓' : `${clampedProgress}%`}
</div>

View File

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

View File

@@ -0,0 +1,30 @@
import { useEffect } from 'react'
import { useLocation, useMatch } from 'react-router-dom'
export default function RouteDebug() {
const location = useLocation()
const matchArticle = useMatch('/a/:naddr')
useEffect(() => {
const params = new URLSearchParams(location.search)
if (params.get('debug') !== '1') return
const info: Record<string, unknown> = {
pathname: location.pathname,
search: location.search || null,
matchedArticleRoute: Boolean(matchArticle),
referrer: document.referrer || null
}
if (location.pathname === '/') {
// Unexpected during deep-link refresh tests
console.warn('[RouteDebug] unexpected root redirect', info)
} else {
console.debug('[RouteDebug]', info)
}
}, [location, matchArticle])
return null
}

View File

@@ -6,11 +6,13 @@ import IconButton from './IconButton'
import { loadFont } from '../utils/fontLoader'
import ThemeSettings from './Settings/ThemeSettings'
import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
import ExploreSettings from './Settings/ExploreSettings'
import LayoutBehaviorSettings from './Settings/LayoutBehaviorSettings'
import ZapSettings from './Settings/ZapSettings'
import RelaySettings from './Settings/RelaySettings'
import PWASettings from './Settings/PWASettings'
import { useRelayStatus } from '../hooks/useRelayStatus'
import VersionFooter from './VersionFooter'
const DEFAULT_SETTINGS: UserSettings = {
collapseOnArticleOpen: true,
@@ -28,13 +30,18 @@ const DEFAULT_SETTINGS: UserSettings = {
defaultHighlightVisibilityNostrverse: true,
defaultHighlightVisibilityFriends: true,
defaultHighlightVisibilityMine: true,
defaultExploreScopeNostrverse: false,
defaultExploreScopeFriends: true,
defaultExploreScopeMine: false,
zapSplitHighlighterWeight: 50,
zapSplitBorisWeight: 2.1,
zapSplitAuthorWeight: 50,
useLocalRelayAsCache: true,
rebroadcastToAllRelays: false,
paragraphAlignment: 'justify',
syncReadingPosition: false,
syncReadingPosition: true,
autoMarkAsReadOnCompletion: false,
hideBookmarksWithoutCreationDate: false,
}
interface SettingsProps {
@@ -162,11 +169,13 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
<div className="settings-content">
<ThemeSettings settings={localSettings} onUpdate={handleUpdate} />
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
<ExploreSettings settings={localSettings} onUpdate={handleUpdate} />
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
<LayoutBehaviorSettings settings={localSettings} onUpdate={handleUpdate} />
<PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
</div>
<VersionFooter />
</div>
)
}

View File

@@ -0,0 +1,59 @@
import React from 'react'
import { faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../../services/settingsService'
import IconButton from '../IconButton'
interface ExploreSettingsProps {
settings: UserSettings
onUpdate: (updates: Partial<UserSettings>) => void
}
const ExploreSettings: React.FC<ExploreSettingsProps> = ({ settings, onUpdate }) => {
return (
<div className="settings-section">
<h3 className="section-title">Explore</h3>
<div className="setting-group setting-inline">
<label>Default Explore Scope</label>
<div className="highlight-level-toggles">
<IconButton
icon={faNetworkWired}
onClick={() => onUpdate({ defaultExploreScopeNostrverse: !(settings.defaultExploreScopeNostrverse !== false) })}
title="Nostrverse content"
ariaLabel="Toggle nostrverse content by default in explore"
variant="ghost"
style={{
color: (settings.defaultExploreScopeNostrverse !== false) ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined,
opacity: (settings.defaultExploreScopeNostrverse !== false) ? 1 : 0.4
}}
/>
<IconButton
icon={faUserGroup}
onClick={() => onUpdate({ defaultExploreScopeFriends: !(settings.defaultExploreScopeFriends !== false) })}
title="Friends content"
ariaLabel="Toggle friends content by default in explore"
variant="ghost"
style={{
color: (settings.defaultExploreScopeFriends !== false) ? 'var(--highlight-color-friends, #f97316)' : undefined,
opacity: (settings.defaultExploreScopeFriends !== false) ? 1 : 0.4
}}
/>
<IconButton
icon={faUser}
onClick={() => onUpdate({ defaultExploreScopeMine: !(settings.defaultExploreScopeMine !== false) })}
title="My content"
ariaLabel="Toggle my content by default in explore"
variant="ghost"
style={{
color: (settings.defaultExploreScopeMine !== false) ? 'var(--highlight-color-mine, #eab308)' : undefined,
opacity: (settings.defaultExploreScopeMine !== false) ? 1 : 0.4
}}
/>
</div>
</div>
</div>
)
}
export default ExploreSettings

View File

@@ -117,6 +117,32 @@ const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ setting
<span>Sync reading position across devices</span>
</label>
</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>
)
}

View File

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

View File

@@ -1,11 +1,10 @@
import React, { useState } from 'react'
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faHome, faNewspaper, faTimes } from '@fortawesome/free-solid-svg-icons'
import { faChevronRight, faRightFromBracket, faUserCircle, faGear, faHome, faNewspaper, faTimes } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import { Accounts } from 'applesauce-accounts'
import IconButton from './IconButton'
interface SidebarHeaderProps {
@@ -16,26 +15,10 @@ interface SidebarHeaderProps {
}
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, isMobile = false }) => {
const [isConnecting, setIsConnecting] = useState(false)
const navigate = useNavigate()
const activeAccount = Hooks.useActiveAccount()
const accountManager = Hooks.useAccountManager()
const profile = useEventModel(Models.ProfileModel, activeAccount ? [activeAccount.pubkey] : null)
const handleLogin = async () => {
try {
setIsConnecting(true)
const account = await Accounts.ExtensionAccount.fromExtension()
accountManager.addAccount(account)
accountManager.setActive(account)
} catch (error) {
console.error('Login failed:', error)
alert('Login failed. Please install a nostr browser extension and try again.\n\nIf you aren\'t on nostr yet, start here: https://nstart.me/')
} finally {
setIsConnecting(false)
}
}
const getProfileImage = () => {
return profile?.picture || null
}
@@ -73,22 +56,20 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
</button>
)}
<div className="sidebar-header-right">
<div
className="profile-avatar"
title={activeAccount ? getUserDisplayName() : "Login"}
onClick={
activeAccount
? () => navigate('/me')
: (isConnecting ? () => {} : handleLogin)
}
style={{ cursor: 'pointer' }}
>
{profileImage ? (
<img src={profileImage} alt={getUserDisplayName()} />
) : (
<FontAwesomeIcon icon={faUserCircle} />
)}
</div>
{activeAccount && (
<div
className="profile-avatar"
title={getUserDisplayName()}
onClick={() => navigate('/me')}
style={{ cursor: 'pointer' }}
>
{profileImage ? (
<img src={profileImage} alt={getUserDisplayName()} />
) : (
<FontAwesomeIcon icon={faUserCircle} />
)}
</div>
)}
<IconButton
icon={faHome}
onClick={() => navigate('/')}
@@ -110,7 +91,7 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
ariaLabel="Settings"
variant="ghost"
/>
{activeAccount ? (
{activeAccount && (
<IconButton
icon={faRightFromBracket}
onClick={onLogout}
@@ -118,14 +99,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
ariaLabel="Logout"
variant="ghost"
/>
) : (
<IconButton
icon={faRightToBracket}
onClick={isConnecting ? () => {} : handleLogin}
title={isConnecting ? "Connecting..." : "Login"}
ariaLabel="Login"
variant="ghost"
/>
)}
</div>
</div>

View File

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

View File

@@ -0,0 +1,32 @@
/* global __APP_VERSION__, __GIT_COMMIT__, __GIT_COMMIT_URL__, __RELEASE_URL__ */
import React from 'react'
const VersionFooter: React.FC = () => {
return (
<div className="text-xs opacity-60 mt-4 px-4 pb-3 select-text">
<span>
{typeof __RELEASE_URL__ !== 'undefined' && __RELEASE_URL__ ? (
<a href={__RELEASE_URL__} target="_blank" rel="noopener noreferrer">
Version {typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'}
</a>
) : (
`Version ${typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'}`
)}
</span>
{typeof __GIT_COMMIT__ !== 'undefined' && __GIT_COMMIT__ ? (
<span>
{' '}·{' '}
{typeof __GIT_COMMIT_URL__ !== 'undefined' && __GIT_COMMIT_URL__ ? (
<a href={__GIT_COMMIT_URL__} target="_blank" rel="noopener noreferrer">
<code>{__GIT_COMMIT__.slice(0, 7)}</code>
</a>
) : (
<code>{__GIT_COMMIT__.slice(0, 7)}</code>
)}
</span>
) : null}
</div>
)
}
export default VersionFooter

22
src/config/kinds.ts Normal file
View File

@@ -0,0 +1,22 @@
// Nostr event kinds used throughout the application
export const KINDS = {
Highlights: 9802, // NIP-84 user highlights
BlogPost: 30023, // NIP-23 long-form article
AppData: 30078, // NIP-78 application data
ReadingProgress: 39802, // NIP-85 reading progress
List: 30001, // NIP-51 list (addressable)
ListReplaceable: 30003, // NIP-51 replaceable list
ListSimple: 10003, // NIP-51 simple list
WebBookmark: 39701, // NIP-B0 web bookmark
ReactionToEvent: 7, // emoji reaction to event (used for mark-as-read)
ReactionToUrl: 17 // emoji reaction to URL (used for mark-as-read)
} as const
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

@@ -7,6 +7,7 @@
export const RELAYS = [
'ws://localhost:10547',
'ws://localhost:4869',
'wss://relay.nsec.app',
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.nostr.band',

View File

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

View File

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

View File

@@ -1,141 +1,189 @@
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { RelayPool } from 'applesauce-relay'
import { IAccount, AccountManager } from 'applesauce-accounts'
import { IAccount } from 'applesauce-accounts'
import { IEventStore } from 'applesauce-core'
import { Bookmark } from '../types/bookmarks'
import { Highlight } from '../types/highlights'
import { fetchBookmarks } from '../services/bookmarkService'
import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService'
import { fetchContacts } from '../services/contactService'
import { fetchHighlightsForArticle } from '../services/highlightService'
import { UserSettings } from '../services/settingsService'
import { highlightsController } from '../services/highlightsController'
import { contactsController } from '../services/contactsController'
import { useStoreTimeline } from './useStoreTimeline'
import { eventToHighlight } from '../services/highlightEventProcessor'
import { KINDS } from '../config/kinds'
import { nip19 } from 'nostr-tools'
interface UseBookmarksDataParams {
relayPool: RelayPool | null
activeAccount: IAccount | undefined
accountManager: AccountManager
naddr?: string
externalUrl?: string
currentArticleCoordinate?: string
currentArticleEventId?: string
settings?: UserSettings
eventStore?: IEventStore | null
bookmarks: Bookmark[] // Passed from App.tsx (centralized loading)
bookmarksLoading: boolean // Passed from App.tsx (centralized loading)
onRefreshBookmarks: () => Promise<void>
}
export const useBookmarksData = ({
relayPool,
activeAccount,
accountManager,
naddr,
externalUrl,
currentArticleCoordinate,
currentArticleEventId,
settings
}: UseBookmarksDataParams) => {
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [bookmarksLoading, setBookmarksLoading] = useState(true)
const [highlights, setHighlights] = useState<Highlight[]>([])
settings,
eventStore,
onRefreshBookmarks
}: Omit<UseBookmarksDataParams, 'bookmarks' | 'bookmarksLoading'>) => {
const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
const [articleHighlights, setArticleHighlights] = useState<Highlight[]>([])
const [highlightsLoading, setHighlightsLoading] = useState(true)
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
const [isRefreshing, setIsRefreshing] = useState(false)
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null)
const handleFetchContacts = useCallback(async () => {
if (!relayPool || !activeAccount) return
const contacts = await fetchContacts(relayPool, activeAccount.pubkey)
setFollowedPubkeys(contacts)
}, [relayPool, activeAccount])
const handleFetchBookmarks = useCallback(async () => {
if (!relayPool || !activeAccount) return
// don't clear existing bookmarks: we keep UI stable and show spinner unobtrusively
setBookmarksLoading(true)
// 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 fullAccount = accountManager.getActive()
// merge-friendly: updater form that preserves visible list until replacement
await fetchBookmarks(relayPool, fullAccount || activeAccount, (next) => {
setBookmarks(() => next)
}, settings)
} finally {
setBookmarksLoading(false)
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
}
}, [relayPool, activeAccount, accountManager, settings])
return undefined
}, [currentArticleCoordinate, naddr])
// Load cached article-specific highlights from event store
const articleFilter = useMemo(() => {
if (!effectiveArticleCoordinate) return null
return {
kinds: [KINDS.Highlights],
'#a': [effectiveArticleCoordinate],
...(currentArticleEventId ? { '#e': [currentArticleEventId] } : {})
}
}, [effectiveArticleCoordinate, currentArticleEventId])
const cachedArticleHighlights = useStoreTimeline(
eventStore || null,
articleFilter || { kinds: [KINDS.Highlights], limit: 0 }, // empty filter if no article
eventToHighlight,
[effectiveArticleCoordinate, currentArticleEventId]
)
// Subscribe to centralized controllers
useEffect(() => {
// Get initial state immediately
setMyHighlights(highlightsController.getHighlights())
setFollowedPubkeys(new Set(contactsController.getContacts()))
// Subscribe to updates
const unsubHighlights = highlightsController.onHighlights(setMyHighlights)
const unsubContacts = contactsController.onContacts((contacts) => {
setFollowedPubkeys(new Set(contacts))
})
return () => {
unsubHighlights()
unsubContacts()
}
}, [])
const handleFetchHighlights = useCallback(async () => {
if (!relayPool) return
setHighlightsLoading(true)
try {
if (currentArticleCoordinate) {
if (effectiveArticleCoordinate) {
// Seed with cached highlights first
if (cachedArticleHighlights.length > 0) {
setArticleHighlights(cachedArticleHighlights.sort((a, b) => b.created_at - a.created_at))
}
// Fetch fresh article-specific highlights (from all users)
const highlightsMap = new Map<string, Highlight>()
// Seed map with cached highlights
cachedArticleHighlights.forEach(h => highlightsMap.set(h.id, h))
await fetchHighlightsForArticle(
relayPool,
currentArticleCoordinate,
effectiveArticleCoordinate,
currentArticleEventId,
(highlight) => {
// Deduplicate highlights by ID as they arrive
if (!highlightsMap.has(highlight.id)) {
highlightsMap.set(highlight.id, highlight)
const highlightsList = Array.from(highlightsMap.values())
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
setArticleHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
}
},
settings
settings,
false, // force
eventStore || undefined
)
console.log(`🔄 Refreshed ${highlightsMap.size} highlights for article`)
} else if (activeAccount) {
const fetchedHighlights = await fetchHighlights(relayPool, activeAccount.pubkey, undefined, settings)
setHighlights(fetchedHighlights)
} else {
// No article selected - clear article highlights
setArticleHighlights([])
}
} catch (err) {
console.error('Failed to fetch highlights:', err)
} finally {
setHighlightsLoading(false)
}
}, [relayPool, activeAccount, currentArticleCoordinate, currentArticleEventId, settings])
}, [relayPool, effectiveArticleCoordinate, currentArticleEventId, settings, eventStore, cachedArticleHighlights])
const handleRefreshAll = useCallback(async () => {
if (!relayPool || !activeAccount || isRefreshing) return
setIsRefreshing(true)
try {
await handleFetchBookmarks()
await onRefreshBookmarks()
await handleFetchHighlights()
await handleFetchContacts()
// Contacts and own highlights are managed by controllers
setLastFetchTime(Date.now())
} catch (err) {
console.error('Failed to refresh data:', err)
} finally {
setIsRefreshing(false)
}
}, [relayPool, activeAccount, isRefreshing, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
}, [relayPool, activeAccount, isRefreshing, onRefreshBookmarks, handleFetchHighlights])
// Load initial data (avoid clearing on route-only changes)
// Fetch article-specific highlights when viewing an article
useEffect(() => {
if (!relayPool || !activeAccount) return
// Only (re)fetch bookmarks when account or relayPool changes, not on naddr route changes
handleFetchBookmarks()
}, [relayPool, activeAccount, handleFetchBookmarks])
// Fetch highlights/contacts independently to avoid disturbing bookmarks
useEffect(() => {
if (!relayPool || !activeAccount) return
// Only fetch general highlights when not viewing an article (naddr) or external URL
// Fetch article-specific highlights when viewing an article
// External URLs have their highlights fetched by useExternalUrlLoader
if (!naddr && !externalUrl) {
if (effectiveArticleCoordinate && !externalUrl) {
handleFetchHighlights()
} else if (!naddr && !externalUrl) {
// Clear article highlights when not viewing an article
setArticleHighlights([])
setHighlightsLoading(false)
}
handleFetchContacts()
}, [relayPool, activeAccount, naddr, externalUrl, handleFetchHighlights, handleFetchContacts])
}, [relayPool, activeAccount, effectiveArticleCoordinate, naddr, externalUrl, handleFetchHighlights])
// When viewing an article, show only article-specific highlights
// Otherwise, show user's highlights from controller
const highlights = effectiveArticleCoordinate || externalUrl
? articleHighlights.sort((a, b) => b.created_at - a.created_at)
: myHighlights
return {
bookmarks,
bookmarksLoading,
highlights,
setHighlights,
setHighlights: setArticleHighlights, // For external updates (like from useExternalUrlLoader)
highlightsLoading,
setHighlightsLoading,
followedPubkeys,
isRefreshing,
lastFetchTime,
handleFetchBookmarks,
handleFetchHighlights,
handleRefreshAll
}

View File

@@ -1,8 +1,12 @@
import { useEffect } from 'react'
import { useEffect, useMemo } from 'react'
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { fetchReadableContent, ReadableContent } from '../services/readerService'
import { fetchHighlightsForUrl } from '../services/highlightService'
import { Highlight } from '../types/highlights'
import { useStoreTimeline } from './useStoreTimeline'
import { eventToHighlight } from '../services/highlightEventProcessor'
import { KINDS } from '../config/kinds'
// Helper to extract filename from URL
function getFilenameFromUrl(url: string): string {
@@ -20,6 +24,7 @@ function getFilenameFromUrl(url: string): string {
interface UseExternalUrlLoaderProps {
url: string | undefined
relayPool: RelayPool | null
eventStore?: IEventStore | null
setSelectedUrl: (url: string) => void
setReaderContent: (content: ReadableContent | undefined) => void
setReaderLoading: (loading: boolean) => void
@@ -33,6 +38,7 @@ interface UseExternalUrlLoaderProps {
export function useExternalUrlLoader({
url,
relayPool,
eventStore,
setSelectedUrl,
setReaderContent,
setReaderLoading,
@@ -42,6 +48,19 @@ export function useExternalUrlLoader({
setCurrentArticleCoordinate,
setCurrentArticleEventId
}: UseExternalUrlLoaderProps) {
// Load cached URL-specific highlights from event store
const urlFilter = useMemo(() => {
if (!url) return null
return { kinds: [KINDS.Highlights], '#r': [url] }
}, [url])
const cachedUrlHighlights = useStoreTimeline(
eventStore || null,
urlFilter || { kinds: [KINDS.Highlights], limit: 0 },
eventToHighlight,
[url]
)
useEffect(() => {
if (!relayPool || !url) return
@@ -58,7 +77,6 @@ export function useExternalUrlLoader({
const content = await fetchReadableContent(url)
setReaderContent(content)
console.log('🌐 External URL loaded:', content.title)
// Set reader loading to false immediately after content is ready
setReaderLoading(false)
@@ -66,11 +84,20 @@ export function useExternalUrlLoader({
// Fetch highlights for this URL asynchronously
try {
setHighlightsLoading(true)
setHighlights([])
// Seed with cached highlights first
if (cachedUrlHighlights.length > 0) {
setHighlights(cachedUrlHighlights.sort((a, b) => b.created_at - a.created_at))
} else {
setHighlights([])
}
// Check if fetchHighlightsForUrl exists, otherwise skip
if (typeof fetchHighlightsForUrl === 'function') {
const seen = new Set<string>()
// Seed with cached IDs
cachedUrlHighlights.forEach(h => seen.add(h.id))
await fetchHighlightsForUrl(
relayPool,
url,
@@ -82,13 +109,11 @@ export function useExternalUrlLoader({
const next = [...prev, highlight]
return next.sort((a, b) => b.created_at - a.created_at)
})
}
},
undefined, // settings
false, // force
eventStore || undefined
)
// Highlights are already set via the streaming callback
// No need to set them again as that could cause a flash/disappearance
console.log(`📌 Finished fetching highlights for URL`)
} else {
console.log('📌 Highlight fetching for URLs not yet implemented')
}
} catch (err) {
console.error('Failed to fetch highlights:', err)
@@ -109,6 +134,6 @@ export function useExternalUrlLoader({
}
loadExternalUrl()
}, [url, relayPool, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId])
}, [url, relayPool, eventStore, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId, cachedUrlHighlights])
}

View File

@@ -9,6 +9,7 @@ import { ReadableContent } from '../services/readerService'
import { createHighlight } from '../services/highlightCreationService'
import { HighlightButtonRef } from '../components/HighlightButton'
import { UserSettings } from '../services/settingsService'
import { useToast } from './useToast'
interface UseHighlightCreationParams {
activeAccount: IAccount | undefined
@@ -32,6 +33,7 @@ export const useHighlightCreation = ({
settings
}: UseHighlightCreationParams) => {
const highlightButtonRef = useRef<HighlightButtonRef>(null)
const { showToast } = useToast()
const handleTextSelection = useCallback((text: string) => {
highlightButtonRef.current?.updateSelection(text)
@@ -58,7 +60,6 @@ export const useHighlightCreation = ({
? currentArticle.content
: readerContent?.markdown || readerContent?.html
console.log('🎯 Creating highlight...', { text: text.substring(0, 50) + '...' })
const newHighlight = await createHighlight(
text,
@@ -71,12 +72,7 @@ export const useHighlightCreation = ({
settings
)
console.log('✅ Highlight created successfully!', {
id: newHighlight.id,
isLocalOnly: newHighlight.isLocalOnly,
isOfflineCreated: newHighlight.isOfflineCreated,
publishedRelays: newHighlight.publishedRelays
})
// Highlight created successfully
// Clear the browser's text selection immediately to allow DOM update
const selection = window.getSelection()
@@ -92,10 +88,19 @@ export const useHighlightCreation = ({
})
} catch (error) {
console.error('❌ Failed to create highlight:', error)
// Show user-friendly error messages
const errorMessage = error instanceof Error ? error.message : 'Failed to create highlight'
if (errorMessage.toLowerCase().includes('permission') || errorMessage.toLowerCase().includes('unauthorized')) {
showToast('Reconnect bunker and approve signing permissions to create highlights')
} else {
showToast(`Failed to create highlight: ${errorMessage}`)
}
// Re-throw to allow parent to handle
throw error
}
}, [activeAccount, relayPool, eventStore, currentArticle, selectedUrl, readerContent, onHighlightCreated, settings])
}, [activeAccount, relayPool, eventStore, currentArticle, selectedUrl, readerContent, onHighlightCreated, settings, showToast])
return {
highlightButtonRef,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,40 +4,47 @@ interface UseReadingPositionOptions {
enabled?: boolean
onPositionChange?: (position: number) => 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
onSave?: (position: number) => void // Callback for saving position
autoSaveInterval?: number // Auto-save interval in ms (default 5000)
completionHoldMs?: number // How long to hold at 100% before firing complete (default 2000)
}
export const useReadingPosition = ({
enabled = true,
onPositionChange,
onReadingComplete,
readingCompleteThreshold = 0.9,
readingCompleteThreshold = 0.95, // Match filter threshold for consistency
syncEnabled = false,
onSave,
autoSaveInterval = 5000
autoSaveInterval = 5000,
completionHoldMs = 2000
}: UseReadingPositionOptions = {}) => {
const [position, setPosition] = useState(0)
const [isReadingComplete, setIsReadingComplete] = useState(false)
const hasTriggeredComplete = useRef(false)
const lastSavedPosition = useRef(0)
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const hasSavedOnce = useRef(false)
const completionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Debounced save function
const scheduleSave = useCallback((currentPosition: number) => {
if (!syncEnabled || !onSave) return
// Don't save if position is too low (< 5%)
if (currentPosition < 0.05) return
if (!syncEnabled || !onSave) {
return
}
// Don't save if position hasn't changed significantly (less than 1%)
// But always save if we've reached 100% (completion)
const hasSignificantChange = Math.abs(currentPosition - lastSavedPosition.current) >= 0.01
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
if (saveTimerRef.current) {
@@ -47,6 +54,7 @@ export const useReadingPosition = ({
// Schedule new save
saveTimerRef.current = setTimeout(() => {
lastSavedPosition.current = currentPosition
hasSavedOnce.current = true
onSave(currentPosition)
}, autoSaveInterval)
}, [syncEnabled, onSave, autoSaveInterval])
@@ -61,11 +69,10 @@ export const useReadingPosition = ({
saveTimerRef.current = null
}
// Save if position is meaningful (>= 5%)
if (position >= 0.05) {
lastSavedPosition.current = position
onSave(position)
}
// Always allow immediate save (including 0%)
lastSavedPosition.current = position
hasSavedOnce.current = true
onSave(position)
}, [syncEnabled, onSave, position])
useEffect(() => {
@@ -89,17 +96,46 @@ export const useReadingPosition = ({
const isAtBottom = scrollTop + windowHeight >= documentHeight - 5
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)
onPositionChange?.(clampedProgress)
// Schedule auto-save if sync is enabled
scheduleSave(clampedProgress)
// Check if reading is complete
if (clampedProgress >= readingCompleteThreshold && !hasTriggeredComplete.current) {
setIsReadingComplete(true)
hasTriggeredComplete.current = true
onReadingComplete?.()
// Completion detection with 2s hold at 100%
if (!hasTriggeredComplete.current) {
// If at exact 100%, start a hold timer; cancel if we scroll up
if (clampedProgress === 1) {
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) {
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])
// Reset reading complete state when enabled changes
@@ -126,6 +167,12 @@ export const useReadingPosition = ({
if (!enabled) {
setIsReadingComplete(false)
hasTriggeredComplete.current = false
hasSavedOnce.current = false
lastSavedPosition.current = 0
if (completionTimerRef.current) {
clearTimeout(completionTimerRef.current)
completionTimerRef.current = null
}
}
}, [enabled])

View File

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

View File

@@ -0,0 +1,33 @@
import { useMemo } from 'react'
import { useObservableMemo } from 'applesauce-react/hooks'
import { startWith } from 'rxjs'
import type { IEventStore } from 'applesauce-core'
import type { Filter, NostrEvent } from 'nostr-tools'
/**
* Subscribe to EventStore timeline and map events to app types
* Provides instant cached results, then updates reactively
*
* @param eventStore - The applesauce event store
* @param filter - Nostr filter to query
* @param mapEvent - Function to transform NostrEvent to app type
* @param deps - Dependencies for memoization
* @returns Array of mapped results
*/
export function useStoreTimeline<T>(
eventStore: IEventStore | null,
filter: Filter,
mapEvent: (event: NostrEvent) => T,
deps: unknown[] = []
): T[] {
const events = useObservableMemo(
() => eventStore ? eventStore.timeline(filter).pipe(startWith([])) : undefined,
[eventStore, ...deps]
)
return useMemo(
() => events?.map(mapEvent) ?? [],
[events, mapEvent]
)
}

View File

@@ -14,6 +14,7 @@
@import './styles/components/me.css';
@import './styles/components/pull-to-refresh.css';
@import './styles/components/skeletons.css';
@import './styles/components/login.css';
@import './styles/utils/animations.css';
@import './styles/utils/utilities.css';
@import './styles/utils/legacy.css';

View File

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

View File

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

View File

@@ -0,0 +1,438 @@
import { RelayPool } from 'applesauce-relay'
import { Helpers, EventStore } from 'applesauce-core'
import { createEventLoader, createAddressLoader } from 'applesauce-loaders/loaders'
import { NostrEvent } from 'nostr-tools'
import { EventPointer } from 'nostr-tools/nip19'
import { merge } from 'rxjs'
import { queryEvents } from './dataFetch'
import { KINDS } from '../config/kinds'
import { RELAYS } from '../config/relays'
import { collectBookmarksFromEvents } from './bookmarkProcessing'
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
import {
AccountWithExtension,
hydrateItems,
dedupeBookmarksById,
extractUrlsFromContent
} from './bookmarkHelpers'
/**
* Get unique key for event deduplication (from Debug)
*/
function getEventKey(evt: NostrEvent): string {
if (evt.kind === 30003 || evt.kind === 30001) {
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
return `${evt.kind}:${evt.pubkey}:${dTag}`
} else if (evt.kind === 10003) {
return `${evt.kind}:${evt.pubkey}`
}
return evt.id
}
/**
* Check if event has encrypted content (from Debug)
*/
function hasEncryptedContent(evt: NostrEvent): boolean {
if (Helpers.hasHiddenContent(evt)) return true
if (evt.content && evt.content.includes('?iv=')) return true
if (Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt)) return true
return false
}
type RawEventCallback = (event: NostrEvent) => void
type BookmarksCallback = (bookmarks: Bookmark[]) => void
type LoadingCallback = (loading: boolean) => void
type DecryptCompleteCallback = (eventId: string, publicCount: number, privateCount: number) => void
/**
* Shared bookmark streaming controller
* Encapsulates the Debug flow: stream events, dedupe, decrypt, build bookmarks
*/
class BookmarkController {
private rawEventListeners: RawEventCallback[] = []
private bookmarksListeners: BookmarksCallback[] = []
private loadingListeners: LoadingCallback[] = []
private decryptCompleteListeners: DecryptCompleteCallback[] = []
private currentEvents: Map<string, NostrEvent> = new Map()
private decryptedResults: Map<string, {
publicItems: IndividualBookmark[]
privateItems: IndividualBookmark[]
newestCreatedAt?: number
latestContent?: string
allTags?: string[][]
}> = new Map()
private isLoading = false
private hydrationGeneration = 0
// Event loaders for efficient batching
private eventStore = new EventStore()
private eventLoader: ReturnType<typeof createEventLoader> | null = null
private addressLoader: ReturnType<typeof createAddressLoader> | null = null
onRawEvent(cb: RawEventCallback): () => void {
this.rawEventListeners.push(cb)
return () => {
this.rawEventListeners = this.rawEventListeners.filter(l => l !== cb)
}
}
onBookmarks(cb: BookmarksCallback): () => void {
this.bookmarksListeners.push(cb)
return () => {
this.bookmarksListeners = this.bookmarksListeners.filter(l => l !== cb)
}
}
onLoading(cb: LoadingCallback): () => void {
this.loadingListeners.push(cb)
return () => {
this.loadingListeners = this.loadingListeners.filter(l => l !== cb)
}
}
onDecryptComplete(cb: DecryptCompleteCallback): () => void {
this.decryptCompleteListeners.push(cb)
return () => {
this.decryptCompleteListeners = this.decryptCompleteListeners.filter(l => l !== cb)
}
}
reset(): void {
this.hydrationGeneration++
this.currentEvents.clear()
this.decryptedResults.clear()
this.setLoading(false)
}
private setLoading(loading: boolean): void {
if (this.isLoading !== loading) {
this.isLoading = loading
this.loadingListeners.forEach(cb => cb(loading))
}
}
private emitRawEvent(evt: NostrEvent): void {
this.rawEventListeners.forEach(cb => cb(evt))
}
/**
* Hydrate events by IDs using EventLoader (auto-batching, streaming)
*/
private hydrateByIds(
ids: string[],
idToEvent: Map<string, NostrEvent>,
onProgress: () => void,
generation: number
): void {
if (!this.eventLoader) {
return
}
// Filter to unique IDs not already hydrated
const unique = Array.from(new Set(ids)).filter(id => !idToEvent.has(id))
if (unique.length === 0) {
return
}
// Convert IDs to EventPointers
const pointers: EventPointer[] = unique.map(id => ({ id }))
// Use EventLoader - it auto-batches and streams results
merge(...pointers.map(this.eventLoader)).subscribe({
next: (event) => {
// Check if hydration was cancelled
if (this.hydrationGeneration !== generation) return
idToEvent.set(event.id, event)
// Also index by coordinate for addressable events
if (event.kind && event.kind >= 30000 && event.kind < 40000) {
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
idToEvent.set(coordinate, event)
}
onProgress()
},
error: () => {
// Silent error - EventLoader handles retries
}
})
}
/**
* Hydrate addressable events by coordinates using AddressLoader (auto-batching, streaming)
*/
private hydrateByCoordinates(
coords: Array<{ kind: number; pubkey: string; identifier: string }>,
idToEvent: Map<string, NostrEvent>,
onProgress: () => void,
generation: number
): void {
if (!this.addressLoader) {
return
}
if (coords.length === 0) return
// Convert coordinates to AddressPointers
const pointers = coords.map(c => ({
kind: c.kind,
pubkey: c.pubkey,
identifier: c.identifier
}))
// Use AddressLoader - it auto-batches and streams results
merge(...pointers.map(this.addressLoader)).subscribe({
next: (event) => {
// Check if hydration was cancelled
if (this.hydrationGeneration !== generation) return
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
idToEvent.set(coordinate, event)
idToEvent.set(event.id, event)
onProgress()
},
error: () => {
// Silent error - AddressLoader handles retries
}
})
}
private async buildAndEmitBookmarks(
activeAccount: AccountWithExtension,
signerCandidate: unknown
): Promise<void> {
const allEvents = Array.from(this.currentEvents.values())
// Include unencrypted events OR encrypted events that have been decrypted
const readyEvents = allEvents.filter(evt => {
const isEncrypted = hasEncryptedContent(evt)
if (!isEncrypted) return true // Include unencrypted
// Include encrypted if already decrypted
return this.decryptedResults.has(getEventKey(evt))
})
if (readyEvents.length === 0) {
this.bookmarksListeners.forEach(cb => cb([]))
return
}
try {
// Separate unencrypted and decrypted events
const unencryptedEvents = readyEvents.filter(evt => !hasEncryptedContent(evt))
const decryptedEvents = readyEvents.filter(evt => hasEncryptedContent(evt))
// Process unencrypted events
const { publicItemsAll: publicUnencrypted, privateItemsAll: privateUnencrypted, newestCreatedAt, latestContent, allTags } =
await collectBookmarksFromEvents(unencryptedEvents, activeAccount, signerCandidate)
// Merge in decrypted results
let publicItemsAll = [...publicUnencrypted]
let privateItemsAll = [...privateUnencrypted]
decryptedEvents.forEach(evt => {
const eventKey = getEventKey(evt)
const decrypted = this.decryptedResults.get(eventKey)
if (decrypted) {
publicItemsAll = [...publicItemsAll, ...decrypted.publicItems]
privateItemsAll = [...privateItemsAll, ...decrypted.privateItems]
}
})
const allItems = [...publicItemsAll, ...privateItemsAll]
// Separate hex IDs from coordinates
const noteIds: string[] = []
const coordinates: string[] = []
allItems.forEach(i => {
if (/^[0-9a-f]{64}$/i.test(i.id)) {
noteIds.push(i.id)
} else if (i.id.includes(':')) {
coordinates.push(i.id)
}
})
// Helper to build and emit bookmarks
const emitBookmarks = (idToEvent: Map<string, NostrEvent>) => {
const allBookmarks = dedupeBookmarksById([
...hydrateItems(publicItemsAll, idToEvent),
...hydrateItems(privateItemsAll, idToEvent)
])
const enriched = allBookmarks.map(b => ({
...b,
tags: b.tags || [],
content: b.content || ''
}))
const sortedBookmarks = enriched
.map(b => ({ ...b, urlReferences: extractUrlsFromContent(b.content) }))
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
const bookmark: Bookmark = {
id: `${activeAccount.pubkey}-bookmarks`,
title: `Bookmarks (${sortedBookmarks.length})`,
url: '',
content: latestContent,
created_at: newestCreatedAt || Math.floor(Date.now() / 1000),
tags: allTags,
bookmarkCount: sortedBookmarks.length,
eventReferences: allTags.filter((tag: string[]) => tag[0] === 'e').map((tag: string[]) => tag[1]),
individualBookmarks: sortedBookmarks,
isPrivate: privateItemsAll.length > 0,
encryptedContent: undefined
}
this.bookmarksListeners.forEach(cb => cb([bookmark]))
}
// Emit immediately with empty metadata (show placeholders)
const idToEvent: Map<string, NostrEvent> = new Map()
emitBookmarks(idToEvent)
// Now fetch events progressively in background using batched hydrators
const generation = this.hydrationGeneration
const onProgress = () => emitBookmarks(idToEvent)
// Parse coordinates from strings to objects
const coordObjs = coordinates.map(c => {
const parts = c.split(':')
return {
kind: parseInt(parts[0]),
pubkey: parts[1],
identifier: parts[2] || ''
}
})
// Kick off batched hydration (streaming, non-blocking)
// EventLoader and AddressLoader handle batching and streaming automatically
this.hydrateByIds(noteIds, idToEvent, onProgress, generation)
this.hydrateByCoordinates(coordObjs, idToEvent, onProgress, generation)
} catch (error) {
console.error('Failed to build bookmarks:', error)
this.bookmarksListeners.forEach(cb => cb([]))
}
}
async start(options: {
relayPool: RelayPool
activeAccount: unknown
accountManager: { getActive: () => unknown }
}): Promise<void> {
const { relayPool, activeAccount, accountManager } = options
if (!activeAccount || typeof (activeAccount as { pubkey?: string }).pubkey !== 'string') {
return
}
const account = activeAccount as { pubkey: string; [key: string]: unknown }
// Increment generation to cancel any in-flight hydration
this.hydrationGeneration++
// Initialize loaders for this session
this.eventLoader = createEventLoader(relayPool, {
eventStore: this.eventStore,
extraRelays: RELAYS
})
this.addressLoader = createAddressLoader(relayPool, {
eventStore: this.eventStore,
extraRelays: RELAYS
})
this.setLoading(true)
try {
// Get signer for auto-decryption
const fullAccount = accountManager.getActive() as AccountWithExtension | null
const maybeAccount = (fullAccount || account) as AccountWithExtension
let signerCandidate: unknown = maybeAccount
const hasNip04Prop = (signerCandidate as { nip04?: unknown })?.nip04 !== undefined
const hasNip44Prop = (signerCandidate as { nip44?: unknown })?.nip44 !== undefined
if (signerCandidate && !hasNip04Prop && !hasNip44Prop && maybeAccount?.signer) {
signerCandidate = maybeAccount.signer
}
// Stream events with live deduplication (same as Debug)
await queryEvents(
relayPool,
{ kinds: [KINDS.ListSimple, KINDS.ListReplaceable, KINDS.List, KINDS.WebBookmark], authors: [account.pubkey] },
{
onEvent: (evt) => {
const key = getEventKey(evt)
const existing = this.currentEvents.get(key)
if (existing && (existing.created_at || 0) >= (evt.created_at || 0)) {
return // Keep existing (it's newer)
}
// Add/update event
this.currentEvents.set(key, evt)
// Emit raw event for Debug UI
this.emitRawEvent(evt)
// Build bookmarks immediately for unencrypted events
const isEncrypted = hasEncryptedContent(evt)
if (!isEncrypted) {
// For unencrypted events, build bookmarks immediately (progressive update)
this.buildAndEmitBookmarks(maybeAccount, signerCandidate)
.catch(() => {
// Silent error - will retry on next event
})
}
// Auto-decrypt if event has encrypted content (fire-and-forget, non-blocking)
if (isEncrypted) {
// Don't await - let it run in background
collectBookmarksFromEvents([evt], account, signerCandidate)
.then(({ publicItemsAll, privateItemsAll, newestCreatedAt, latestContent, allTags }) => {
const eventKey = getEventKey(evt)
// Store the actual decrypted items, not just counts
this.decryptedResults.set(eventKey, {
publicItems: publicItemsAll,
privateItems: privateItemsAll,
newestCreatedAt,
latestContent,
allTags
})
// Emit decrypt complete for Debug UI
this.decryptCompleteListeners.forEach(cb =>
cb(evt.id, publicItemsAll.length, privateItemsAll.length)
)
// Rebuild bookmarks with newly decrypted content (progressive update)
this.buildAndEmitBookmarks(maybeAccount, signerCandidate)
.catch(() => {
// Silent error - will retry on next event
})
})
.catch(() => {
// Silent error - decrypt failed
})
}
}
}
)
// Final update after EOSE
await this.buildAndEmitBookmarks(maybeAccount, signerCandidate)
} catch (error) {
console.error('Failed to load bookmarks:', error)
this.bookmarksListeners.forEach(cb => cb([]))
} finally {
this.setLoading(false)
}
}
}
// Singleton instance
export const bookmarkController = new BookmarkController()

View File

@@ -11,6 +11,96 @@ type UnlockHiddenTagsFn = typeof Helpers.unlockHiddenTags
type HiddenContentSigner = Parameters<UnlockHiddenTagsFn>[1]
type UnlockMode = Parameters<UnlockHiddenTagsFn>[2]
/**
* Decrypt/unlock a single event and return private bookmarks
*/
async function decryptEvent(
evt: NostrEvent,
activeAccount: ActiveAccount,
signerCandidate: unknown,
metadata: { dTag?: string; setTitle?: string; setDescription?: string; setImage?: string }
): Promise<IndividualBookmark[]> {
const { dTag, setTitle, setDescription, setImage } = metadata
const privateItems: IndividualBookmark[] = []
try {
if (Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt)) {
try {
await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner)
} catch {
try {
await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner, 'nip44' as UnlockMode)
} catch (_err) {
// Ignore unlock errors
}
}
} else if (evt.content && evt.content.length > 0) {
let decryptedContent: string | undefined
// Try to detect encryption method from content format
// NIP-44 starts with version byte (currently 0x02), NIP-04 is base64
const looksLikeNip44 = evt.content.length > 0 && !evt.content.includes('?iv=')
// Try the likely method first (no timeout - let it fail naturally like debug page)
if (looksLikeNip44 && hasNip44Decrypt(signerCandidate)) {
try {
decryptedContent = await (signerCandidate as { nip44: { decrypt: DecryptFn } }).nip44.decrypt(evt.pubkey, evt.content)
} catch (_err) {
// Ignore NIP-44 decryption errors
}
}
// Fallback to nip04 if nip44 failed or content looks like nip04
if (!decryptedContent && hasNip04Decrypt(signerCandidate)) {
try {
decryptedContent = await (signerCandidate as { nip04: { decrypt: DecryptFn } }).nip04.decrypt(evt.pubkey, evt.content)
} catch (_err) {
// Ignore NIP-04 decryption errors
}
}
if (decryptedContent) {
try {
const hiddenTags = JSON.parse(decryptedContent) as string[][]
const manualPrivate = Helpers.parseBookmarkTags(hiddenTags)
privateItems.push(
...processApplesauceBookmarks(manualPrivate, activeAccount, true).map(i => ({
...i,
sourceKind: evt.kind,
setName: dTag,
setTitle,
setDescription,
setImage
}))
)
Reflect.set(evt, BookmarkHiddenSymbol, manualPrivate)
Reflect.set(evt, 'EncryptedContentSymbol', decryptedContent)
} catch (err) {
// ignore parse errors
}
}
}
const priv = Helpers.getHiddenBookmarks(evt)
if (priv) {
privateItems.push(
...processApplesauceBookmarks(priv, activeAccount, true).map(i => ({
...i,
sourceKind: evt.kind,
setName: dTag,
setTitle,
setDescription,
setImage
}))
)
}
} catch {
// ignore individual event failures
}
return privateItems
}
export async function collectBookmarksFromEvents(
bookmarkListEvents: NostrEvent[],
activeAccount: ActiveAccount,
@@ -23,21 +113,23 @@ export async function collectBookmarksFromEvents(
allTags: string[][]
}> {
const publicItemsAll: IndividualBookmark[] = []
const privateItemsAll: IndividualBookmark[] = []
let newestCreatedAt = 0
let latestContent = ''
let allTags: string[][] = []
// Build list of events needing decrypt and collect public items immediately
const decryptJobs: Array<{ evt: NostrEvent; metadata: { dTag?: string; setTitle?: string; setDescription?: string; setImage?: string } }> = []
for (const evt of bookmarkListEvents) {
newestCreatedAt = Math.max(newestCreatedAt, evt.created_at || 0)
if (!latestContent && evt.content && !Helpers.hasHiddenContent(evt)) latestContent = evt.content
if (Array.isArray(evt.tags)) allTags = allTags.concat(evt.tags)
// Extract the 'd' tag and metadata for bookmark sets (kind 30003)
const dTag = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] : undefined
const setTitle = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'title')?.[1] : undefined
const setDescription = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'description')?.[1] : undefined
const setImage = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'image')?.[1] : undefined
const metadata = { dTag, setTitle, setDescription, setImage }
// Handle web bookmarks (kind:39701) as individual bookmarks
if (evt.kind === 39701) {
@@ -73,69 +165,22 @@ export async function collectBookmarksFromEvents(
}))
)
try {
if (Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt) && signerCandidate) {
try {
await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner)
} catch {
try {
await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner, 'nip44' as UnlockMode)
} catch {
// ignore
}
}
} else if (evt.content && evt.content.length > 0 && signerCandidate) {
let decryptedContent: string | undefined
try {
if (hasNip44Decrypt(signerCandidate)) {
decryptedContent = await (signerCandidate as { nip44: { decrypt: DecryptFn } }).nip44.decrypt(
evt.pubkey,
evt.content
)
}
} catch {
// ignore
}
if (!decryptedContent) {
try {
if (hasNip04Decrypt(signerCandidate)) {
decryptedContent = await (signerCandidate as { nip04: { decrypt: DecryptFn } }).nip04.decrypt(
evt.pubkey,
evt.content
)
}
} catch {
// ignore
}
}
if (decryptedContent) {
try {
const hiddenTags = JSON.parse(decryptedContent) as string[][]
const manualPrivate = Helpers.parseBookmarkTags(hiddenTags)
privateItemsAll.push(
...processApplesauceBookmarks(manualPrivate, activeAccount, true).map(i => ({
...i,
sourceKind: evt.kind,
setName: dTag,
setTitle,
setDescription,
setImage
}))
)
Reflect.set(evt, BookmarkHiddenSymbol, manualPrivate)
Reflect.set(evt, 'EncryptedContentSymbol', decryptedContent)
// Don't set latestContent to decrypted JSON - it's not user-facing content
} catch {
// ignore
}
}
}
// Schedule decrypt if needed
// Check for NIP-44 (Helpers.hasHiddenContent), NIP-04 (?iv= in content), or encrypted tags
const hasNip04Content = evt.content && evt.content.includes('?iv=')
const needsDecrypt = signerCandidate && (
(Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt)) ||
Helpers.hasHiddenContent(evt) ||
hasNip04Content
)
if (needsDecrypt) {
decryptJobs.push({ evt, metadata })
} else {
// Check for already-unlocked hidden bookmarks
const priv = Helpers.getHiddenBookmarks(evt)
if (priv) {
privateItemsAll.push(
publicItemsAll.push(
...processApplesauceBookmarks(priv, activeAccount, true).map(i => ({
...i,
sourceKind: evt.kind,
@@ -146,8 +191,17 @@ export async function collectBookmarksFromEvents(
}))
)
}
} catch {
// ignore individual event failures
}
}
// Decrypt events sequentially
const privateItemsAll: IndividualBookmark[] = []
if (decryptJobs.length > 0 && signerCandidate) {
for (const job of decryptJobs) {
const privateItems = await decryptEvent(job.evt, activeAccount, signerCandidate, job.metadata)
if (privateItems && privateItems.length > 0) {
privateItemsAll.push(...privateItems)
}
}
}

View File

@@ -1,233 +0,0 @@
import { RelayPool } from 'applesauce-relay'
import {
AccountWithExtension,
NostrEvent,
dedupeNip51Events,
hydrateItems,
isAccountWithExtension,
hasNip04Decrypt,
hasNip44Decrypt,
dedupeBookmarksById,
extractUrlsFromContent
} from './bookmarkHelpers'
import { Bookmark } from '../types/bookmarks'
import { collectBookmarksFromEvents } from './bookmarkProcessing.ts'
import { UserSettings } from './settingsService'
import { rebroadcastEvents } from './rebroadcastService'
import { queryEvents } from './dataFetch'
export const fetchBookmarks = async (
relayPool: RelayPool,
activeAccount: unknown, // Full account object with extension capabilities
setBookmarks: (bookmarks: Bookmark[]) => void,
settings?: UserSettings
) => {
try {
if (!isAccountWithExtension(activeAccount)) {
throw new Error('Invalid account object provided')
}
// Fetch bookmark events - NIP-51 standards, legacy formats, and web bookmarks (NIP-B0)
console.log('🔍 Fetching bookmark events')
const rawEvents = await queryEvents(
relayPool,
{ kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] },
{}
)
console.log('📊 Raw events fetched:', rawEvents.length, 'events')
// Rebroadcast bookmark events to local/all relays based on settings
await rebroadcastEvents(rawEvents, relayPool, settings)
// Check for events with potentially encrypted content
const eventsWithContent = rawEvents.filter(evt => evt.content && evt.content.length > 0)
if (eventsWithContent.length > 0) {
console.log('🔐 Events with content (potentially encrypted):', eventsWithContent.length)
eventsWithContent.forEach((evt, i) => {
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || 'none'
const contentPreview = evt.content.slice(0, 60) + (evt.content.length > 60 ? '...' : '')
console.log(` Encrypted Event ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag=${dTag}, contentLength=${evt.content.length}, preview=${contentPreview}`)
})
}
rawEvents.forEach((evt, i) => {
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || 'none'
const contentPreview = evt.content ? evt.content.slice(0, 50) + (evt.content.length > 50 ? '...' : '') : 'empty'
const eTags = evt.tags?.filter((t: string[]) => t[0] === 'e').length || 0
const aTags = evt.tags?.filter((t: string[]) => t[0] === 'a').length || 0
console.log(` Event ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag=${dTag}, contentLength=${evt.content?.length || 0}, eTags=${eTags}, aTags=${aTags}, contentPreview=${contentPreview}`)
})
const bookmarkListEvents = dedupeNip51Events(rawEvents)
console.log('📋 After deduplication:', bookmarkListEvents.length, 'bookmark events')
// Log which events made it through deduplication
bookmarkListEvents.forEach((evt, i) => {
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || 'none'
console.log(` Dedupe ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag="${dTag}"`)
})
// Check specifically for Primal's "reads" list
const primalReads = rawEvents.find(e => e.kind === 10003 && e.tags?.find((t: string[]) => t[0] === 'd' && t[1] === 'reads'))
if (primalReads) {
console.log('✅ Found Primal reads list:', primalReads.id.slice(0, 8))
} else {
console.log('❌ No Primal reads list found (kind:10003 with d="reads")')
}
if (bookmarkListEvents.length === 0) {
// Keep existing bookmarks visible; do not clear list if nothing new found
return
}
// Aggregate across events
const maybeAccount = activeAccount as AccountWithExtension
console.log('🔐 Account object:', {
hasSignEvent: typeof maybeAccount?.signEvent === 'function',
hasSigner: !!maybeAccount?.signer,
accountType: typeof maybeAccount,
accountKeys: maybeAccount ? Object.keys(maybeAccount) : []
})
// For ExtensionAccount, we need a signer with nip04/nip44 for decrypting hidden content
// The ExtensionAccount itself has nip04/nip44 getters that proxy to the signer
let signerCandidate: unknown = maybeAccount
const hasNip04Prop = (signerCandidate as { nip04?: unknown })?.nip04 !== undefined
const hasNip44Prop = (signerCandidate as { nip44?: unknown })?.nip44 !== undefined
if (signerCandidate && !hasNip04Prop && !hasNip44Prop && maybeAccount?.signer) {
// Fallback to the raw signer if account doesn't have nip04/nip44
signerCandidate = maybeAccount.signer
}
console.log('🔑 Signer candidate:', !!signerCandidate, typeof signerCandidate)
if (signerCandidate) {
console.log('🔑 Signer has nip04:', hasNip04Decrypt(signerCandidate))
console.log('🔑 Signer has nip44:', hasNip44Decrypt(signerCandidate))
}
const { publicItemsAll, privateItemsAll, newestCreatedAt, latestContent, allTags } = await collectBookmarksFromEvents(
bookmarkListEvents,
activeAccount,
signerCandidate
)
const allItems = [...publicItemsAll, ...privateItemsAll]
// Separate hex IDs (regular events) from coordinates (addressable events)
const noteIds: string[] = []
const coordinates: string[] = []
allItems.forEach(i => {
// Check if it's a hex ID (64 character hex string)
if (/^[0-9a-f]{64}$/i.test(i.id)) {
noteIds.push(i.id)
} else if (i.id.includes(':')) {
// Coordinate format: kind:pubkey:identifier
coordinates.push(i.id)
}
})
const idToEvent: Map<string, NostrEvent> = new Map()
// Fetch regular events by ID
if (noteIds.length > 0) {
try {
const events = await queryEvents(
relayPool,
{ ids: Array.from(new Set(noteIds)) },
{ localTimeoutMs: 800, remoteTimeoutMs: 2500 }
)
events.forEach((e: NostrEvent) => {
idToEvent.set(e.id, e)
// Also store by coordinate if it's an addressable event
if (e.kind && e.kind >= 30000 && e.kind < 40000) {
const dTag = e.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
const coordinate = `${e.kind}:${e.pubkey}:${dTag}`
idToEvent.set(coordinate, e)
}
})
} catch (error) {
console.warn('Failed to fetch events by ID:', error)
}
}
// Fetch addressable events by coordinates
if (coordinates.length > 0) {
try {
// Group by kind for more efficient querying
const byKind = new Map<number, Array<{ pubkey: string; identifier: string }>>()
coordinates.forEach(coord => {
const parts = coord.split(':')
const kind = parseInt(parts[0])
const pubkey = parts[1]
const identifier = parts[2] || ''
if (!byKind.has(kind)) {
byKind.set(kind, [])
}
byKind.get(kind)!.push({ pubkey, identifier })
})
// Query each kind group
for (const [kind, items] of byKind.entries()) {
const authors = Array.from(new Set(items.map(i => i.pubkey)))
const identifiers = Array.from(new Set(items.map(i => i.identifier)))
const events = await queryEvents(
relayPool,
{ kinds: [kind], authors, '#d': identifiers },
{ localTimeoutMs: 800, remoteTimeoutMs: 2500 }
)
events.forEach((e: NostrEvent) => {
const dTag = e.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
const coordinate = `${e.kind}:${e.pubkey}:${dTag}`
idToEvent.set(coordinate, e)
// Also store by event ID
idToEvent.set(e.id, e)
})
}
} catch (error) {
console.warn('Failed to fetch addressable events:', error)
}
}
console.log(`📦 Hydration: fetched ${idToEvent.size} events for ${allItems.length} bookmarks (${noteIds.length} notes, ${coordinates.length} articles)`)
const allBookmarks = dedupeBookmarksById([
...hydrateItems(publicItemsAll, idToEvent),
...hydrateItems(privateItemsAll, idToEvent)
])
// Sort individual bookmarks by "added" timestamp first (most recently added first),
// falling back to event created_at when unknown.
const enriched = allBookmarks.map(b => ({
...b,
tags: b.tags || [],
content: b.content || ''
}))
const sortedBookmarks = enriched
.map(b => ({ ...b, urlReferences: extractUrlsFromContent(b.content) }))
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
const bookmark: Bookmark = {
id: `${activeAccount.pubkey}-bookmarks`,
title: `Bookmarks (${sortedBookmarks.length})`,
url: '',
content: latestContent,
created_at: newestCreatedAt || Math.floor(Date.now() / 1000),
tags: allTags,
bookmarkCount: sortedBookmarks.length,
eventReferences: allTags.filter((tag: string[]) => tag[0] === 'e').map((tag: string[]) => tag[1]),
individualBookmarks: sortedBookmarks,
isPrivate: privateItemsAll.length > 0,
encryptedContent: undefined
}
setBookmarks([bookmark])
} catch (error) {
console.error('Failed to fetch bookmarks:', error)
}
}

View File

@@ -1,7 +1,6 @@
import { RelayPool } from 'applesauce-relay'
import { prioritizeLocalRelays } from '../utils/helpers'
import { queryEvents } from './dataFetch'
import { CONTACTS_REMOTE_TIMEOUT_MS } from '../config/network'
/**
* Fetches the contact list (follows) for a specific user
@@ -16,7 +15,6 @@ export const fetchContacts = async (
): Promise<Set<string>> => {
try {
const relayUrls = prioritizeLocalRelays(Array.from(relayPool.relays.values()).map(relay => relay.url))
console.log('🔍 Fetching contacts (kind 3) for user:', pubkey)
const partialFollowed = new Set<string>()
const events = await queryEvents(
@@ -24,7 +22,6 @@ export const fetchContacts = async (
{ kinds: [3], authors: [pubkey] },
{
relayUrls,
remoteTimeoutMs: CONTACTS_REMOTE_TIMEOUT_MS,
onEvent: (event: { created_at: number; tags: string[][] }) => {
// Stream partials as we see any contact list
for (const tag of event.tags) {
@@ -53,9 +50,7 @@ export const fetchContacts = async (
}
// merged already via streams
console.log('📊 Contact events fetched:', events.length)
console.log('👥 Followed contacts:', followed.size)
return followed
} catch (error) {
console.error('Failed to fetch contacts:', error)

View File

@@ -0,0 +1,110 @@
import { RelayPool } from 'applesauce-relay'
import { fetchContacts } from './contactService'
type ContactsCallback = (contacts: Set<string>) => void
type LoadingCallback = (loading: boolean) => void
/**
* Shared contacts/friends controller
* Manages the user's follow list centrally, similar to bookmarkController
*/
class ContactsController {
private contactsListeners: ContactsCallback[] = []
private loadingListeners: LoadingCallback[] = []
private currentContacts: Set<string> = new Set()
private lastLoadedPubkey: string | null = null
onContacts(cb: ContactsCallback): () => void {
this.contactsListeners.push(cb)
return () => {
this.contactsListeners = this.contactsListeners.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 emitContacts(contacts: Set<string>): void {
this.contactsListeners.forEach(cb => cb(contacts))
}
/**
* Get current contacts without triggering a reload
*/
getContacts(): Set<string> {
return new Set(this.currentContacts)
}
/**
* Check if contacts are loaded for a specific pubkey
*/
isLoadedFor(pubkey: string): boolean {
return this.lastLoadedPubkey === pubkey && this.currentContacts.size > 0
}
/**
* Reset state (for logout or manual refresh)
*/
reset(): void {
this.currentContacts.clear()
this.lastLoadedPubkey = null
this.emitContacts(this.currentContacts)
}
/**
* Load contacts for a user
* Streams partial results and caches the final list
*/
async start(options: {
relayPool: RelayPool
pubkey: string
force?: boolean
}): Promise<void> {
const { relayPool, pubkey, force = false } = options
// Skip if already loaded for this pubkey (unless forced)
if (!force && this.isLoadedFor(pubkey)) {
this.emitContacts(this.currentContacts)
return
}
this.setLoading(true)
try {
const contacts = await fetchContacts(
relayPool,
pubkey,
(partial) => {
// Stream partial updates
this.currentContacts = new Set(partial)
this.emitContacts(this.currentContacts)
}
)
// Store final result
this.currentContacts = new Set(contacts)
this.lastLoadedPubkey = pubkey
this.emitContacts(this.currentContacts)
} catch (error) {
console.error('[contacts] ❌ Failed to load contacts:', error)
this.currentContacts.clear()
this.emitContacts(this.currentContacts)
} finally {
this.setLoading(false)
}
}
}
// Singleton instance
export const contactsController = new ContactsController()

View File

@@ -1,20 +1,18 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { Observable, merge, takeUntil, timer, toArray, tap, lastValueFrom } from 'rxjs'
import { Observable, merge, toArray, tap, lastValueFrom } from 'rxjs'
import { NostrEvent } from 'nostr-tools'
import { Filter } from 'nostr-tools/filter'
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
import { LOCAL_TIMEOUT_MS, REMOTE_TIMEOUT_MS } from '../config/network'
export interface QueryOptions {
relayUrls?: string[]
localTimeoutMs?: number
remoteTimeoutMs?: number
onEvent?: (event: NostrEvent) => void
}
/**
* Unified local-first query helper with optional streaming callback.
* Returns all collected events (deduped by id) after both streams complete or time out.
* Returns all collected events (deduped by id) after both streams complete (EOSE).
* Trusts relay EOSE signals - no artificial timeouts.
*/
export async function queryEvents(
relayPool: RelayPool,
@@ -23,8 +21,6 @@ export async function queryEvents(
): Promise<NostrEvent[]> {
const {
relayUrls,
localTimeoutMs = LOCAL_TIMEOUT_MS,
remoteTimeoutMs = REMOTE_TIMEOUT_MS,
onEvent
} = options
@@ -41,8 +37,7 @@ export async function queryEvents(
.pipe(
onlyEvents(),
onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}),
completeOnEose(),
takeUntil(timer(localTimeoutMs))
completeOnEose()
) as unknown as Observable<NostrEvent>
: new Observable<NostrEvent>((sub) => sub.complete())
@@ -52,8 +47,7 @@ export async function queryEvents(
.pipe(
onlyEvents(),
onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}),
completeOnEose(),
takeUntil(timer(remoteTimeoutMs))
completeOnEose()
) as unknown as Observable<NostrEvent>
: new Observable<NostrEvent>((sub) => sub.complete())

View File

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

View File

@@ -2,6 +2,7 @@ import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { queryEvents } from './dataFetch'
import { KINDS } from '../config/kinds'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
@@ -19,29 +20,34 @@ export interface BlogPostPreview {
* @param relayPool - The relay pool to query
* @param pubkeys - Array of pubkeys to fetch posts from
* @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
*/
export const fetchBlogPostsFromAuthors = async (
relayPool: RelayPool,
pubkeys: string[],
relayUrls: string[],
onPost?: (post: BlogPostPreview) => void
onPost?: (post: BlogPostPreview) => void,
limit: number | null = 100
): Promise<BlogPostPreview[]> => {
try {
if (pubkeys.length === 0) {
console.log('⚠️ No pubkeys to fetch blog posts from')
return []
}
console.log('📚 Fetching blog posts (kind 30023) from', pubkeys.length, 'authors')
// Deduplicate replaceable events by keeping the most recent version
// Group by author + d-tag identifier
const uniqueEvents = new Map<string, NostrEvent>()
const filter = limit !== null
? { kinds: [KINDS.BlogPost], authors: pubkeys, limit }
: { kinds: [KINDS.BlogPost], authors: pubkeys }
await queryEvents(
relayPool,
{ kinds: [30023], authors: pubkeys, limit: 100 },
filter,
{
relayUrls,
onEvent: (event: NostrEvent) => {
@@ -67,7 +73,6 @@ export const fetchBlogPostsFromAuthors = async (
}
)
console.log('📊 Blog post events fetched (unique):', uniqueEvents.size)
// Convert to blog post previews and sort by published date (most recent first)
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
@@ -89,7 +94,6 @@ export const fetchBlogPostsFromAuthors = async (
return timeB - timeA // Most recent first
})
console.log('📰 Processed', blogPosts.length, 'unique blog posts')
return blogPosts
} catch (error) {

View File

@@ -46,7 +46,7 @@ export async function createHighlight(
}
// Create EventFactory with the account as signer
const factory = new EventFactory({ signer: account })
const factory = new EventFactory({ signer: account.signer })
let blueprintSource: NostrEvent | AddressPointer | string
let context: string | undefined

View File

@@ -0,0 +1,96 @@
import { Highlight } from '../../types/highlights'
interface CacheEntry {
highlights: Highlight[]
timestamp: number
}
/**
* Simple in-memory session cache for highlight queries with TTL
*/
class HighlightCache {
private cache = new Map<string, CacheEntry>()
private ttlMs = 60000 // 60 seconds
/**
* Generate cache key for article coordinate
*/
articleKey(coordinate: string): string {
return `article:${coordinate}`
}
/**
* Generate cache key for URL
*/
urlKey(url: string): string {
// Normalize URL for consistent caching
try {
const normalized = new URL(url)
normalized.hash = '' // Remove hash
return `url:${normalized.toString()}`
} catch {
return `url:${url}`
}
}
/**
* Generate cache key for author pubkey
*/
authorKey(pubkey: string): string {
return `author:${pubkey}`
}
/**
* Get cached highlights if not expired
*/
get(key: string): Highlight[] | null {
const entry = this.cache.get(key)
if (!entry) return null
const now = Date.now()
if (now - entry.timestamp > this.ttlMs) {
this.cache.delete(key)
return null
}
return entry.highlights
}
/**
* Store highlights in cache
*/
set(key: string, highlights: Highlight[]): void {
this.cache.set(key, {
highlights,
timestamp: Date.now()
})
}
/**
* Clear specific cache entry
*/
clear(key: string): void {
this.cache.delete(key)
}
/**
* Clear all cache entries
*/
clearAll(): void {
this.cache.clear()
}
/**
* Get cache stats
*/
stats(): { size: number; keys: string[] } {
return {
size: this.cache.size,
keys: Array.from(this.cache.keys())
}
}
}
// Singleton instance
export const highlightCache = new HighlightCache()

View File

@@ -1,60 +1,75 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { IEventStore } from 'applesauce-core'
import { Highlight } from '../../types/highlights'
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
import { UserSettings } from '../settingsService'
import { rebroadcastEvents } from '../rebroadcastService'
import { KINDS } from '../../config/kinds'
import { queryEvents } from '../dataFetch'
import { highlightCache } from './cache'
export const fetchHighlights = async (
relayPool: RelayPool,
pubkey: string,
onHighlight?: (highlight: Highlight) => void,
settings?: UserSettings
settings?: UserSettings,
force = false,
eventStore?: IEventStore
): Promise<Highlight[]> => {
// Check cache first unless force refresh
if (!force) {
const cacheKey = highlightCache.authorKey(pubkey)
const cached = highlightCache.get(cacheKey)
if (cached) {
// Stream cached highlights if callback provided
if (onHighlight) {
cached.forEach(h => onHighlight(h))
}
return cached
}
}
try {
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const ordered = prioritizeLocalRelays(relayUrls)
const { local: localRelays, remote: remoteRelays } = partitionRelays(ordered)
const seenIds = new Set<string>()
const local$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [9802], authors: [pubkey] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
if (!seenIds.has(event.id)) {
seenIds.add(event.id)
if (onHighlight) onHighlight(eventToHighlight(event))
}
}),
completeOnEose(),
takeUntil(timer(1200))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const remote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [9802], authors: [pubkey] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
if (!seenIds.has(event.id)) {
seenIds.add(event.id)
if (onHighlight) onHighlight(eventToHighlight(event))
}
}),
completeOnEose(),
takeUntil(timer(6000))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
const rawEvents: NostrEvent[] = await queryEvents(
relayPool,
{ kinds: [KINDS.Highlights], authors: [pubkey] },
{
onEvent: (event: NostrEvent) => {
if (seenIds.has(event.id)) return
seenIds.add(event.id)
// Store in event store if provided
if (eventStore) {
eventStore.add(event)
}
if (onHighlight) onHighlight(eventToHighlight(event))
}
}
)
// Store all events in event store if provided
if (eventStore) {
rawEvents.forEach(evt => eventStore.add(evt))
}
try {
await rebroadcastEvents(rawEvents, relayPool, settings)
} catch (err) {
console.warn('Failed to rebroadcast highlight events:', err)
}
await rebroadcastEvents(rawEvents, relayPool, settings)
const uniqueEvents = dedupeHighlights(rawEvents)
const highlights = uniqueEvents.map(eventToHighlight)
return sortHighlights(highlights)
const sorted = sortHighlights(highlights)
// Cache the results
const cacheKey = highlightCache.authorKey(pubkey)
highlightCache.set(cacheKey, sorted)
return sorted
} catch {
return []
}

View File

@@ -1,95 +1,79 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { IEventStore } from 'applesauce-core'
import { Highlight } from '../../types/highlights'
import { RELAYS } from '../../config/relays'
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
import { KINDS } from '../../config/kinds'
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
import { UserSettings } from '../settingsService'
import { rebroadcastEvents } from '../rebroadcastService'
import { queryEvents } from '../dataFetch'
import { highlightCache } from './cache'
export const fetchHighlightsForArticle = async (
relayPool: RelayPool,
articleCoordinate: string,
eventId?: string,
onHighlight?: (highlight: Highlight) => void,
settings?: UserSettings
settings?: UserSettings,
force = false,
eventStore?: IEventStore
): Promise<Highlight[]> => {
// Check cache first unless force refresh
if (!force) {
const cacheKey = highlightCache.articleKey(articleCoordinate)
const cached = highlightCache.get(cacheKey)
if (cached) {
// Stream cached highlights if callback provided
if (onHighlight) {
cached.forEach(h => onHighlight(h))
}
return cached
}
}
try {
const seenIds = new Set<string>()
const processEvent = (event: NostrEvent): Highlight | null => {
if (seenIds.has(event.id)) return null
const onEvent = (event: NostrEvent) => {
if (seenIds.has(event.id)) return
seenIds.add(event.id)
return eventToHighlight(event)
// Store in event store if provided
if (eventStore) {
eventStore.add(event)
}
if (onHighlight) onHighlight(eventToHighlight(event))
}
const orderedRelays = prioritizeLocalRelays(RELAYS)
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
const aLocal$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [9802], '#a': [articleCoordinate] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
const highlight = processEvent(event)
if (highlight && onHighlight) onHighlight(highlight)
}),
completeOnEose(),
takeUntil(timer(1200))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const aRemote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [9802], '#a': [articleCoordinate] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
const highlight = processEvent(event)
if (highlight && onHighlight) onHighlight(highlight)
}),
completeOnEose(),
takeUntil(timer(6000))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const aTagEvents: NostrEvent[] = await lastValueFrom(merge(aLocal$, aRemote$).pipe(toArray()))
let eTagEvents: NostrEvent[] = []
if (eventId) {
const eLocal$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [9802], '#e': [eventId] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
const highlight = processEvent(event)
if (highlight && onHighlight) onHighlight(highlight)
}),
completeOnEose(),
takeUntil(timer(1200))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const eRemote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [9802], '#e': [eventId] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
const highlight = processEvent(event)
if (highlight && onHighlight) onHighlight(highlight)
}),
completeOnEose(),
takeUntil(timer(6000))
)
: new Observable<NostrEvent>((sub) => sub.complete())
eTagEvents = await lastValueFrom(merge(eLocal$, eRemote$).pipe(toArray()))
}
// Query for both #a and #e tags in parallel
const [aTagEvents, eTagEvents] = await Promise.all([
queryEvents(relayPool, { kinds: [KINDS.Highlights], '#a': [articleCoordinate] }, { onEvent }),
eventId
? queryEvents(relayPool, { kinds: [KINDS.Highlights], '#e': [eventId] }, { onEvent })
: Promise.resolve([] as NostrEvent[])
])
const rawEvents = [...aTagEvents, ...eTagEvents]
await rebroadcastEvents(rawEvents, relayPool, settings)
// Store all events in event store if provided
if (eventStore) {
rawEvents.forEach(evt => eventStore.add(evt))
}
try {
await rebroadcastEvents(rawEvents, relayPool, settings)
} catch (err) {
console.warn('Failed to rebroadcast highlight events:', err)
}
const uniqueEvents = dedupeHighlights(rawEvents)
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
return sortHighlights(highlights)
const sorted = sortHighlights(highlights)
// Cache the results
const cacheKey = highlightCache.articleKey(articleCoordinate)
highlightCache.set(cacheKey, sorted)
return sorted
} catch {
return []
}

View File

@@ -1,68 +1,78 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { IEventStore } from 'applesauce-core'
import { Highlight } from '../../types/highlights'
import { RELAYS } from '../../config/relays'
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
import { KINDS } from '../../config/kinds'
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
import { UserSettings } from '../settingsService'
import { rebroadcastEvents } from '../rebroadcastService'
import { queryEvents } from '../dataFetch'
import { highlightCache } from './cache'
export const fetchHighlightsForUrl = async (
relayPool: RelayPool,
url: string,
onHighlight?: (highlight: Highlight) => void,
settings?: UserSettings
settings?: UserSettings,
force = false,
eventStore?: IEventStore
): Promise<Highlight[]> => {
const seenIds = new Set<string>()
const orderedRelaysUrl = prioritizeLocalRelays(RELAYS)
const { local: localRelaysUrl, remote: remoteRelaysUrl } = partitionRelays(orderedRelaysUrl)
// Check cache first unless force refresh
if (!force) {
const cacheKey = highlightCache.urlKey(url)
const cached = highlightCache.get(cacheKey)
if (cached) {
// Stream cached highlights if callback provided
if (onHighlight) {
cached.forEach(h => onHighlight(h))
}
return cached
}
}
try {
const local$ = localRelaysUrl.length > 0
? relayPool
.req(localRelaysUrl, { kinds: [9802], '#r': [url] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
seenIds.add(event.id)
if (onHighlight) onHighlight(eventToHighlight(event))
}),
completeOnEose(),
takeUntil(timer(1200))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const remote$ = remoteRelaysUrl.length > 0
? relayPool
.req(remoteRelaysUrl, { kinds: [9802], '#r': [url] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
seenIds.add(event.id)
if (onHighlight) onHighlight(eventToHighlight(event))
}),
completeOnEose(),
takeUntil(timer(6000))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
console.log(`📌 Fetched ${rawEvents.length} highlight events for URL:`, url)
const seenIds = new Set<string>()
const rawEvents: NostrEvent[] = await queryEvents(
relayPool,
{ kinds: [KINDS.Highlights], '#r': [url] },
{
onEvent: (event: NostrEvent) => {
if (seenIds.has(event.id)) return
seenIds.add(event.id)
// Store in event store if provided
if (eventStore) {
eventStore.add(event)
}
if (onHighlight) onHighlight(eventToHighlight(event))
}
}
)
// Store all events in event store if provided
if (eventStore) {
rawEvents.forEach(evt => eventStore.add(evt))
}
// Rebroadcast events - but don't let errors here break the highlight display
try {
await rebroadcastEvents(rawEvents, relayPool, settings)
} catch (err) {
console.warn('Failed to rebroadcast highlight events:', err)
}
const uniqueEvents = dedupeHighlights(rawEvents)
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
return sortHighlights(highlights)
const sorted = sortHighlights(highlights)
// Cache the results
const cacheKey = highlightCache.urlKey(url)
highlightCache.set(cacheKey, sorted)
return sorted
} catch (err) {
console.error('Error fetching highlights for URL:', err)
// Return highlights that were already streamed via callback
// Don't return empty array as that would clear already-displayed highlights
return []
}
}

View File

@@ -1,5 +1,6 @@
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { IEventStore } from 'applesauce-core'
import { Highlight } from '../../types/highlights'
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
import { queryEvents } from '../dataFetch'
@@ -9,20 +10,20 @@ import { queryEvents } from '../dataFetch'
* @param relayPool - The relay pool to query
* @param pubkeys - Array of pubkeys to fetch highlights from
* @param onHighlight - Optional callback for streaming highlights as they arrive
* @param eventStore - Optional event store to persist events
* @returns Array of highlights
*/
export const fetchHighlightsFromAuthors = async (
relayPool: RelayPool,
pubkeys: string[],
onHighlight?: (highlight: Highlight) => void
onHighlight?: (highlight: Highlight) => void,
eventStore?: IEventStore
): Promise<Highlight[]> => {
try {
if (pubkeys.length === 0) {
console.log('⚠️ No pubkeys to fetch highlights from')
return []
}
console.log('💡 Fetching highlights (kind 9802) from', pubkeys.length, 'authors')
const seenIds = new Set<string>()
const rawEvents = await queryEvents(
@@ -32,16 +33,26 @@ export const fetchHighlightsFromAuthors = async (
onEvent: (event: NostrEvent) => {
if (!seenIds.has(event.id)) {
seenIds.add(event.id)
// Store in event store if provided
if (eventStore) {
eventStore.add(event)
}
if (onHighlight) onHighlight(eventToHighlight(event))
}
}
}
)
// Store all events in event store if provided
if (eventStore) {
rawEvents.forEach(evt => eventStore.add(evt))
}
const uniqueEvents = dedupeHighlights(rawEvents)
const highlights = uniqueEvents.map(eventToHighlight)
console.log('💡 Processed', highlights.length, 'unique highlights')
return sortHighlights(highlights)
} catch (error) {

View File

@@ -0,0 +1,203 @@
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 = 'highlights_last_synced'
/**
* Shared highlights controller
* Manages the user's highlights centrally, similar to bookmarkController
*/
class HighlightsController {
private highlightsListeners: HighlightsCallback[] = []
private loadingListeners: LoadingCallback[] = []
private currentHighlights: Highlight[] = []
private lastLoadedPubkey: string | null = null
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))
}
/**
* Get current highlights without triggering a reload
*/
getHighlights(): Highlight[] {
return [...this.currentHighlights]
}
/**
* Check if highlights are loaded for a specific pubkey
*/
isLoadedFor(pubkey: string): boolean {
return this.lastLoadedPubkey === pubkey && this.currentHighlights.length >= 0
}
/**
* Reset state (for logout or manual refresh)
*/
reset(): void {
this.generation++
this.currentHighlights = []
this.lastLoadedPubkey = null
this.emitHighlights(this.currentHighlights)
}
/**
* 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('[highlights] Failed to save last synced timestamp:', err)
}
}
/**
* Load highlights for a user
* 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.emitHighlights(this.currentHighlights)
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 highlightsMap = new Map<string, Highlight>()
// Get last synced timestamp for incremental loading
const lastSyncedAt = force ? null : this.getLastSyncedAt(pubkey)
const filter: { kinds: number[]; authors: string[]; since?: number } = {
kinds: [KINDS.Highlights],
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)
// Convert to highlight and add to map
const highlight = eventToHighlight(evt)
highlightsMap.set(highlight.id, highlight)
// Stream to listeners
const sortedHighlights = sortHighlights(Array.from(highlightsMap.values()))
this.currentHighlights = sortedHighlights
this.emitHighlights(sortedHighlights)
}
}
)
// 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
const highlights = events.map(eventToHighlight)
const uniqueHighlights = Array.from(
new Map(highlights.map(h => [h.id, h])).values()
)
const sorted = sortHighlights(uniqueHighlights)
this.currentHighlights = sorted
this.lastLoadedPubkey = pubkey
this.emitHighlights(sorted)
// Update last synced timestamp
if (sorted.length > 0) {
const newestTimestamp = Math.max(...sorted.map(h => h.created_at))
this.setLastSyncedAt(pubkey, newestTimestamp)
}
} catch (error) {
console.error('[highlights] ❌ Failed to load highlights:', error)
this.currentHighlights = []
this.emitHighlights(this.currentHighlights)
} finally {
// Only clear loading if this generation is still active
if (currentGeneration === this.generation) {
this.setLoading(false)
}
}
}
}
// Singleton instance
export const highlightsController = new HighlightsController()

View File

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

View File

@@ -2,6 +2,7 @@ import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { RELAYS } from '../config/relays'
import { KINDS } from '../config/kinds'
import { MARK_AS_READ_EMOJI } from './reactionService'
import { BlogPostPreview } from './exploreService'
import { queryEvents } from './dataFetch'
@@ -29,8 +30,8 @@ export async function fetchReadArticles(
try {
// Fetch kind:7 and kind:17 reactions in parallel
const [kind7Events, kind17Events] = await Promise.all([
queryEvents(relayPool, { kinds: [7], authors: [userPubkey] }, { relayUrls: RELAYS }),
queryEvents(relayPool, { kinds: [17], authors: [userPubkey] }, { relayUrls: RELAYS })
queryEvents(relayPool, { kinds: [KINDS.ReactionToEvent], authors: [userPubkey] }, { relayUrls: RELAYS }),
queryEvents(relayPool, { kinds: [KINDS.ReactionToUrl], authors: [userPubkey] }, { relayUrls: RELAYS })
])
const readArticles: ReadArticle[] = []
@@ -102,7 +103,7 @@ export async function fetchReadArticlesWithData(
// Filter to only nostr-native articles (kind 30023)
const nostrArticles = readArticles.filter(
article => article.eventKind === 30023 && article.eventId
article => article.eventKind === KINDS.BlogPost && article.eventId
)
if (nostrArticles.length === 0) {
@@ -114,7 +115,7 @@ export async function fetchReadArticlesWithData(
const articleEvents = await queryEvents(
relayPool,
{ kinds: [30023], ids: eventIds },
{ kinds: [KINDS.BlogPost], ids: eventIds },
{ relayUrls: RELAYS }
)

View File

@@ -0,0 +1,83 @@
import { RelayPool } from 'applesauce-relay'
import { fetchReadArticles } from './libraryService'
import { queryEvents } from './dataFetch'
import { RELAYS } from '../config/relays'
import { KINDS } from '../config/kinds'
import { ReadItem } from './readsService'
import { processReadingProgress, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor'
import { mergeReadItem } from '../utils/readItemMerge'
/**
* Fetches external URL links with reading progress from:
* - URLs with reading progress (kind:39802)
* - Manually marked as read URLs (kind:7, kind:17)
*/
export async function fetchLinks(
relayPool: RelayPool,
userPubkey: string,
onItem?: (item: ReadItem) => void
): Promise<ReadItem[]> {
const linksMap = new Map<string, ReadItem>()
// Helper to emit items as they're added/updated
const emitItem = (item: ReadItem) => {
if (onItem && mergeReadItem(linksMap, item)) {
onItem(linksMap.get(item.id)!)
} else if (!onItem) {
linksMap.set(item.id, item)
}
}
try {
// Fetch all data sources in parallel
const [progressEvents, markedAsReadArticles] = await Promise.all([
queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [userPubkey] }, { relayUrls: RELAYS }),
fetchReadArticles(relayPool, userPubkey)
])
// Process reading progress events (kind 39802)
processReadingProgress(progressEvents, linksMap)
if (onItem) {
linksMap.forEach(item => {
if (item.type === 'external') {
const hasProgress = (item.readingProgress && item.readingProgress > 0) || item.markedAsRead
if (hasProgress) emitItem(item)
}
})
}
// Process marked-as-read and emit external items
processMarkedAsRead(markedAsReadArticles, linksMap)
if (onItem) {
linksMap.forEach(item => {
if (item.type === 'external') {
const hasProgress = (item.readingProgress && item.readingProgress > 0) || item.markedAsRead
if (hasProgress) emitItem(item)
}
})
}
// Filter for external URLs only with reading progress
const links = Array.from(linksMap.values())
.filter(item => {
// Only external URLs
if (item.type !== 'external') return false
// Only include if there's reading progress or marked as read
const hasProgress = (item.readingProgress && item.readingProgress > 0) || item.markedAsRead
return hasProgress
})
// Apply common validation and sorting
const validLinks = filterValidItems(links)
const sortedLinks = sortByReadingActivity(validLinks)
return sortedLinks
} catch (error) {
console.error('Failed to fetch links:', error)
return []
}
}

View File

@@ -1,11 +1,14 @@
import { Highlight } from '../types/highlights'
import { Bookmark } from '../types/bookmarks'
import { BlogPostPreview } from './exploreService'
import { ReadItem } from './readsService'
export interface MeCache {
highlights: Highlight[]
bookmarks: Bookmark[]
readArticles: BlogPostPreview[]
reads?: ReadItem[]
links?: ReadItem[]
timestamp: number
}

View File

@@ -0,0 +1,26 @@
import { NostrConnectSigner } from 'applesauce-signers'
/**
* Get default NIP-46 permissions for bunker connections
* These permissions cover all event kinds and encryption/decryption operations Boris needs
*/
export function getDefaultBunkerPermissions(): string[] {
return [
// Signing permissions for event kinds we create
...NostrConnectSigner.buildSigningPermissions([
0, // Profile metadata
5, // Event deletion
7, // Reactions (nostr events)
17, // Reactions (websites)
9802, // Highlights
30078, // Settings & reading positions
39701, // Web bookmarks
]),
// Encryption/decryption for hidden content
'nip04_encrypt',
'nip04_decrypt',
'nip44_encrypt',
'nip44_decrypt',
]
}

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

@@ -1,6 +1,6 @@
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { Helpers, IEventStore } from 'applesauce-core'
import { BlogPostPreview } from './exploreService'
import { Highlight } from '../types/highlights'
import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor'
@@ -13,15 +13,17 @@ const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary
* @param relayPool - The relay pool to query
* @param relayUrls - Array of relay URLs to query
* @param limit - Maximum number of posts to fetch (default: 50)
* @param eventStore - Optional event store to persist fetched events
* @returns Array of blog post previews
*/
export const fetchNostrverseBlogPosts = async (
relayPool: RelayPool,
relayUrls: string[],
limit = 50
limit = 50,
eventStore?: IEventStore,
onPost?: (post: BlogPostPreview) => void
): Promise<BlogPostPreview[]> => {
try {
console.log('📚 Fetching nostrverse blog posts (kind 30023), limit:', limit)
// Deduplicate replaceable events by keeping the most recent version
const uniqueEvents = new Map<string, NostrEvent>()
@@ -32,17 +34,34 @@ export const fetchNostrverseBlogPosts = async (
{
relayUrls,
onEvent: (event: NostrEvent) => {
// Store in event store if provided
if (eventStore) {
eventStore.add(event)
}
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${event.pubkey}:${dTag}`
const existing = uniqueEvents.get(key)
if (!existing || event.created_at > existing.created_at) {
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)
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
@@ -60,7 +79,6 @@ export const fetchNostrverseBlogPosts = async (
return timeB - timeA // Most recent first
})
console.log('📰 Processed', blogPosts.length, 'unique nostrverse blog posts')
return blogPosts
} catch (error) {
@@ -73,25 +91,44 @@ export const fetchNostrverseBlogPosts = async (
* Fetches public highlights (kind:9802) from the nostrverse (not filtered by author)
* @param relayPool - The relay pool to query
* @param limit - Maximum number of highlights to fetch (default: 100)
* @param eventStore - Optional event store to persist fetched events
* @returns Array of highlights
*/
export const fetchNostrverseHighlights = async (
relayPool: RelayPool,
limit = 100
limit = 100,
eventStore?: IEventStore
): Promise<Highlight[]> => {
try {
console.log('💡 Fetching nostrverse highlights (kind 9802), limit:', limit)
const seenIds = new Set<string>()
// Collect but do not block callers awaiting network completion
const collected: NostrEvent[] = []
const rawEvents = await queryEvents(
relayPool,
{ kinds: [9802], limit },
{}
{
onEvent: (event: NostrEvent) => {
if (seenIds.has(event.id)) return
seenIds.add(event.id)
// Store in event store if provided
if (eventStore) {
eventStore.add(event)
}
collected.push(event)
}
}
)
const uniqueEvents = dedupeHighlights(rawEvents)
// Store all events in event store if provided (in case some were missed in streaming)
if (eventStore) {
rawEvents.forEach(evt => eventStore.add(evt))
}
const uniqueEvents = dedupeHighlights([...collected, ...rawEvents])
const highlights = uniqueEvents.map(eventToHighlight)
console.log('💡 Processed', highlights.length, 'unique nostrverse highlights')
return sortHighlights(highlights)
} 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 {
offlineCreatedEvents.add(eventId)
console.log(`📝 Marked event ${eventId.slice(0, 8)} as offline-created. Total: ${offlineCreatedEvents.size}`)
}
/**
@@ -57,49 +56,35 @@ export async function syncLocalEventsToRemote(
eventStore: IEventStore
): Promise<void> {
if (isSyncing) {
console.log('⏳ Sync already in progress, skipping...')
return
}
console.log('🔄 Coming back online - syncing local events to remote relays...')
console.log(`📦 Offline events tracked: ${offlineCreatedEvents.size}`)
isSyncing = true
try {
const remoteRelays = RELAYS.filter(url => !isLocalRelay(url))
console.log(`📡 Remote relays: ${remoteRelays.length}`)
if (remoteRelays.length === 0) {
console.log('⚠️ No remote relays available for sync')
isSyncing = false
return
}
if (offlineCreatedEvents.size === 0) {
console.log('✅ No offline events to sync')
isSyncing = false
return
}
// Get events from EventStore using the tracked IDs
const eventsToSync: NostrEvent[] = []
console.log(`🔍 Querying EventStore for ${offlineCreatedEvents.size} offline events...`)
for (const eventId of offlineCreatedEvents) {
const event = eventStore.getEvent(eventId)
if (event) {
console.log(`📥 Found event ${eventId.slice(0, 8)} (kind ${event.kind}) in EventStore`)
eventsToSync.push(event)
} else {
console.warn(`⚠️ Event ${eventId.slice(0, 8)} not found in EventStore`)
}
}
console.log(`📊 Total events to sync: ${eventsToSync.length}`)
if (eventsToSync.length === 0) {
console.log('✅ No events found in EventStore to sync')
isSyncing = false
offlineCreatedEvents.clear()
return
@@ -110,8 +95,6 @@ export async function syncLocalEventsToRemote(
new Map(eventsToSync.map(e => [e.id, e])).values()
)
console.log(`📤 Syncing ${uniqueEvents.length} event(s) to remote relays...`)
// Mark all events as syncing
uniqueEvents.forEach(event => {
syncingEvents.add(event.id)
@@ -119,21 +102,16 @@ export async function syncLocalEventsToRemote(
})
// Publish to remote relays
let successCount = 0
const successfulIds: string[] = []
for (const event of uniqueEvents) {
try {
await relayPool.publish(remoteRelays, event)
successCount++
successfulIds.push(event.id)
console.log(`✅ Synced event ${event.id.slice(0, 8)}`)
} catch (error) {
console.warn(`⚠️ Failed to sync event ${event.id.slice(0, 8)}:`, error)
// Silently fail for individual events
}
}
console.log(`✅ Synced ${successCount}/${uniqueEvents.length} events to remote relays`)
// Clear syncing state and offline tracking for successful events
successfulIds.forEach(eventId => {
@@ -150,7 +128,7 @@ export async function syncLocalEventsToRemote(
}
})
} catch (error) {
console.error('❌ Error during offline sync:', error)
// Silently fail
} finally {
isSyncing = false
}

View File

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

View File

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

View File

@@ -0,0 +1,185 @@
import { NostrEvent, nip19 } from 'nostr-tools'
import { ReadItem } from './readsService'
import { fallbackTitleFromUrl } from '../utils/readItemMerge'
import { KINDS } from '../config/kinds'
const READING_PROGRESS_KIND = KINDS.ReadingProgress // 39802 - NIP-85
interface ReadArticle {
id: string
url?: string
eventId?: string
eventKind?: number
markedAt: number
}
/**
* 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 processReadingProgress(
events: NostrEvent[],
readsMap: Map<string, ReadItem>
): void {
for (const event of events) {
if (event.kind !== READING_PROGRESS_KIND) {
continue
}
const dTag = event.tags.find(t => t[0] === 'd')?.[1]
if (!dTag) {
continue
}
try {
const content = JSON.parse(event.content)
const position = content.progress || 0
// 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 itemUrl: string | undefined
let itemType: 'article' | 'external' = 'external'
// Check if d tag is a coordinate (30023:pubkey:identifier)
if (dTag.startsWith('30023:')) {
// It's a nostr article coordinate
const parts = dTag.split(':')
if (parts.length === 3) {
// 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 {
itemUrl = atob(encoded.replace(/-/g, '+').replace(/_/g, '/'))
itemId = itemUrl
itemType = 'external'
} catch (e) {
continue
}
} else {
continue
}
// Add or update the item, preferring newer timestamps
const existing = readsMap.get(itemId)
if (!existing || !existing.readingTimestamp || timestamp > existing.readingTimestamp) {
readsMap.set(itemId, {
...existing,
id: itemId,
source: 'reading-progress',
type: itemType,
url: itemUrl,
readingProgress: position,
readingTimestamp: timestamp
})
}
} catch (error) {
// Silently fail
}
}
}
/**
* Processes marked-as-read articles into ReadItems
*/
export function processMarkedAsRead(
articles: ReadArticle[],
readsMap: Map<string, ReadItem>
): void {
for (const article of articles) {
const existing = readsMap.get(article.id)
if (article.eventId && article.eventKind === 30023) {
// Nostr article
readsMap.set(article.id, {
...existing,
id: article.id,
source: 'marked-as-read',
type: 'article',
markedAsRead: true,
markedAt: article.markedAt,
readingTimestamp: existing?.readingTimestamp || article.markedAt
})
} else if (article.url) {
// External URL
readsMap.set(article.id, {
...existing,
id: article.id,
source: 'marked-as-read',
type: 'external',
url: article.url,
markedAsRead: true,
markedAt: article.markedAt,
readingTimestamp: existing?.readingTimestamp || article.markedAt
})
}
}
}
/**
* Sorts ReadItems by most recent reading activity
*/
export function sortByReadingActivity(items: ReadItem[]): ReadItem[] {
return items.sort((a, b) => {
const timeA = a.readingTimestamp || a.markedAt || 0
const timeB = b.readingTimestamp || b.markedAt || 0
return timeB - timeA
})
}
/**
* Filters out items without timestamps and enriches external items with fallback titles
*/
export function filterValidItems(items: ReadItem[]): ReadItem[] {
return items
.filter(item => {
// Only include items that have a timestamp
const hasTimestamp = (item.readingTimestamp && item.readingTimestamp > 0) ||
(item.markedAt && item.markedAt > 0)
if (!hasTimestamp) return false
// For Nostr articles, we need the event to be valid
if (item.type === 'article' && !item.event) return false
// For external URLs, we need at least a URL
if (item.type === 'external' && !item.url) return false
return true
})
.map(item => {
// Add fallback title for external URLs without titles
if (item.type === 'external' && !item.title && item.url) {
return { ...item, title: fallbackTitleFromUrl(item.url) }
}
return item
})
}

View File

@@ -1,13 +1,13 @@
import { IEventStore, mapEventsToStore } from 'applesauce-core'
import { EventFactory } from 'applesauce-factory'
import { RelayPool, onlyEvents } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { NostrEvent, nip19 } from 'nostr-tools'
import { firstValueFrom } from 'rxjs'
import { publishEvent } from './writeService'
import { RELAYS } from '../config/relays'
import { KINDS } from '../config/kinds'
const APP_DATA_KIND = 30078 // NIP-78 Application Data
const READING_POSITION_PREFIX = 'boris:reading-position:'
const READING_PROGRESS_KIND = KINDS.ReadingProgress // 39802 - NIP-85 Reading Progress
export interface ReadingPosition {
position: number // 0-1 scroll progress
@@ -15,16 +15,79 @@ export interface ReadingPosition {
scrollTop?: number // Optional: pixel position
}
// Helper to extract and parse reading position from an event
function getReadingPositionContent(event: NostrEvent): ReadingPosition | undefined {
export interface ReadingProgressContent {
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
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 {
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
* 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(
relayPool: RelayPool,
@@ -52,36 +115,31 @@ export async function saveReadingPosition(
articleIdentifier: string,
position: ReadingPosition
): Promise<void> {
console.log('💾 [ReadingPosition] Saving position:', {
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 now = Math.floor(Date.now() / 1000)
const progressContent: ReadingProgressContent = {
progress: position.position,
ts: position.timestamp,
loc: position.scrollTop,
ver: '1'
}
const tags = generateProgressTags(articleIdentifier)
const draft = await factory.create(async () => ({
kind: APP_DATA_KIND,
content: JSON.stringify(position),
tags: [
['d', dTag],
['client', 'boris']
],
created_at: Math.floor(Date.now() / 1000)
kind: READING_PROGRESS_KIND,
content: JSON.stringify(progressContent),
tags,
created_at: now
}))
const signed = await factory.sign(draft)
// Use unified write service
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(
relayPool: RelayPool,
@@ -89,32 +147,20 @@ export async function loadReadingPosition(
pubkey: string,
articleIdentifier: string
): Promise<ReadingPosition | null> {
const dTag = `${READING_POSITION_PREFIX}${articleIdentifier}`
const dTag = generateDTag(articleIdentifier)
console.log('📖 [ReadingPosition] Loading position:', {
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
// Check local event store first
try {
const localEvent = await firstValueFrom(
eventStore.replaceable(APP_DATA_KIND, pubkey, dTag)
eventStore.replaceable(READING_PROGRESS_KIND, pubkey, dTag)
)
if (localEvent) {
const content = getReadingPositionContent(localEvent)
const content = getReadingProgressContent(localEvent)
if (content) {
console.log('✅ [ReadingPosition] Loaded from local store:', {
position: content.position,
positionPercent: Math.round(content.position * 100) + '%',
timestamp: content.timestamp
})
// Still fetch from relays in the background to get any updates
// Fetch from relays in background to get any updates
relayPool
.subscription(RELAYS, {
kinds: [APP_DATA_KIND],
kinds: [READING_PROGRESS_KIND],
authors: [pubkey],
'#d': [dTag]
})
@@ -125,23 +171,43 @@ export async function loadReadingPosition(
}
}
} 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) => {
let hasResolved = false
const timeout = setTimeout(() => {
if (!hasResolved) {
console.log('⏱️ Reading position load timeout - no position found')
hasResolved = true
resolve(null)
}
}, 3000) // Shorter timeout for reading positions
}, 3000)
const sub = relayPool
.subscription(RELAYS, {
kinds: [APP_DATA_KIND],
kinds: [kind],
authors: [pubkey],
'#d': [dTag]
})
@@ -153,33 +219,20 @@ export async function loadReadingPosition(
hasResolved = true
try {
const event = await firstValueFrom(
eventStore.replaceable(APP_DATA_KIND, pubkey, dTag)
eventStore.replaceable(kind, pubkey, dTag)
)
if (event) {
const content = getReadingPositionContent(event)
if (content) {
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)
}
const content = parser(event)
resolve(content || null)
} else {
console.log('📭 [ReadingPosition] No position found on relays')
resolve(null)
}
} catch (err) {
console.error('❌ Error loading reading position:', err)
resolve(null)
}
}
},
error: (err) => {
console.error('❌ Reading position subscription error:', err)
error: () => {
clearTimeout(timeout)
if (!hasResolved) {
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

@@ -0,0 +1,183 @@
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { Bookmark } from '../types/bookmarks'
import { fetchReadArticles } from './libraryService'
import { queryEvents } from './dataFetch'
import { RELAYS } from '../config/relays'
import { KINDS } from '../config/kinds'
import { classifyBookmarkType } from '../utils/bookmarkTypeClassifier'
import { nip19 } from 'nostr-tools'
import { processReadingProgress, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor'
import { mergeReadItem } from '../utils/readItemMerge'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
export interface ReadItem {
id: string // event ID or URL or coordinate
source: 'bookmark' | 'reading-progress' | 'marked-as-read'
type: 'article' | 'external' // article=kind:30023, external=URL
// Article data
event?: NostrEvent
url?: string
title?: string
summary?: string
image?: string
published?: number
author?: string
// Reading metadata
readingProgress?: number // 0-1
readingTimestamp?: number // Unix timestamp of last reading activity
markedAsRead?: boolean
markedAt?: number
}
/**
* Fetches all reads from multiple sources:
* - Bookmarked articles (kind:30023) and article/website URLs
* - Articles/URLs with reading progress (kind:39802)
* - Manually marked as read articles/URLs (kind:7, kind:17)
*/
export async function fetchAllReads(
relayPool: RelayPool,
userPubkey: string,
bookmarks: Bookmark[],
onItem?: (item: ReadItem) => void
): Promise<ReadItem[]> {
const readsMap = new Map<string, ReadItem>()
// Helper to emit items as they're added/updated
const emitItem = (item: ReadItem) => {
if (onItem && mergeReadItem(readsMap, item)) {
onItem(readsMap.get(item.id)!)
} else if (!onItem) {
readsMap.set(item.id, item)
}
}
try {
// Fetch all data sources in parallel
const [progressEvents, markedAsReadArticles] = await Promise.all([
queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [userPubkey] }, { relayUrls: RELAYS }),
fetchReadArticles(relayPool, userPubkey)
])
// Process reading progress events (kind 39802)
processReadingProgress(progressEvents, readsMap)
// Process marked-as-read and emit items
processMarkedAsRead(markedAsReadArticles, readsMap)
if (onItem) {
readsMap.forEach(item => {
if (item.type === 'article') onItem(item)
})
}
// 3. Process bookmarked articles and article/website URLs
const allBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
for (const bookmark of allBookmarks) {
const bookmarkType = classifyBookmarkType(bookmark)
// Only include articles
if (bookmarkType === 'article') {
// Kind:30023 nostr article
const coordinate = bookmark.id // Already in coordinate format
const existing = readsMap.get(coordinate)
if (!existing) {
const item: ReadItem = {
id: coordinate,
source: 'bookmark',
type: 'article',
readingProgress: 0,
readingTimestamp: bookmark.added_at || bookmark.created_at
}
readsMap.set(coordinate, item)
if (onItem) emitItem(item)
}
}
}
// 4. Fetch full event data for nostr articles
const articleCoordinates = Array.from(readsMap.values())
.filter(item => item.type === 'article' && !item.event)
.map(item => item.id)
if (articleCoordinates.length > 0) {
// Parse coordinates and fetch events
const articlesToFetch: Array<{ pubkey: string; identifier: string }> = []
for (const coord of articleCoordinates) {
try {
// Try to decode as naddr
if (coord.startsWith('naddr1')) {
const decoded = nip19.decode(coord)
if (decoded.type === 'naddr' && decoded.data.kind === KINDS.BlogPost) {
articlesToFetch.push({
pubkey: decoded.data.pubkey,
identifier: decoded.data.identifier || ''
})
}
} else {
// Try coordinate format (kind:pubkey:identifier)
const parts = coord.split(':')
if (parts.length === 3 && parseInt(parts[0]) === KINDS.BlogPost) {
articlesToFetch.push({
pubkey: parts[1],
identifier: parts[2]
})
}
}
} catch (e) {
console.warn('Failed to decode article coordinate:', coord)
}
}
if (articlesToFetch.length > 0) {
const authors = Array.from(new Set(articlesToFetch.map(a => a.pubkey)))
const identifiers = Array.from(new Set(articlesToFetch.map(a => a.identifier)))
const events = await queryEvents(
relayPool,
{ kinds: [KINDS.BlogPost], authors, '#d': identifiers },
{ relayUrls: RELAYS }
)
// Merge event data into ReadItems and emit
for (const event of events) {
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
const coordinate = `${KINDS.BlogPost}:${event.pubkey}:${dTag}`
const item = readsMap.get(coordinate) || readsMap.get(event.id)
if (item) {
item.event = event
item.title = getArticleTitle(event) || 'Untitled'
item.summary = getArticleSummary(event)
item.image = getArticleImage(event)
item.published = getArticlePublished(event)
item.author = event.pubkey
if (onItem) emitItem(item)
}
}
}
}
// 5. Filter for Nostr articles only and apply common validation/sorting
const articles = Array.from(readsMap.values())
.filter(item => item.type === 'article')
const validArticles = filterValidItems(articles)
const sortedReads = sortByReadingActivity(validArticles)
return sortedReads
} catch (error) {
console.error('Failed to fetch all reads:', error)
return []
}
}

View File

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

View File

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

View File

@@ -36,6 +36,10 @@ export interface UserSettings {
defaultHighlightVisibilityNostrverse?: boolean
defaultHighlightVisibilityFriends?: boolean
defaultHighlightVisibilityMine?: boolean
// Default explore scope
defaultExploreScopeNostrverse?: boolean
defaultExploreScopeFriends?: boolean
defaultExploreScopeMine?: boolean
// Zap split weights (treated as relative weights, not strict percentages)
zapSplitHighlighterWeight?: number // default 50
zapSplitBorisWeight?: number // default 2.1
@@ -56,6 +60,9 @@ export interface UserSettings {
paragraphAlignment?: 'left' | 'justify' // default: justify
// Reading position sync
syncReadingPosition?: boolean // default: false (opt-in)
autoMarkAsReadOnCompletion?: boolean // default: false (opt-in)
// Bookmark filtering
hideBookmarksWithoutCreationDate?: boolean // default: false
}
export async function loadSettings(
@@ -64,7 +71,6 @@ export async function loadSettings(
pubkey: string,
relays: string[]
): Promise<UserSettings | null> {
console.log('⚙️ Loading settings from nostr...', { pubkey: pubkey.slice(0, 8) + '...', relays })
// First, check if we already have settings in the local event store
try {
@@ -73,7 +79,6 @@ export async function loadSettings(
)
if (localEvent) {
const content = getAppDataContent<UserSettings>(localEvent)
console.log('✅ Settings loaded from local store (cached):', content)
// Still fetch from relays in the background to get any updates
relayPool
@@ -87,8 +92,8 @@ export async function loadSettings(
return content || null
}
} catch (err) {
console.log('📭 No cached settings found, fetching from relays...')
} catch (_err) {
// Ignore local store errors
}
// If not in local store, fetch from relays
@@ -120,10 +125,8 @@ export async function loadSettings(
)
if (event) {
const content = getAppDataContent<UserSettings>(event)
console.log('✅ Settings loaded from relays:', content)
resolve(content || null)
} else {
console.log('📭 No settings event found - using defaults')
resolve(null)
}
} catch (err) {
@@ -154,7 +157,6 @@ export async function saveSettings(
factory: EventFactory,
settings: UserSettings
): Promise<void> {
console.log('💾 Saving settings to nostr:', settings)
// Create NIP-78 application data event manually
// Note: AppDataBlueprint is not available in the npm package
@@ -170,7 +172,6 @@ export async function saveSettings(
// Use unified write service
await publishEvent(relayPool, eventStore, signed)
console.log('✅ Settings published successfully')
}
export function watchSettings(

View File

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

View File

@@ -14,9 +14,11 @@ export async function publishEvent(
eventStore: IEventStore,
event: NostrEvent
): Promise<void> {
const isProgressEvent = event.kind === 39802
const logPrefix = isProgressEvent ? '[progress]' : ''
// Store the event in the local EventStore FIRST for immediate UI display
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?
const connectedRelays = Array.from(relayPool.relays.values())
@@ -32,13 +34,7 @@ export async function publishEvent(
const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays)
console.log('📍 Event relay status:', {
targetRelays: RELAYS.length,
expectedSuccessRelays: expectedSuccessRelays.length,
isLocalOnly,
hasRemoteConnection,
eventId: event.id.slice(0, 8)
})
// Publishing event
// If we're in local-only mode, mark this event for later sync
if (isLocalOnly) {
@@ -48,10 +44,14 @@ export async function publishEvent(
// Publish to all configured relays in the background (non-blocking)
relayPool.publish(RELAYS, event)
.then(() => {
console.log('✅ Event published to', RELAYS.length, 'relay(s):', event.id.slice(0, 8))
})
.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
if (error instanceof Error && error.message.includes('permission')) {
console.warn('💡 Hint: This may be a bunker permission issue. Ensure your bunker connection has signing permissions.')
}
})
}

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

View File

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

View File

@@ -0,0 +1,261 @@
/* Login component styles */
.login-container {
max-width: 420px;
margin: 0 auto;
padding: 2rem 1rem;
}
.login-content {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.login-title {
font-size: 1.75rem;
font-weight: 600;
color: var(--color-text);
margin: 0;
text-align: center;
}
.login-description {
font-size: 1rem;
line-height: 1.6;
color: var(--color-text-secondary);
margin: 0;
text-align: center;
}
.login-highlight {
background-color: var(--highlight-color-mine, #fde047);
color: var(--color-text);
padding: 0.125rem 0.25rem;
border-radius: 3px;
font-weight: 500;
}
.login-buttons {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 0.5rem;
}
.login-button {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 1rem 1.5rem;
font-size: 1rem;
font-weight: 500;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
border: none;
min-height: var(--min-touch-target);
}
.login-button svg {
font-size: 1.125rem;
width: 1.125rem;
height: 1.125rem;
}
.login-button-primary {
background: var(--color-primary);
color: white;
}
.login-button-primary:hover:not(:disabled) {
background: var(--color-primary-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.login-button-secondary {
background: var(--color-bg-elevated);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.login-button-secondary:hover:not(:disabled) {
background: var(--color-border);
border-color: var(--color-border-subtle);
}
.login-button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.bunker-input-container {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: 8px;
width: 100%;
box-sizing: border-box;
}
.bunker-input {
padding: 0.75rem 1rem;
font-size: 0.95rem;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 6px;
color: var(--color-text);
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
transition: all 0.2s ease;
width: 100%;
box-sizing: border-box;
}
.bunker-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.bunker-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.bunker-input::placeholder {
color: var(--color-text-muted);
}
.bunker-actions {
display: flex;
gap: 0.5rem;
}
.bunker-button {
flex: 1;
padding: 0.75rem 1rem;
font-size: 0.95rem;
font-weight: 500;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
border: none;
min-height: var(--min-touch-target);
}
.bunker-connect {
background: var(--color-primary);
color: white;
}
.bunker-connect:hover:not(:disabled) {
background: var(--color-primary-hover);
}
.bunker-connect:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.bunker-cancel {
background: transparent;
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
.bunker-cancel:hover:not(:disabled) {
background: var(--color-bg);
color: var(--color-text);
}
.bunker-cancel:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.login-error {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.875rem 1rem;
background: rgba(251, 191, 36, 0.1);
border: 1px solid rgba(251, 191, 36, 0.3);
border-radius: 8px;
color: var(--color-text-secondary);
font-size: 0.9rem;
line-height: 1.5;
text-align: left;
}
.login-error svg {
font-size: 1.125rem;
width: 1.125rem;
height: 1.125rem;
color: rgb(251, 191, 36);
flex-shrink: 0;
}
.login-error a {
color: var(--color-primary);
text-decoration: underline;
font-weight: 600;
transition: color 0.2s ease;
}
.login-error a:hover {
color: var(--color-primary-hover);
text-decoration: underline;
}
.login-footer {
margin: 0;
text-align: center;
font-size: 0.9rem;
color: var(--color-text-muted);
padding-top: 0.5rem;
}
.login-footer a {
color: var(--color-primary);
text-decoration: none;
font-weight: 500;
transition: color 0.2s ease;
}
.login-footer a:hover {
color: var(--color-primary-hover);
text-decoration: underline;
}
/* Mobile responsive styles */
@media (max-width: 768px) {
.login-container {
padding: 1.5rem 1rem;
}
.login-title {
font-size: 1.5rem;
}
.login-description {
font-size: 0.95rem;
}
.login-button {
padding: 0.875rem 1.25rem;
}
.bunker-actions {
flex-direction: column;
}
.bunker-button {
width: 100%;
}
}

View File

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

View File

@@ -73,7 +73,7 @@
.highlight-mode-toggle .mode-btn.active { background: var(--color-primary); color: rgb(255 255 255); /* white */ }
/* Three-level highlight toggles */
.highlight-level-toggles { display: flex; gap: 0.25rem; padding: 0.25rem; background: rgba(255, 255, 255, 0.05); border-radius: 4px; }
.highlight-level-toggles { display: flex; gap: 0.25rem; padding: 0.25rem; border-radius: 4px; }
.highlights-loading,
.highlights-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem 1rem; color: var(--color-text-secondary); text-align: center; gap: 0.5rem; }

View File

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

39
src/utils/async.ts Normal file
View File

@@ -0,0 +1,39 @@
/**
* Wrap a promise with a timeout
*/
export async function withTimeout<T>(promise: Promise<T>, timeoutMs = 30000): Promise<T> {
return Promise.race([
promise,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error(`Timeout after ${timeoutMs}ms`)), timeoutMs)
)
])
}
/**
* Map items through an async function with limited concurrency
* @param items - Array of items to process
* @param limit - Maximum number of concurrent operations
* @param mapper - Async function to apply to each item
* @returns Array of results in the same order as input
*/
export async function mapWithConcurrency<T, R>(
items: T[],
limit: number,
mapper: (item: T, index: number) => Promise<R>
): Promise<R[]> {
const results: R[] = new Array(items.length)
let currentIndex = 0
const worker = async () => {
while (currentIndex < items.length) {
const index = currentIndex++
results[index] = await mapper(items[index], index)
}
}
const workers = new Array(Math.min(limit, items.length)).fill(0).map(() => worker())
await Promise.all(workers)
return results
}

View File

@@ -92,19 +92,40 @@ export const sortIndividualBookmarks = (items: IndividualBookmark[]) => {
export function groupIndividualBookmarks(items: IndividualBookmark[]) {
const sorted = sortIndividualBookmarks(items)
const web = sorted.filter(i => i.kind === 39701 || i.type === 'web')
// Only non-encrypted legacy bookmarks go to the amethyst section
const amethyst = sorted.filter(i => i.sourceKind === 30001 && !i.isPrivate)
const isIn = (list: IndividualBookmark[], x: IndividualBookmark) => list.some(i => i.id === x.id)
// Private items include encrypted legacy bookmarks
const privateItems = sorted.filter(i => i.isPrivate && !isIn(web, i))
const publicItems = sorted.filter(i => !i.isPrivate && !isIn(amethyst, i) && !isIn(web, i))
return { privateItems, publicItems, web, amethyst }
// Group by source list, not by content type
const nip51Public = sorted.filter(i => i.sourceKind === 10003 && !i.isPrivate)
const nip51Private = sorted.filter(i => i.sourceKind === 10003 && i.isPrivate)
// Amethyst bookmarks: kind:30001 (any d-tag or undefined)
const amethystPublic = sorted.filter(i => i.sourceKind === 30001 && !i.isPrivate)
const amethystPrivate = sorted.filter(i => i.sourceKind === 30001 && i.isPrivate)
const standaloneWeb = sorted.filter(i => i.sourceKind === 39701)
return {
nip51Public,
nip51Private,
amethystPublic,
amethystPrivate,
standaloneWeb
}
}
// Simple filter: only exclude bookmarks with empty/whitespace-only content
// Simple filter: show bookmarks that have content OR just an ID (placeholder)
export function hasContent(bookmark: IndividualBookmark): boolean {
return !!(bookmark.content && bookmark.content.trim().length > 0)
// Show if has content OR has an ID (placeholder until events are fetched)
const hasValidContent = !!(bookmark.content && bookmark.content.trim().length > 0)
const hasId = !!(bookmark.id && bookmark.id.trim().length > 0)
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)

36
src/utils/debugBus.ts Normal file
View File

@@ -0,0 +1,36 @@
export type DebugLevel = 'info' | 'warn' | 'error'
export interface DebugLogEntry {
ts: number
level: DebugLevel
source: string
message: string
data?: unknown
}
type Listener = (entry: DebugLogEntry) => void
const listeners = new Set<Listener>()
const buffer: DebugLogEntry[] = []
const MAX_BUFFER = 300
export const DebugBus = {
log(level: DebugLevel, source: string, message: string, data?: unknown): void {
const entry: DebugLogEntry = { ts: Date.now(), level, source, message, data }
buffer.push(entry)
if (buffer.length > MAX_BUFFER) buffer.shift()
listeners.forEach(l => {
try { l(entry) } catch (err) { console.warn('[DebugBus] listener error:', err) }
})
},
info(source: string, message: string, data?: unknown): void { this.log('info', source, message, data) },
warn(source: string, message: string, data?: unknown): void { this.log('warn', source, message, data) },
error(source: string, message: string, data?: unknown): void { this.log('error', source, message, data) },
subscribe(listener: Listener): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
},
snapshot(): DebugLogEntry[] { return buffer.slice() }
}

Some files were not shown because too many files have changed in this diff Show More