Compare commits

...

133 Commits

Author SHA1 Message Date
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
38 changed files with 3462 additions and 872 deletions

View File

@@ -12,6 +12,13 @@
- After deserialization, recreated the signer with pool context and merged its relays with app `RELAYS` (includes local relays). - 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`. - 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** - **Probes and timeouts**
- Initial probe tried `decrypt('invalid-ciphertext')` → timed out. - 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. - Switched to roundtrip probes: `encrypt(self, ... )` then `decrypt(self, cipher)` for both nip-44 and nip-04.
@@ -69,9 +76,80 @@ If DECRYPT entries still dont appear:
- 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). - 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. - 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 ## Current conclusion
- Client is configured and publishing requests correctly; encryption proves endtoend path is alive. - Client is configured and publishing requests correctly; encryption proves endtoend path is alive.
- The missing DECRYPT activity in Amber is the blocker. Fixing Ambers NIP46 decrypt handling should resolve bookmark decryption in Boris without further client changes. - 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,124 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [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 ## [0.6.24] - 2025-01-16
### Fixed ### Fixed
@@ -1760,7 +1878,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Optimize relay usage following applesauce-relay best practices - Optimize relay usage following applesauce-relay best practices
- Use applesauce-react event models for better profile handling - Use applesauce-react event models for better profile handling
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.24...HEAD [Unreleased]: https://github.com/dergigi/boris/compare/v0.7.0...HEAD
[0.7.0]: https://github.com/dergigi/boris/compare/v0.6.24...v0.7.0
[0.6.24]: https://github.com/dergigi/boris/compare/v0.6.23...v0.6.24 [0.6.24]: https://github.com/dergigi/boris/compare/v0.6.23...v0.6.24
[0.6.23]: https://github.com/dergigi/boris/compare/v0.6.22...v0.6.23 [0.6.23]: https://github.com/dergigi/boris/compare/v0.6.22...v0.6.23
[0.6.21]: https://github.com/dergigi/boris/compare/v0.6.20...v0.6.21 [0.6.21]: https://github.com/dergigi/boris/compare/v0.6.20...v0.6.21

View File

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

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useCallback } from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner } from '@fortawesome/free-solid-svg-icons' import { faSpinner } from '@fortawesome/free-solid-svg-icons'
@@ -19,6 +19,10 @@ import { useOnlineStatus } from './hooks/useOnlineStatus'
import { RELAYS } from './config/relays' import { RELAYS } from './config/relays'
import { SkeletonThemeProvider } from './components/Skeletons' import { SkeletonThemeProvider } from './components/Skeletons'
import { DebugBus } from './utils/debugBus' 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'
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR || const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew' 'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
@@ -26,15 +30,104 @@ const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
// AppRoutes component that has access to hooks // AppRoutes component that has access to hooks
function AppRoutes({ function AppRoutes({
relayPool, relayPool,
eventStore,
showToast showToast
}: { }: {
relayPool: RelayPool relayPool: RelayPool
eventStore: EventStore | null
showToast: (message: string) => void showToast: (message: string) => void
}) { }) {
const accountManager = Hooks.useAccountManager() 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(() => {
console.log('[bookmark] 🎧 Subscribing to bookmark controller')
const unsubBookmarks = bookmarkController.onBookmarks((bookmarks) => {
console.log('[bookmark] 📥 Received bookmarks:', bookmarks.length)
setBookmarks(bookmarks)
})
const unsubLoading = bookmarkController.onLoading((loading) => {
console.log('[bookmark] 📥 Loading state:', loading)
setBookmarksLoading(loading)
})
return () => {
console.log('[bookmark] 🔇 Unsubscribing from bookmark controller')
unsubBookmarks()
unsubLoading()
}
}, [])
// Subscribe to contacts controller
useEffect(() => {
console.log('[contacts] 🎧 Subscribing to contacts controller')
const unsubContacts = contactsController.onContacts((contacts) => {
console.log('[contacts] 📥 Received contacts:', contacts.size)
setContacts(contacts)
})
const unsubLoading = contactsController.onLoading((loading) => {
console.log('[contacts] 📥 Loading state:', loading)
setContactsLoading(loading)
})
return () => {
console.log('[contacts] 🔇 Unsubscribing from contacts controller')
unsubContacts()
unsubLoading()
}
}, [])
// 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) {
console.log('[bookmark] 🚀 Auto-loading bookmarks on mount/login')
bookmarkController.start({ relayPool, activeAccount, accountManager })
}
// Load contacts
if (pubkey && contacts.size === 0 && !contactsLoading) {
console.log('[contacts] 🚀 Auto-loading contacts on mount/login')
contactsController.start({ relayPool, pubkey })
}
// Load highlights (controller manages its own state)
if (pubkey && eventStore && !highlightsController.isLoadedFor(pubkey)) {
console.log('[highlights] 🚀 Auto-loading highlights on mount/login')
highlightsController.start({ relayPool, eventStore, pubkey })
}
}
}, [activeAccount, relayPool, eventStore, bookmarks.length, bookmarksLoading, contacts.size, contactsLoading, accountManager])
// Manual refresh (for sidebar button)
const handleRefreshBookmarks = useCallback(async () => {
if (!relayPool || !activeAccount) {
console.warn('[bookmark] Cannot refresh: missing relayPool or activeAccount')
return
}
console.log('[bookmark] 🔄 Manual refresh triggered')
bookmarkController.reset()
await bookmarkController.start({ relayPool, activeAccount, accountManager })
}, [relayPool, activeAccount, accountManager])
const handleLogout = () => { const handleLogout = () => {
accountManager.clearActive() accountManager.clearActive()
bookmarkController.reset() // Clear bookmarks via controller
contactsController.reset() // Clear contacts via controller
highlightsController.reset() // Clear highlights via controller
showToast('Logged out successfully') showToast('Logged out successfully')
} }
@@ -46,6 +139,9 @@ function AppRoutes({
<Bookmarks <Bookmarks
relayPool={relayPool} relayPool={relayPool}
onLogout={handleLogout} onLogout={handleLogout}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
/> />
} }
/> />
@@ -55,6 +151,9 @@ function AppRoutes({
<Bookmarks <Bookmarks
relayPool={relayPool} relayPool={relayPool}
onLogout={handleLogout} onLogout={handleLogout}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
/> />
} }
/> />
@@ -64,6 +163,9 @@ function AppRoutes({
<Bookmarks <Bookmarks
relayPool={relayPool} relayPool={relayPool}
onLogout={handleLogout} onLogout={handleLogout}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
/> />
} }
/> />
@@ -73,6 +175,9 @@ function AppRoutes({
<Bookmarks <Bookmarks
relayPool={relayPool} relayPool={relayPool}
onLogout={handleLogout} onLogout={handleLogout}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
/> />
} }
/> />
@@ -82,6 +187,9 @@ function AppRoutes({
<Bookmarks <Bookmarks
relayPool={relayPool} relayPool={relayPool}
onLogout={handleLogout} onLogout={handleLogout}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
/> />
} }
/> />
@@ -91,6 +199,9 @@ function AppRoutes({
<Bookmarks <Bookmarks
relayPool={relayPool} relayPool={relayPool}
onLogout={handleLogout} onLogout={handleLogout}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
/> />
} }
/> />
@@ -104,6 +215,9 @@ function AppRoutes({
<Bookmarks <Bookmarks
relayPool={relayPool} relayPool={relayPool}
onLogout={handleLogout} onLogout={handleLogout}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
/> />
} }
/> />
@@ -113,6 +227,9 @@ function AppRoutes({
<Bookmarks <Bookmarks
relayPool={relayPool} relayPool={relayPool}
onLogout={handleLogout} onLogout={handleLogout}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
/> />
} }
/> />
@@ -122,6 +239,9 @@ function AppRoutes({
<Bookmarks <Bookmarks
relayPool={relayPool} relayPool={relayPool}
onLogout={handleLogout} onLogout={handleLogout}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
/> />
} }
/> />
@@ -131,6 +251,9 @@ function AppRoutes({
<Bookmarks <Bookmarks
relayPool={relayPool} relayPool={relayPool}
onLogout={handleLogout} onLogout={handleLogout}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
/> />
} }
/> />
@@ -140,6 +263,9 @@ function AppRoutes({
<Bookmarks <Bookmarks
relayPool={relayPool} relayPool={relayPool}
onLogout={handleLogout} onLogout={handleLogout}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
/> />
} }
/> />
@@ -149,6 +275,9 @@ function AppRoutes({
<Bookmarks <Bookmarks
relayPool={relayPool} relayPool={relayPool}
onLogout={handleLogout} onLogout={handleLogout}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
/> />
} }
/> />
@@ -158,6 +287,9 @@ function AppRoutes({
<Bookmarks <Bookmarks
relayPool={relayPool} relayPool={relayPool}
onLogout={handleLogout} onLogout={handleLogout}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
/> />
} }
/> />
@@ -167,10 +299,25 @@ function AppRoutes({
<Bookmarks <Bookmarks
relayPool={relayPool} relayPool={relayPool}
onLogout={handleLogout} 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}
/> />
} }
/> />
<Route path="/debug" element={<Debug />} />
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} /> <Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
</Routes> </Routes>
) )
@@ -189,6 +336,10 @@ function App() {
const store = new EventStore() const store = new EventStore()
const accounts = new AccountManager() 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) // Register common account types (needed for deserialization)
registerCommonAccountTypes(accounts) registerCommonAccountTypes(accounts)
@@ -199,9 +350,13 @@ function App() {
// wait for every relay send to finish. Responses still resolve the pending request. // wait for every relay send to finish. Responses still resolve the pending request.
NostrConnectSigner.subscriptionMethod = pool.subscription.bind(pool) NostrConnectSigner.subscriptionMethod = pool.subscription.bind(pool)
NostrConnectSigner.publishMethod = (relays: string[], event: unknown) => { NostrConnectSigner.publishMethod = (relays: string[], event: unknown) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result: any = pool.publish(relays, event as 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') { if (result && typeof (result as any).subscribe === 'function') {
try { (result as any).subscribe({ complete: () => {}, error: () => {} }) } catch {} // 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 an already-resolved promise so upstream await finishes immediately
return Promise.resolve() return Promise.resolve()
@@ -358,7 +513,8 @@ function App() {
// Observable/Promise to upstream to avoid their awaiting of completion. // Observable/Promise to upstream to avoid their awaiting of completion.
const result = originalPublish(relays, event) const result = originalPublish(relays, event)
if (result && typeof (result as { subscribe?: unknown }).subscribe === 'function') { if (result && typeof (result as { subscribe?: unknown }).subscribe === 'function') {
try { (result as { subscribe: (h: { complete?: () => void; error?: (e: unknown) => void }) => unknown }).subscribe({ complete: () => {}, error: () => {} }) } catch {} // 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. // 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 // Return a benign object so callers that probe for a "subscribe" property
@@ -531,7 +687,7 @@ function App() {
<AccountsProvider manager={accountManager}> <AccountsProvider manager={accountManager}>
<BrowserRouter> <BrowserRouter>
<div className="min-h-screen p-0 max-w-none m-0 relative"> <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 /> <RouteDebug />
</div> </div>
</BrowserRouter> </BrowserRouter>

View File

@@ -1,7 +1,7 @@
import React, { useRef, useState } from 'react' import React, { useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate, faHeart, faPlus } 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 { formatDistanceToNow } from 'date-fns'
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { Bookmark, IndividualBookmark } from '../types/bookmarks' import { Bookmark, IndividualBookmark } from '../types/bookmarks'
@@ -65,8 +65,18 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
const friendsColor = settings?.highlightColorFriends || '#f97316' const friendsColor = settings?.highlightColorFriends || '#f97316'
const [showAddModal, setShowAddModal] = useState(false) const [showAddModal, setShowAddModal] = useState(false)
const [selectedFilter, setSelectedFilter] = useState<BookmarkFilterType>('all') 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 activeAccount = Hooks.useActiveAccount()
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[]) => { const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
if (!activeAccount || !relayPool) { if (!activeAccount || !relayPool) {
throw new Error('Please login to create bookmarks') throw new Error('Please login to create bookmarks')
@@ -98,14 +108,18 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks) const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
const bookmarkSets = getBookmarkSets(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 groups = groupIndividualBookmarks(bookmarksWithoutSet)
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [ const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
{ key: 'private', title: 'Private Bookmarks', items: groups.privateItems }, groupingMode === 'flat'
{ key: 'public', title: 'Public Bookmarks', items: groups.publicItems }, ? [{ key: 'all', title: `All Bookmarks (${bookmarksWithoutSet.length})`, items: bookmarksWithoutSet }]
{ key: 'web', title: 'Web Bookmarks', items: groups.web }, : [
{ key: 'amethyst', title: 'Legacy Bookmarks', items: groups.amethyst } { key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
] { key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
{ key: 'amethyst-private', title: 'Amethyst Private', items: groups.amethystPrivate },
{ key: 'amethyst-public', title: 'Amethyst Lists', items: groups.amethystPublic },
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb }
]
// Add bookmark sets as additional sections // Add bookmark sets as additional sections
bookmarkSets.forEach(set => { bookmarkSets.forEach(set => {
@@ -224,40 +238,49 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
style={{ color: friendsColor }} style={{ color: friendsColor }}
/> />
</div> </div>
<div className="view-mode-right"> {activeAccount && (
{onRefresh && ( <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 <IconButton
icon={faRotate} icon={groupingMode === 'grouped' ? faLayerGroup : faBars}
onClick={onRefresh} onClick={toggleGroupingMode}
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'} title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
ariaLabel="Refresh bookmarks" ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
variant="ghost" variant="ghost"
disabled={isRefreshing}
spin={isRefreshing}
/> />
)} <IconButton
<IconButton icon={faList}
icon={faList} onClick={() => onViewModeChange('compact')}
onClick={() => onViewModeChange('compact')} title="Compact list view"
title="Compact list view" ariaLabel="Compact list view"
ariaLabel="Compact list view" variant={viewMode === 'compact' ? 'primary' : 'ghost'}
variant={viewMode === 'compact' ? 'primary' : 'ghost'} />
/> <IconButton
<IconButton icon={faThLarge}
icon={faThLarge} onClick={() => onViewModeChange('cards')}
onClick={() => onViewModeChange('cards')} title="Cards view"
title="Cards view" ariaLabel="Cards view"
ariaLabel="Cards view" variant={viewMode === 'cards' ? 'primary' : 'ghost'}
variant={viewMode === 'cards' ? 'primary' : 'ghost'} />
/> <IconButton
<IconButton icon={faImage}
icon={faImage} onClick={() => onViewModeChange('large')}
onClick={() => onViewModeChange('large')} title="Large preview view"
title="Large preview view" ariaLabel="Large preview view"
ariaLabel="Large preview view" variant={viewMode === 'large' ? 'primary' : 'ghost'}
variant={viewMode === 'large' ? 'primary' : 'ghost'} />
/> </div>
</div> )}
</div> </div>
{showAddModal && ( {showAddModal && (
<AddBookmarkModal <AddBookmarkModal

View File

@@ -13,6 +13,7 @@ import { useHighlightCreation } from '../hooks/useHighlightCreation'
import { useBookmarksUI } from '../hooks/useBookmarksUI' import { useBookmarksUI } from '../hooks/useBookmarksUI'
import { useRelayStatus } from '../hooks/useRelayStatus' import { useRelayStatus } from '../hooks/useRelayStatus'
import { useOfflineSync } from '../hooks/useOfflineSync' import { useOfflineSync } from '../hooks/useOfflineSync'
import { Bookmark } from '../types/bookmarks'
import ThreePaneLayout from './ThreePaneLayout' import ThreePaneLayout from './ThreePaneLayout'
import Explore from './Explore' import Explore from './Explore'
import Me from './Me' import Me from './Me'
@@ -24,9 +25,18 @@ export type ViewMode = 'compact' | 'cards' | 'large'
interface BookmarksProps { interface BookmarksProps {
relayPool: RelayPool | null relayPool: RelayPool | null
onLogout: () => void 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 { naddr, npub } = useParams<{ naddr?: string; npub?: string }>()
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
@@ -152,8 +162,6 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
}, [navigationState, setIsHighlightsCollapsed, setSelectedHighlightId, navigate, location.pathname]) }, [navigationState, setIsHighlightsCollapsed, setSelectedHighlightId, navigate, location.pathname])
const { const {
bookmarks,
bookmarksLoading,
highlights, highlights,
setHighlights, setHighlights,
highlightsLoading, highlightsLoading,
@@ -166,12 +174,13 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
} = useBookmarksData({ } = useBookmarksData({
relayPool, relayPool,
activeAccount, activeAccount,
accountManager,
naddr, naddr,
externalUrl, externalUrl,
currentArticleCoordinate, currentArticleCoordinate,
currentArticleEventId, currentArticleEventId,
settings settings,
eventStore,
onRefreshBookmarks
}) })
const { const {
@@ -234,6 +243,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
useExternalUrlLoader({ useExternalUrlLoader({
url: externalUrl, url: externalUrl,
relayPool, relayPool,
eventStore,
setSelectedUrl, setSelectedUrl,
setReaderContent, setReaderContent,
setReaderLoading, setReaderLoading,
@@ -317,10 +327,10 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
) : undefined} ) : undefined}
me={showMe ? ( me={showMe ? (
relayPool ? <Me relayPool={relayPool} activeTab={meTab} /> : null relayPool ? <Me relayPool={relayPool} eventStore={eventStore} activeTab={meTab} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} /> : null
) : undefined} ) : undefined}
profile={showProfile && profilePubkey ? ( profile={showProfile && profilePubkey ? (
relayPool ? <Me relayPool={relayPool} activeTab={profileTab} pubkey={profilePubkey} /> : null relayPool ? <Me relayPool={relayPool} eventStore={eventStore} activeTab={profileTab} pubkey={profilePubkey} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} /> : null
) : undefined} ) : undefined}
support={showSupport ? ( support={showSupport ? (
relayPool ? <Support relayPool={relayPool} eventStore={eventStore} settings={settings} /> : null relayPool ? <Support relayPool={relayPool} eventStore={eventStore} settings={settings} /> : null

View File

@@ -1,18 +1,61 @@
import React, { useEffect, useMemo, useState } from 'react' import React, { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faClock, faSpinner } from '@fortawesome/free-solid-svg-icons' import { faClock, faSpinner } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react' import { Hooks } from 'applesauce-react'
import { Accounts } from 'applesauce-accounts' import { Accounts } from 'applesauce-accounts'
import { NostrConnectSigner } from 'applesauce-signers' import { NostrConnectSigner } from 'applesauce-signers'
import { RelayPool } from 'applesauce-relay'
import { Helpers, IEventStore } from 'applesauce-core'
import { nip19 } from 'nostr-tools'
import { getDefaultBunkerPermissions } from '../services/nostrConnect' import { getDefaultBunkerPermissions } from '../services/nostrConnect'
import { DebugBus, type DebugLogEntry } from '../utils/debugBus' import { DebugBus, type DebugLogEntry } from '../utils/debugBus'
import VersionFooter from './VersionFooter' import ThreePaneLayout from './ThreePaneLayout'
import { KINDS } from '../config/kinds'
import type { NostrEvent } from '../services/bookmarkHelpers'
import { Bookmark } from '../types/bookmarks'
import { useBookmarksUI } from '../hooks/useBookmarksUI'
import { useSettings } from '../hooks/useSettings'
import { fetchHighlights, fetchHighlightsFromAuthors } from '../services/highlightService'
import { contactsController } from '../services/contactsController'
const defaultPayload = 'The quick brown fox jumps over the lazy dog.' const defaultPayload = 'The quick brown fox jumps over the lazy dog.'
const Debug: React.FC = () => { interface DebugProps {
relayPool: RelayPool | null
eventStore: IEventStore | null
bookmarks: Bookmark[]
bookmarksLoading: boolean
onRefreshBookmarks: () => Promise<void>
onLogout: () => void
}
const Debug: React.FC<DebugProps> = ({
relayPool,
eventStore,
bookmarks,
bookmarksLoading,
onRefreshBookmarks,
onLogout
}) => {
const navigate = useNavigate()
const activeAccount = Hooks.useActiveAccount() const activeAccount = Hooks.useActiveAccount()
const accountManager = Hooks.useAccountManager() const accountManager = Hooks.useAccountManager()
const { settings, saveSettings } = useSettings({
relayPool,
eventStore: eventStore!,
pubkey: activeAccount?.pubkey,
accountManager
})
const {
isMobile,
isCollapsed,
setIsCollapsed,
viewMode,
setViewMode
} = useBookmarksUI({ settings })
const [payload, setPayload] = useState<string>(defaultPayload) const [payload, setPayload] = useState<string>(defaultPayload)
const [cipher44, setCipher44] = useState<string>('') const [cipher44, setCipher44] = useState<string>('')
const [cipher04, setCipher04] = useState<string>('') const [cipher04, setCipher04] = useState<string>('')
@@ -30,11 +73,39 @@ const Debug: React.FC = () => {
const [isBunkerLoading, setIsBunkerLoading] = useState<boolean>(false) const [isBunkerLoading, setIsBunkerLoading] = useState<boolean>(false)
const [bunkerError, setBunkerError] = useState<string | null>(null) const [bunkerError, setBunkerError] = useState<string | null>(null)
// Bookmark loading state
const [bookmarkEvents, setBookmarkEvents] = useState<NostrEvent[]>([])
const [isLoadingBookmarks, setIsLoadingBookmarks] = useState(false)
const [bookmarkStats, setBookmarkStats] = useState<{ public: number; private: number } | null>(null)
const [tLoadBookmarks, setTLoadBookmarks] = useState<number | null>(null)
const [tDecryptBookmarks, setTDecryptBookmarks] = useState<number | null>(null)
const [tFirstBookmark, setTFirstBookmark] = useState<number | null>(null)
// Individual event decryption results
const [decryptedEvents, setDecryptedEvents] = useState<Map<string, { public: number; private: number }>>(new Map())
// Highlight loading state
const [highlightMode, setHighlightMode] = useState<'article' | 'url' | 'author'>('author')
const [highlightArticleCoord, setHighlightArticleCoord] = useState<string>('')
const [highlightUrl, setHighlightUrl] = useState<string>('')
const [highlightAuthor, setHighlightAuthor] = useState<string>('')
const [isLoadingHighlights, setIsLoadingHighlights] = useState(false)
const [highlightEvents, setHighlightEvents] = useState<NostrEvent[]>([])
const [tLoadHighlights, setTLoadHighlights] = useState<number | null>(null)
const [tFirstHighlight, setTFirstHighlight] = useState<number | null>(null)
// Live timing state // Live timing state
const [liveTiming, setLiveTiming] = useState<{ const [liveTiming, setLiveTiming] = useState<{
nip44?: { type: 'encrypt' | 'decrypt'; startTime: number } nip44?: { type: 'encrypt' | 'decrypt'; startTime: number }
nip04?: { type: 'encrypt' | 'decrypt'; startTime: number } nip04?: { type: 'encrypt' | 'decrypt'; startTime: number }
loadBookmarks?: { startTime: number }
decryptBookmarks?: { startTime: number }
loadHighlights?: { startTime: number }
}>({}) }>({})
// Web of Trust state
const [friendsPubkeys, setFriendsPubkeys] = useState<Set<string>>(new Set())
const [friendsButtonLoading, setFriendsButtonLoading] = useState(false)
useEffect(() => { useEffect(() => {
return DebugBus.subscribe((e) => setLogs(prev => [...prev, e].slice(-300))) return DebugBus.subscribe((e) => setLogs(prev => [...prev, e].slice(-300)))
@@ -55,6 +126,63 @@ const Debug: React.FC = () => {
const hasNip04 = typeof (signer as { nip04?: { encrypt?: unknown; decrypt?: unknown } } | undefined)?.nip04?.encrypt === 'function' const hasNip04 = typeof (signer as { nip04?: { encrypt?: unknown; decrypt?: unknown } } | undefined)?.nip04?.encrypt === 'function'
const hasNip44 = typeof (signer as { nip44?: { encrypt?: unknown; decrypt?: unknown } } | undefined)?.nip44?.encrypt === 'function' const hasNip44 = typeof (signer as { nip44?: { encrypt?: unknown; decrypt?: unknown } } | undefined)?.nip44?.encrypt === 'function'
const getKindName = (kind: number): string => {
switch (kind) {
case KINDS.ListSimple: return 'Simple List (10003)'
case KINDS.ListReplaceable: return 'Replaceable List (30003)'
case KINDS.List: return 'List (30001)'
case KINDS.WebBookmark: return 'Web Bookmark (39701)'
default: return `Kind ${kind}`
}
}
const getEventSize = (evt: NostrEvent): number => {
const content = evt.content || ''
const tags = JSON.stringify(evt.tags || [])
return content.length + tags.length
}
const hasEncryptedContent = (evt: NostrEvent): boolean => {
// Check for NIP-44 encrypted content (detected by Helpers)
if (Helpers.hasHiddenContent(evt)) return true
// Check for NIP-04 encrypted content (base64 with ?iv= suffix)
if (evt.content && evt.content.includes('?iv=')) return true
// Check for encrypted tags
if (Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt)) return true
return false
}
const getBookmarkCount = (evt: NostrEvent): { public: number; private: number } => {
const publicTags = (evt.tags || []).filter((t: string[]) => t[0] === 'e' || t[0] === 'a')
const hasEncrypted = hasEncryptedContent(evt)
return {
public: publicTags.length,
private: hasEncrypted ? 1 : 0 // Can't know exact count until decrypted
}
}
const formatBytes = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
}
const getEventKey = (evt: NostrEvent): string => {
if (evt.kind === 30003 || evt.kind === 30001) {
// Replaceable: kind:pubkey:dtag
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
return `${evt.kind}:${evt.pubkey}:${dTag}`
} else if (evt.kind === 10003) {
// Simple list: kind:pubkey
return `${evt.kind}:${evt.pubkey}`
}
// Web bookmarks: use event id (no deduplication)
return evt.id
}
const doEncrypt = async (mode: 'nip44' | 'nip04') => { const doEncrypt = async (mode: 'nip44' | 'nip04') => {
if (!signer || !pubkey) return if (!signer || !pubkey) return
try { try {
@@ -123,6 +251,330 @@ const Debug: React.FC = () => {
else localStorage.removeItem('debug') else localStorage.removeItem('debug')
} }
const handleLoadBookmarks = async () => {
if (!relayPool || !activeAccount) {
DebugBus.warn('debug', 'Cannot load bookmarks: missing relayPool or activeAccount')
return
}
try {
setIsLoadingBookmarks(true)
setBookmarkStats(null)
setBookmarkEvents([]) // Clear existing events
setDecryptedEvents(new Map())
setTFirstBookmark(null)
DebugBus.info('debug', 'Loading bookmark events...')
// Start timing
const start = performance.now()
let firstEventTime: number | null = null
setLiveTiming(prev => ({ ...prev, loadBookmarks: { startTime: start } }))
// Import controller at runtime to avoid circular dependencies
const { bookmarkController } = await import('../services/bookmarkController')
// Subscribe to raw events for Debug UI display
const unsubscribeRaw = bookmarkController.onRawEvent((evt) => {
// Track time to first event
if (firstEventTime === null) {
firstEventTime = performance.now() - start
setTFirstBookmark(Math.round(firstEventTime))
}
// Add event immediately with live deduplication
setBookmarkEvents(prev => {
const key = getEventKey(evt)
const existingIdx = prev.findIndex(e => getEventKey(e) === key)
if (existingIdx >= 0) {
const existing = prev[existingIdx]
if ((evt.created_at || 0) > (existing.created_at || 0)) {
const newEvents = [...prev]
newEvents[existingIdx] = evt
return newEvents
}
return prev
}
return [...prev, evt]
})
})
// Subscribe to decrypt complete events for Debug UI display
const unsubscribeDecrypt = bookmarkController.onDecryptComplete((eventId, publicCount, privateCount) => {
console.log('[bunker] ✅ Auto-decrypted:', eventId.slice(0, 8), {
public: publicCount,
private: privateCount
})
setDecryptedEvents(prev => new Map(prev).set(eventId, {
public: publicCount,
private: privateCount
}))
})
// Start the controller (triggers app bookmark population too)
bookmarkController.reset()
await bookmarkController.start({ relayPool, activeAccount, accountManager })
// Clean up subscriptions
unsubscribeRaw()
unsubscribeDecrypt()
const ms = Math.round(performance.now() - start)
setLiveTiming(prev => ({ ...prev, loadBookmarks: undefined }))
setTLoadBookmarks(ms)
DebugBus.info('debug', `Loaded bookmark events`, { ms })
} catch (error) {
setLiveTiming(prev => ({ ...prev, loadBookmarks: undefined }))
DebugBus.error('debug', 'Failed to load bookmarks', error instanceof Error ? error.message : String(error))
} finally {
setIsLoadingBookmarks(false)
}
}
const handleClearBookmarks = () => {
setBookmarkEvents([])
setBookmarkStats(null)
setTLoadBookmarks(null)
setTDecryptBookmarks(null)
setTFirstBookmark(null)
setDecryptedEvents(new Map())
DebugBus.info('debug', 'Cleared bookmark data')
}
const handleLoadHighlights = async () => {
if (!relayPool) {
DebugBus.warn('debug', 'Cannot load highlights: missing relayPool')
return
}
// Default to logged-in user's highlights if no specific query provided
const getValue = () => {
if (highlightMode === 'article') return highlightArticleCoord.trim()
if (highlightMode === 'url') return highlightUrl.trim()
const authorValue = highlightAuthor.trim()
return authorValue || pubkey || ''
}
const value = getValue()
if (!value) {
DebugBus.warn('debug', 'Please provide a value to query or log in')
return
}
try {
setIsLoadingHighlights(true)
setHighlightEvents([])
setTFirstHighlight(null)
DebugBus.info('debug', `Loading highlights (${highlightMode}: ${value})...`)
const start = performance.now()
setLiveTiming(prev => ({ ...prev, loadHighlights: { startTime: start } }))
let firstEventTime: number | null = null
const seenIds = new Set<string>()
// Import highlight services
const { queryEvents } = await import('../services/dataFetch')
const { KINDS } = await import('../config/kinds')
// Build filter based on mode
let filter: { kinds: number[]; '#a'?: string[]; '#r'?: string[]; authors?: string[] }
if (highlightMode === 'article') {
filter = { kinds: [KINDS.Highlights], '#a': [value] }
} else if (highlightMode === 'url') {
filter = { kinds: [KINDS.Highlights], '#r': [value] }
} else {
filter = { kinds: [KINDS.Highlights], authors: [value] }
}
const events = await queryEvents(relayPool, filter, {
onEvent: (evt) => {
if (seenIds.has(evt.id)) return
seenIds.add(evt.id)
if (firstEventTime === null) {
firstEventTime = performance.now() - start
setTFirstHighlight(Math.round(firstEventTime))
}
setHighlightEvents(prev => [...prev, evt])
}
})
const elapsed = Math.round(performance.now() - start)
setTLoadHighlights(elapsed)
setLiveTiming(prev => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
const { loadHighlights, ...rest } = prev
return rest
})
DebugBus.info('debug', `Loaded ${events.length} highlight events in ${elapsed}ms`)
} catch (err) {
console.error('Failed to load highlights:', err)
DebugBus.error('debug', `Failed to load highlights: ${err instanceof Error ? err.message : String(err)}`)
} finally {
setIsLoadingHighlights(false)
}
}
const handleClearHighlights = () => {
setHighlightEvents([])
setTLoadHighlights(null)
setTFirstHighlight(null)
DebugBus.info('debug', 'Cleared highlight data')
}
const handleLoadMyHighlights = async () => {
if (!relayPool || !activeAccount?.pubkey) {
DebugBus.warn('debug', 'Please log in to load your highlights')
return
}
const start = performance.now()
setHighlightEvents([])
setIsLoadingHighlights(true)
setTLoadHighlights(null)
setTFirstHighlight(null)
DebugBus.info('debug', 'Loading my highlights...')
try {
let firstEventTime: number | null = null
await fetchHighlights(relayPool, activeAccount.pubkey, (h) => {
if (firstEventTime === null) {
firstEventTime = performance.now() - start
setTFirstHighlight(Math.round(firstEventTime))
}
setHighlightEvents(prev => {
if (prev.some(x => x.id === h.id)) return prev
const next = [...prev, { ...h, pubkey: h.pubkey, created_at: h.created_at, id: h.id, kind: 9802, tags: [], content: h.content, sig: '' } as NostrEvent]
return next.sort((a, b) => b.created_at - a.created_at)
})
}, settings, false, eventStore || undefined)
} finally {
setIsLoadingHighlights(false)
const elapsed = Math.round(performance.now() - start)
setTLoadHighlights(elapsed)
DebugBus.info('debug', `Loaded my highlights in ${elapsed}ms`)
}
}
const handleLoadFriendsHighlights = async () => {
if (!relayPool || !activeAccount?.pubkey) {
DebugBus.warn('debug', 'Please log in to load friends highlights')
return
}
// Get contacts from centralized controller (should already be loaded by App.tsx)
const contacts = contactsController.getContacts()
if (contacts.size === 0) {
DebugBus.warn('debug', 'No friends found. Make sure you have contacts loaded.')
return
}
const start = performance.now()
setHighlightEvents([])
setIsLoadingHighlights(true)
setTLoadHighlights(null)
setTFirstHighlight(null)
DebugBus.info('debug', `Loading highlights from ${contacts.size} friends (using cached contacts)...`)
let firstEventTime: number | null = null
try {
await fetchHighlightsFromAuthors(relayPool, Array.from(contacts), (h) => {
if (firstEventTime === null) {
firstEventTime = performance.now() - start
setTFirstHighlight(Math.round(firstEventTime))
}
setHighlightEvents(prev => {
if (prev.some(x => x.id === h.id)) return prev
const next = [...prev, { ...h, pubkey: h.pubkey, created_at: h.created_at, id: h.id, kind: 9802, tags: [], content: h.content, sig: '' } as NostrEvent]
return next.sort((a, b) => b.created_at - a.created_at)
})
}, eventStore || undefined)
} finally {
setIsLoadingHighlights(false)
const elapsed = Math.round(performance.now() - start)
setTLoadHighlights(elapsed)
DebugBus.info('debug', `Loaded friends highlights in ${elapsed}ms`)
}
}
const handleLoadNostrverseHighlights = async () => {
if (!relayPool) {
DebugBus.warn('debug', 'Relay pool not available')
return
}
const start = performance.now()
setHighlightEvents([])
setIsLoadingHighlights(true)
setTLoadHighlights(null)
setTFirstHighlight(null)
DebugBus.info('debug', 'Loading nostrverse highlights (kind:9802)...')
try {
let firstEventTime: number | null = null
const seenIds = new Set<string>()
const { queryEvents } = await import('../services/dataFetch')
const events = await queryEvents(relayPool, { kinds: [9802], limit: 500 }, {
onEvent: (evt) => {
if (seenIds.has(evt.id)) return
seenIds.add(evt.id)
if (firstEventTime === null) {
firstEventTime = performance.now() - start
setTFirstHighlight(Math.round(firstEventTime))
}
setHighlightEvents(prev => [...prev, evt])
}
})
DebugBus.info('debug', `Loaded ${events.length} nostrverse highlights`)
} finally {
setIsLoadingHighlights(false)
const elapsed = Math.round(performance.now() - start)
setTLoadHighlights(elapsed)
DebugBus.info('debug', `Loaded nostrverse highlights in ${elapsed}ms`)
}
}
const handleLoadFriendsList = async () => {
if (!relayPool || !activeAccount?.pubkey) {
DebugBus.warn('debug', 'Please log in to load friends list')
return
}
setFriendsButtonLoading(true)
DebugBus.info('debug', 'Loading friends list via controller...')
// Clear current list
setFriendsPubkeys(new Set())
// Subscribe to controller updates to see streaming
const unsubscribe = contactsController.onContacts((contacts) => {
console.log('[debug] Received contacts update:', contacts.size)
setFriendsPubkeys(new Set(contacts))
})
try {
// Force reload to see streaming behavior
await contactsController.start({ relayPool, pubkey: activeAccount.pubkey, force: true })
const final = contactsController.getContacts()
setFriendsPubkeys(new Set(final))
DebugBus.info('debug', `Loaded ${final.size} friends from controller`)
} catch (err) {
console.error('[debug] Failed to load friends:', err)
DebugBus.error('debug', `Failed to load friends: ${err instanceof Error ? err.message : String(err)}`)
} finally {
unsubscribe()
setFriendsButtonLoading(false)
}
}
const friendsNpubs = useMemo(() => {
return Array.from(friendsPubkeys).map(pk => nip19.npubEncode(pk))
}, [friendsPubkeys])
const handleBunkerLogin = async () => { const handleBunkerLogin = async () => {
if (!bunkerUri.trim()) { if (!bunkerUri.trim()) {
setBunkerError('Please enter a bunker URI') setBunkerError('Please enter a bunker URI')
@@ -184,13 +636,23 @@ const Debug: React.FC = () => {
return null return null
} }
const Stat = ({ label, value, mode, type }: { const getBookmarkLiveTiming = (operation: 'loadBookmarks' | 'decryptBookmarks' | 'loadHighlights') => {
const timing = liveTiming[operation]
if (timing) {
const elapsed = Math.round(performance.now() - timing.startTime)
return elapsed
}
return null
}
const Stat = ({ label, value, mode, type, bookmarkOp }: {
label: string; label: string;
value?: string | number | null; value?: string | number | null;
mode?: 'nip44' | 'nip04'; mode?: 'nip44' | 'nip04';
type?: 'encrypt' | 'decrypt'; type?: 'encrypt' | 'decrypt';
bookmarkOp?: 'loadBookmarks' | 'decryptBookmarks' | 'loadHighlights';
}) => { }) => {
const liveValue = mode && type ? getLiveTiming(mode, type) : null const liveValue = bookmarkOp ? getBookmarkLiveTiming(bookmarkOp) : (mode && type ? getLiveTiming(mode, type) : null)
const isLive = !!liveValue const isLive = !!liveValue
let displayValue: string let displayValue: string
@@ -214,7 +676,7 @@ const Debug: React.FC = () => {
) )
} }
return ( const debugContent = (
<div className="settings-view"> <div className="settings-view">
<div className="settings-header"> <div className="settings-header">
<h2>Debug</h2> <h2>Debug</h2>
@@ -225,9 +687,17 @@ const Debug: React.FC = () => {
<div className="settings-content"> <div className="settings-content">
{/* Bunker Login Section */} {/* Account Connection Section */}
<div className="settings-section"> <div className="settings-section">
<h3 className="section-title">Bunker Connection</h3> <h3 className="section-title">
{activeAccount
? activeAccount.type === 'extension'
? 'Browser Extension'
: activeAccount.type === 'nostr-connect'
? 'Bunker Connection'
: 'Account Connection'
: 'Account Connection'}
</h3>
{!activeAccount ? ( {!activeAccount ? (
<div> <div>
<div className="text-sm opacity-70 mb-3">Connect to your bunker (Nostr Connect signer) to enable encryption/decryption testing</div> <div className="text-sm opacity-70 mb-3">Connect to your bunker (Nostr Connect signer) to enable encryption/decryption testing</div>
@@ -255,7 +725,13 @@ const Debug: React.FC = () => {
) : ( ) : (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<div className="text-sm opacity-70">Connected to bunker</div> <div className="text-sm opacity-70">
{activeAccount.type === 'extension'
? 'Connected via browser extension'
: activeAccount.type === 'nostr-connect'
? 'Connected to bunker'
: 'Connected'}
</div>
<div className="text-sm font-mono">{pubkey}</div> <div className="text-sm font-mono">{pubkey}</div>
</div> </div>
<button <button
@@ -350,6 +826,286 @@ const Debug: React.FC = () => {
</div> </div>
</div> </div>
{/* Bookmark Loading Section */}
<div className="settings-section">
<h3 className="section-title">Bookmark Loading</h3>
<div className="text-sm opacity-70 mb-3">Test bookmark loading with auto-decryption (kinds: 10003, 30003, 30001, 39701)</div>
<div className="flex gap-2 mb-3 items-center">
<button
className="btn btn-primary"
onClick={handleLoadBookmarks}
disabled={isLoadingBookmarks || !relayPool || !activeAccount}
>
{isLoadingBookmarks ? (
<>
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-2" />
Loading...
</>
) : (
'Load Bookmarks'
)}
</button>
<button
className="btn btn-secondary ml-auto"
onClick={handleClearBookmarks}
disabled={bookmarkEvents.length === 0 && !bookmarkStats}
>
Clear
</button>
</div>
<div className="mb-3 flex gap-2 flex-wrap">
<Stat label="total" value={tLoadBookmarks} bookmarkOp="loadBookmarks" />
<Stat label="first event" value={tFirstBookmark} />
<Stat label="decrypt" value={tDecryptBookmarks} bookmarkOp="decryptBookmarks" />
</div>
{bookmarkStats && (
<div className="mb-3">
<div className="text-sm opacity-70 mb-2">Decrypted Bookmarks:</div>
<div className="font-mono text-xs p-2 bg-gray-100 dark:bg-gray-800 rounded">
<div>Public: {bookmarkStats.public}</div>
<div>Private: {bookmarkStats.private}</div>
<div className="font-semibold mt-1">Total: {bookmarkStats.public + bookmarkStats.private}</div>
</div>
</div>
)}
{bookmarkEvents.length > 0 && (
<div className="mb-3">
<div className="text-sm opacity-70 mb-2">Loaded Events ({bookmarkEvents.length}):</div>
<div className="space-y-2">
{bookmarkEvents.map((evt, idx) => {
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1]
const titleTag = evt.tags?.find((t: string[]) => t[0] === 'title')?.[1]
const size = getEventSize(evt)
const counts = getBookmarkCount(evt)
const hasEncrypted = hasEncryptedContent(evt)
const decryptResult = decryptedEvents.get(evt.id)
return (
<div key={idx} className="font-mono text-xs p-2 bg-gray-100 dark:bg-gray-800 rounded">
<div className="font-semibold mb-1">{getKindName(evt.kind)}</div>
{dTag && <div className="opacity-70">d-tag: {dTag}</div>}
{titleTag && <div className="opacity-70">title: {titleTag}</div>}
<div className="mt-1">
<div>Size: {formatBytes(size)}</div>
<div>Public: {counts.public}</div>
{hasEncrypted && <div>🔒 Has encrypted content</div>}
</div>
{decryptResult && (
<div className="mt-1 text-[11px] opacity-80">
<div> Decrypted: {decryptResult.public} public, {decryptResult.private} private</div>
</div>
)}
<div className="opacity-50 mt-1 text-[10px] break-all">ID: {evt.id}</div>
</div>
)
})}
</div>
</div>
)}
</div>
{/* Highlight Loading Section */}
<div className="settings-section">
<h3 className="section-title">Highlight Loading</h3>
<div className="text-sm opacity-70 mb-3">Test highlight loading with EOSE-based queryEvents (kind: 9802). Author mode defaults to your highlights.</div>
<div className="mb-3">
<div className="text-sm opacity-70 mb-2">Query Mode:</div>
<div className="flex gap-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
checked={highlightMode === 'article'}
onChange={() => setHighlightMode('article')}
/>
<span>Article (#a)</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
checked={highlightMode === 'url'}
onChange={() => setHighlightMode('url')}
/>
<span>URL (#r)</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
checked={highlightMode === 'author'}
onChange={() => setHighlightMode('author')}
/>
<span>Author</span>
</label>
</div>
</div>
<div className="mb-3">
{highlightMode === 'article' && (
<input
type="text"
className="input w-full"
placeholder="30023:pubkey:identifier"
value={highlightArticleCoord}
onChange={(e) => setHighlightArticleCoord(e.target.value)}
disabled={isLoadingHighlights}
/>
)}
{highlightMode === 'url' && (
<input
type="text"
className="input w-full"
placeholder="https://example.com/article"
value={highlightUrl}
onChange={(e) => setHighlightUrl(e.target.value)}
disabled={isLoadingHighlights}
/>
)}
{highlightMode === 'author' && (
<input
type="text"
className="input w-full"
placeholder={pubkey ? `${pubkey.slice(0, 16)}... (logged-in user)` : 'pubkey (hex)'}
value={highlightAuthor}
onChange={(e) => setHighlightAuthor(e.target.value)}
disabled={isLoadingHighlights}
/>
)}
</div>
<div className="flex gap-2 mb-3 items-center">
<button
className="btn btn-primary"
onClick={handleLoadHighlights}
disabled={isLoadingHighlights || !relayPool}
>
{isLoadingHighlights ? (
<>
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-2" />
Loading...
</>
) : (
'Load Highlights'
)}
</button>
<button
className="btn btn-secondary ml-auto"
onClick={handleClearHighlights}
disabled={highlightEvents.length === 0}
>
Clear
</button>
</div>
<div className="mb-3 text-sm opacity-70">Quick load options:</div>
<div className="flex gap-2 mb-3 flex-wrap">
<button
className="btn btn-secondary text-sm"
onClick={handleLoadMyHighlights}
disabled={isLoadingHighlights || !relayPool || !activeAccount}
>
Load My Highlights
</button>
<button
className="btn btn-secondary text-sm"
onClick={handleLoadFriendsHighlights}
disabled={isLoadingHighlights || !relayPool || !activeAccount}
>
Load Friends Highlights
</button>
<button
className="btn btn-secondary text-sm"
onClick={handleLoadNostrverseHighlights}
disabled={isLoadingHighlights || !relayPool}
>
Load Nostrverse Highlights
</button>
</div>
<div className="mb-3 flex gap-2 flex-wrap">
<Stat label="total" value={tLoadHighlights} bookmarkOp="loadHighlights" />
<Stat label="first event" value={tFirstHighlight} />
</div>
{highlightEvents.length > 0 && (
<div className="mb-3">
<div className="text-sm opacity-70 mb-2">Loaded Highlights ({highlightEvents.length}):</div>
<div className="space-y-2 max-h-96 overflow-y-auto">
{highlightEvents.map((evt, idx) => {
const content = evt.content || ''
const shortContent = content.length > 100 ? content.substring(0, 100) + '...' : content
const aTag = evt.tags?.find((t: string[]) => t[0] === 'a')?.[1]
const rTag = evt.tags?.find((t: string[]) => t[0] === 'r')?.[1]
const eTag = evt.tags?.find((t: string[]) => t[0] === 'e')?.[1]
const contextTag = evt.tags?.find((t: string[]) => t[0] === 'context')?.[1]
return (
<div key={idx} className="font-mono text-xs p-2 bg-gray-100 dark:bg-gray-800 rounded">
<div className="font-semibold mb-1">Highlight #{idx + 1}</div>
<div className="opacity-70 mb-1">
<div>Author: {evt.pubkey.slice(0, 16)}...</div>
<div>Created: {new Date(evt.created_at * 1000).toLocaleString()}</div>
</div>
<div className="mt-1">
<div className="font-semibold text-[11px]">Content:</div>
<div className="italic">&quot;{shortContent}&quot;</div>
</div>
{contextTag && (
<div className="mt-1 text-[11px] opacity-70">
<div>Context: {contextTag.substring(0, 60)}...</div>
</div>
)}
{aTag && <div className="mt-1 text-[11px] opacity-70">#a: {aTag}</div>}
{rTag && <div className="mt-1 text-[11px] opacity-70">#r: {rTag}</div>}
{eTag && <div className="mt-1 text-[11px] opacity-70">#e: {eTag.slice(0, 16)}...</div>}
<div className="opacity-50 mt-1 text-[10px] break-all">ID: {evt.id}</div>
</div>
)
})}
</div>
</div>
)}
</div>
{/* Web of Trust Section */}
<div className="settings-section">
<h3 className="section-title">Web of Trust</h3>
<div className="text-sm opacity-70 mb-3">Load your followed contacts (friends) for highlight fetching:</div>
<div className="mb-3">
<button
className="btn btn-primary"
onClick={handleLoadFriendsList}
disabled={friendsButtonLoading || !relayPool || !activeAccount}
>
{friendsButtonLoading ? (
<>
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-2" />
Loading...
</>
) : (
'Load Friends'
)}
</button>
</div>
{friendsPubkeys.size > 0 && (
<div className="mb-3">
<div className="text-sm opacity-70 mb-2">Friends Count: {friendsNpubs.length}</div>
<div className="font-mono text-xs max-h-48 overflow-y-auto bg-gray-100 dark:bg-gray-800 p-3 rounded space-y-1">
{friendsNpubs.map(npub => (
<div key={npub} title={npub} className="truncate hover:text-clip hover:whitespace-normal cursor-pointer">
{npub}
</div>
))}
</div>
</div>
)}
</div>
{/* Debug Logs Section */} {/* Debug Logs Section */}
<div className="settings-section"> <div className="settings-section">
<h3 className="section-title">Debug Logs</h3> <h3 className="section-title">Debug Logs</h3>
@@ -386,10 +1142,61 @@ const Debug: React.FC = () => {
</div> </div>
</div> </div>
</div> </div>
<VersionFooter />
</div> </div>
) )
return (
<ThreePaneLayout
isCollapsed={isCollapsed}
isHighlightsCollapsed={true}
isSidebarOpen={false}
showSettings={false}
showSupport={true}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
viewMode={viewMode}
isRefreshing={false}
lastFetchTime={null}
onToggleSidebar={isMobile ? () => {} : () => setIsCollapsed(!isCollapsed)}
onLogout={onLogout}
onViewModeChange={setViewMode}
onOpenSettings={() => navigate('/settings')}
onRefresh={onRefreshBookmarks}
relayPool={relayPool}
eventStore={eventStore}
readerLoading={false}
readerContent={undefined}
selectedUrl={undefined}
settings={settings}
onSaveSettings={saveSettings}
onCloseSettings={() => navigate('/')}
classifiedHighlights={[]}
showHighlights={false}
selectedHighlightId={undefined}
highlightVisibility={{ nostrverse: true, friends: true, mine: true }}
onHighlightClick={() => {}}
onTextSelection={() => {}}
onClearSelection={() => {}}
currentUserPubkey={activeAccount?.pubkey}
followedPubkeys={new Set()}
activeAccount={activeAccount}
currentArticle={null}
highlights={[]}
highlightsLoading={false}
onToggleHighlightsPanel={() => {}}
onSelectUrl={() => {}}
onToggleHighlights={() => {}}
onRefreshHighlights={() => {}}
onHighlightVisibilityChange={() => {}}
highlightButtonRef={{ current: null }}
onCreateHighlight={() => {}}
hasActiveAccount={!!activeAccount}
toastMessage={undefined}
toastType={undefined}
onClearToast={() => {}}
support={debugContent}
/>
)
} }
export default Debug export default Debug

View File

@@ -1,18 +1,19 @@
import React, { useState, useEffect, useMemo } from 'react' import React, { useState, useEffect, useMemo, useCallback } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate, faSpinner } from '@fortawesome/free-solid-svg-icons' import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate, faSpinner } from '@fortawesome/free-solid-svg-icons'
import IconButton from './IconButton' import IconButton from './IconButton'
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons' import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
import { Hooks } from 'applesauce-react' import { Hooks } from 'applesauce-react'
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core' import { IEventStore, Helpers } from 'applesauce-core'
import { nip19 } from 'nostr-tools' import { nip19, NostrEvent } from 'nostr-tools'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { fetchContacts } from '../services/contactService' import { fetchContacts } from '../services/contactService'
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService' import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
import { fetchHighlightsFromAuthors } from '../services/highlightService' import { fetchHighlightsFromAuthors } from '../services/highlightService'
import { fetchProfiles } from '../services/profileService' import { fetchProfiles } from '../services/profileService'
import { fetchNostrverseBlogPosts, fetchNostrverseHighlights } from '../services/nostrverseService' import { fetchNostrverseBlogPosts, fetchNostrverseHighlights } from '../services/nostrverseService'
import { highlightsController } from '../services/highlightsController'
import { Highlight } from '../types/highlights' import { Highlight } from '../types/highlights'
import { UserSettings } from '../services/settingsService' import { UserSettings } from '../services/settingsService'
import BlogPostCard from './BlogPostCard' import BlogPostCard from './BlogPostCard'
@@ -22,6 +23,12 @@ import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator' import RefreshIndicator from './RefreshIndicator'
import { classifyHighlights } from '../utils/highlightClassification' import { classifyHighlights } from '../utils/highlightClassification'
import { HighlightVisibility } from './HighlightsPanel' 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'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
interface ExploreProps { interface ExploreProps {
relayPool: RelayPool relayPool: RelayPool
@@ -42,13 +49,41 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [refreshTrigger, setRefreshTrigger] = useState(0) const [refreshTrigger, setRefreshTrigger] = useState(0)
// Visibility filters (defaults from settings, or friends only) // Get myHighlights directly from controller
const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
const [myHighlightsLoading, setMyHighlightsLoading] = useState(false)
// 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)
const [visibility, setVisibility] = useState<HighlightVisibility>({ const [visibility, setVisibility] = useState<HighlightVisibility>({
nostrverse: settings?.defaultHighlightVisibilityNostrverse ?? false, nostrverse: settings?.defaultExploreScopeNostrverse ?? false,
friends: settings?.defaultHighlightVisibilityFriends ?? true, friends: settings?.defaultExploreScopeFriends ?? true,
mine: settings?.defaultHighlightVisibilityMine ?? false mine: settings?.defaultExploreScopeMine ?? false
}) })
// Subscribe to highlights controller
useEffect(() => {
const unsubHighlights = highlightsController.onHighlights(setMyHighlights)
const unsubLoading = highlightsController.onLoading(setMyHighlightsLoading)
return () => {
unsubHighlights()
unsubLoading()
}
}, [])
// Update local state when prop changes // Update local state when prop changes
useEffect(() => { useEffect(() => {
if (propActiveTab) { if (propActiveTab) {
@@ -68,14 +103,34 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
setLoading(true) setLoading(true)
// Seed from in-memory cache if available to avoid empty flash // Seed from in-memory cache if available to avoid empty flash
// Use functional update to check current state without creating dependency const memoryCachedPosts = getCachedPosts(activeAccount.pubkey)
const cachedPosts = getCachedPosts(activeAccount.pubkey) if (memoryCachedPosts && memoryCachedPosts.length > 0) {
if (cachedPosts && cachedPosts.length > 0) { setBlogPosts(prev => prev.length === 0 ? memoryCachedPosts : prev)
setBlogPosts(prev => prev.length === 0 ? cachedPosts : prev)
} }
const cachedHighlights = getCachedHighlights(activeAccount.pubkey) const memoryCachedHighlights = getCachedHighlights(activeAccount.pubkey)
if (cachedHighlights && cachedHighlights.length > 0) { if (memoryCachedHighlights && memoryCachedHighlights.length > 0) {
setHighlights(prev => prev.length === 0 ? cachedHighlights : prev) 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
})
})
} }
// Fetch the user's contacts (friends) // Fetch the user's contacts (friends)
@@ -97,8 +152,31 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
relayUrls, relayUrls,
(post) => { (post) => {
setBlogPosts((prev) => { setBlogPosts((prev) => {
const exists = prev.some(p => p.event.id === post.event.id) // Deduplicate by author:d-tag (replaceable event key)
if (exists) return 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 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] const next = [...prev, post]
return next.sort((a, b) => { return next.sort((a, b) => {
const timeA = a.published || a.event.created_at const timeA = a.published || a.event.created_at
@@ -110,9 +188,27 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
} }
).then((all) => { ).then((all) => {
setBlogPosts((prev) => { setBlogPosts((prev) => {
const byId = new Map(prev.map(p => [p.event.id, p])) // Deduplicate by author:d-tag (replaceable event key)
for (const post of all) byId.set(post.event.id, post) const byKey = new Map<string, BlogPostPreview>()
const merged = Array.from(byId.values()).sort((a, b) => {
// 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 timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at const timeB = b.published || b.event.created_at
return timeB - timeA return timeB - timeA
@@ -160,33 +256,21 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
const [friendsPosts, friendsHighlights, nostrversePosts, nostriverseHighlights] = await Promise.all([ const [friendsPosts, friendsHighlights, nostrversePosts, nostriverseHighlights] = await Promise.all([
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls), fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls),
fetchHighlightsFromAuthors(relayPool, contactsArray), fetchHighlightsFromAuthors(relayPool, contactsArray),
fetchNostrverseBlogPosts(relayPool, relayUrls, 50), fetchNostrverseBlogPosts(relayPool, relayUrls, 50, eventStore || undefined),
fetchNostrverseHighlights(relayPool, 100) fetchNostrverseHighlights(relayPool, 100, eventStore || undefined)
]) ])
// Merge and deduplicate all posts // Merge and deduplicate all posts
const allPosts = [...friendsPosts, ...nostrversePosts] const allPosts = [...friendsPosts, ...nostrversePosts]
const postsByKey = new Map<string, BlogPostPreview>() const uniquePosts = dedupeWritingsByReplaceable(allPosts).sort((a, b) => {
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 timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at const timeB = b.published || b.event.created_at
return timeB - timeA return timeB - timeA
}) })
// Merge and deduplicate all highlights // Merge and deduplicate all highlights (mine from controller + friends + nostrverse)
const allHighlights = [...friendsHighlights, ...nostriverseHighlights] const allHighlights = [...myHighlights, ...friendsHighlights, ...nostriverseHighlights]
const highlightsByKey = new Map<string, Highlight>() const uniqueHighlights = dedupeHighlightsById(allHighlights).sort((a, b) => b.created_at - a.created_at)
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)
// Fetch profiles for all blog post authors to cache them // Fetch profiles for all blog post authors to cache them
if (uniquePosts.length > 0) { if (uniquePosts.length > 0) {
@@ -211,7 +295,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
} }
loadData() loadData()
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings]) }, [relayPool, activeAccount, refreshTrigger, eventStore, settings, myHighlights, cachedHighlights, cachedWritings])
// Pull-to-refresh // Pull-to-refresh
const { isRefreshing, pullPosition } = usePullToRefresh({ const { isRefreshing, pullPosition } = usePullToRefresh({
@@ -340,7 +424,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
// Show content progressively - no blocking error screens // Show content progressively - no blocking error screens
const hasData = highlights.length > 0 || blogPosts.length > 0 const hasData = highlights.length > 0 || blogPosts.length > 0
const showSkeletons = loading && !hasData const showSkeletons = (loading || myHighlightsLoading) && !hasData
return ( return (
<div className="explore-container"> <div className="explore-container">
@@ -422,7 +506,9 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
</div> </div>
</div> </div>
{renderTabContent()} <div key={activeTab}>
{renderTabContent()}
</div>
</div> </div>
) )
} }

View File

@@ -1,4 +1,6 @@
import React, { useState } from 'react' 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 { Hooks } from 'applesauce-react'
import { Accounts } from 'applesauce-accounts' import { Accounts } from 'applesauce-accounts'
import { NostrConnectSigner } from 'applesauce-signers' import { NostrConnectSigner } from 'applesauce-signers'
@@ -9,7 +11,7 @@ const LoginOptions: React.FC = () => {
const [showBunkerInput, setShowBunkerInput] = useState(false) const [showBunkerInput, setShowBunkerInput] = useState(false)
const [bunkerUri, setBunkerUri] = useState('') const [bunkerUri, setBunkerUri] = useState('')
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<React.ReactNode | null>(null)
const handleExtensionLogin = async () => { const handleExtensionLogin = async () => {
try { try {
@@ -20,7 +22,24 @@ const LoginOptions: React.FC = () => {
accountManager.setActive(account) accountManager.setActive(account)
} catch (err) { } catch (err) {
console.error('Extension login failed:', err) console.error('Extension login failed:', err)
setError('Login failed. Please install a nostr browser extension and try again.') 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 { } finally {
setIsLoading(false) setIsLoading(false)
} }
@@ -33,7 +52,19 @@ const LoginOptions: React.FC = () => {
} }
if (!bunkerUri.startsWith('bunker://')) { if (!bunkerUri.startsWith('bunker://')) {
setError('Invalid bunker URI. Must start with 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 return
} }
@@ -66,7 +97,22 @@ const LoginOptions: React.FC = () => {
if (errorMessage.toLowerCase().includes('permission') || errorMessage.toLowerCase().includes('unauthorized')) { if (errorMessage.toLowerCase().includes('permission') || errorMessage.toLowerCase().includes('unauthorized')) {
setError('Your bunker connection is missing signing permissions. Reconnect and approve signing.') setError('Your bunker connection is missing signing permissions. Reconnect and approve signing.')
} else { } else {
setError(errorMessage) // 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 { } finally {
setIsLoading(false) setIsLoading(false)
@@ -74,103 +120,87 @@ const LoginOptions: React.FC = () => {
} }
return ( return (
<div className="empty-state"> <div className="empty-state login-container">
<p style={{ marginBottom: '1rem' }}>Login with:</p> <div className="login-content">
<h2 className="login-title">Hi! I'm Boris.</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', maxWidth: '300px', margin: '0 auto' }}> <p className="login-description">
<button Connect your npub to see your bookmarks, explore long-form articles, and create <mark className="login-highlight">your own highlights</mark>.
onClick={handleExtensionLogin} </p>
disabled={isLoading}
style={{
padding: '0.75rem 1.5rem',
fontSize: '1rem',
cursor: isLoading ? 'wait' : 'pointer',
opacity: isLoading ? 0.6 : 1
}}
>
{isLoading && !showBunkerInput ? 'Connecting...' : 'Extension'}
</button>
{!showBunkerInput ? ( <div className="login-buttons">
<button {!showBunkerInput && (
onClick={() => setShowBunkerInput(true)} <button
disabled={isLoading} onClick={handleExtensionLogin}
style={{
padding: '0.75rem 1.5rem',
fontSize: '1rem',
cursor: isLoading ? 'wait' : 'pointer',
opacity: isLoading ? 0.6 : 1
}}
>
Bunker
</button>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<input
type="text"
placeholder="bunker://..."
value={bunkerUri}
onChange={(e) => setBunkerUri(e.target.value)}
disabled={isLoading} disabled={isLoading}
style={{ className="login-button login-button-primary"
padding: '0.75rem', >
fontSize: '0.9rem', <FontAwesomeIcon icon={faPuzzlePiece} />
width: '100%', <span>{isLoading ? 'Connecting...' : 'Extension'}</span>
boxSizing: 'border-box' </button>
}} )}
onKeyDown={(e) => {
if (e.key === 'Enter') { {!showBunkerInput ? (
handleBunkerLogin() <button
} onClick={() => setShowBunkerInput(true)}
}} disabled={isLoading}
/> className="login-button login-button-secondary"
<div style={{ display: 'flex', gap: '0.5rem' }}> >
<button <FontAwesomeIcon icon={faShieldHalved} />
onClick={handleBunkerLogin} <span>Signer</span>
disabled={isLoading || !bunkerUri.trim()} </button>
style={{ ) : (
padding: '0.5rem 1rem', <div className="bunker-input-container">
fontSize: '0.9rem', <input
flex: 1, type="text"
cursor: isLoading || !bunkerUri.trim() ? 'not-allowed' : 'pointer', placeholder="bunker://..."
opacity: isLoading || !bunkerUri.trim() ? 0.6 : 1 value={bunkerUri}
}} onChange={(e) => setBunkerUri(e.target.value)}
>
{isLoading && showBunkerInput ? 'Connecting...' : 'Connect'}
</button>
<button
onClick={() => {
setShowBunkerInput(false)
setBunkerUri('')
setError(null)
}}
disabled={isLoading} disabled={isLoading}
style={{ className="bunker-input"
padding: '0.5rem 1rem', onKeyDown={(e) => {
fontSize: '0.9rem', if (e.key === 'Enter') {
cursor: isLoading ? 'not-allowed' : 'pointer', handleBunkerLogin()
opacity: isLoading ? 0.6 : 1 }
}} }}
> />
Cancel <div className="bunker-actions">
</button> <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>
)}
</div>
{error && (
<div className="login-error">
<FontAwesomeIcon icon={faCircleInfo} />
<span>{error}</span>
</div> </div>
)} )}
</div>
<p className="login-footer">
{error && ( New to nostr? Start here:{' '}
<p style={{ color: 'var(--color-error, #ef4444)', marginTop: '1rem', fontSize: '0.9rem' }}> <a href="https://nstart.me/" target="_blank" rel="noopener noreferrer">
{error} nstart.me
</a>
</p> </p>
)} </div>
<p style={{ marginTop: '1.5rem', fontSize: '0.9rem' }}>
If you aren't on nostr yet, start here:{' '}
<a href="https://nstart.me/" target="_blank" rel="noopener noreferrer">
nstart.me
</a>
</p>
</div> </div>
) )
} }

View File

@@ -1,15 +1,16 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect, useMemo } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare, faLink } from '@fortawesome/free-solid-svg-icons' import { faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare, faLink, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react' import { Hooks } from 'applesauce-react'
import { IEventStore, Helpers } from 'applesauce-core'
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons' import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools' import { nip19, NostrEvent } from 'nostr-tools'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { Highlight } from '../types/highlights' import { Highlight } from '../types/highlights'
import { HighlightItem } from './HighlightItem' import { HighlightItem } from './HighlightItem'
import { fetchHighlights } from '../services/highlightService' import { fetchHighlights } from '../services/highlightService'
import { fetchBookmarks } from '../services/bookmarkService' import { highlightsController } from '../services/highlightsController'
import { fetchAllReads, ReadItem } from '../services/readsService' import { fetchAllReads, ReadItem } from '../services/readsService'
import { fetchLinks } from '../services/linksService' import { fetchLinks } from '../services/linksService'
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService' import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
@@ -32,11 +33,19 @@ import { filterByReadingProgress } from '../utils/readingProgressUtils'
import { deriveReadsFromBookmarks } from '../utils/readsFromBookmarks' import { deriveReadsFromBookmarks } from '../utils/readsFromBookmarks'
import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks' import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks'
import { mergeReadItem } from '../utils/readItemMerge' import { mergeReadItem } from '../utils/readItemMerge'
import { useStoreTimeline } from '../hooks/useStoreTimeline'
import { eventToHighlight } from '../services/highlightEventProcessor'
import { KINDS } from '../config/kinds'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
interface MeProps { interface MeProps {
relayPool: RelayPool relayPool: RelayPool
eventStore: IEventStore
activeTab?: TabType activeTab?: TabType
pubkey?: string // Optional pubkey for viewing other users' profiles 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' | 'reads' | 'links' | 'writings' type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
@@ -44,7 +53,13 @@ type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
// Valid reading progress filters // Valid reading progress filters
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed'] const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed']
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => { const Me: React.FC<MeProps> = ({
relayPool,
eventStore,
activeTab: propActiveTab,
pubkey: propPubkey,
bookmarks
}) => {
const activeAccount = Hooks.useActiveAccount() const activeAccount = Hooks.useActiveAccount()
const navigate = useNavigate() const navigate = useNavigate()
const { filter: urlFilter } = useParams<{ filter?: string }>() const { filter: urlFilter } = useParams<{ filter?: string }>()
@@ -54,7 +69,6 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
const viewingPubkey = propPubkey || activeAccount?.pubkey const viewingPubkey = propPubkey || activeAccount?.pubkey
const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey) const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey)
const [highlights, setHighlights] = useState<Highlight[]>([]) const [highlights, setHighlights] = useState<Highlight[]>([])
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [reads, setReads] = useState<ReadItem[]>([]) const [reads, setReads] = useState<ReadItem[]>([])
const [, setReadsMap] = useState<Map<string, ReadItem>>(new Map()) const [, setReadsMap] = useState<Map<string, ReadItem>>(new Map())
const [links, setLinks] = useState<ReadItem[]>([]) const [links, setLinks] = useState<ReadItem[]>([])
@@ -62,9 +76,47 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
const [writings, setWritings] = useState<BlogPostPreview[]>([]) const [writings, setWritings] = useState<BlogPostPreview[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [loadedTabs, setLoadedTabs] = useState<Set<TabType>>(new Set()) const [loadedTabs, setLoadedTabs] = useState<Set<TabType>>(new Set())
// Get myHighlights directly from controller
const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
const [myHighlightsLoading, setMyHighlightsLoading] = useState(false)
// Load cached data from event store for OTHER profiles (not own)
const cachedHighlights = useStoreTimeline(
eventStore,
!isOwnProfile && viewingPubkey ? { kinds: [KINDS.Highlights], authors: [viewingPubkey] } : { kinds: [KINDS.Highlights], limit: 0 },
eventToHighlight,
[viewingPubkey, isOwnProfile]
)
const toBlogPostPreview = useMemo(() => (event: NostrEvent): BlogPostPreview => ({
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
}), [])
const cachedWritings = useStoreTimeline(
eventStore,
!isOwnProfile && viewingPubkey ? { kinds: [30023], authors: [viewingPubkey] } : { kinds: [30023], limit: 0 },
toBlogPostPreview,
[viewingPubkey, isOwnProfile]
)
const [viewMode, setViewMode] = useState<ViewMode>('cards') const [viewMode, setViewMode] = useState<ViewMode>('cards')
const [refreshTrigger, setRefreshTrigger] = useState(0) const [refreshTrigger, setRefreshTrigger] = useState(0)
const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all') const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all')
const [groupingMode, setGroupingMode] = useState<'grouped' | 'flat'>(() => {
const 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 // Initialize reading progress filter from URL param
const initialFilter = urlFilter && VALID_FILTERS.includes(urlFilter as ReadingProgressFilterType) const initialFilter = urlFilter && VALID_FILTERS.includes(urlFilter as ReadingProgressFilterType)
@@ -72,6 +124,20 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
: 'all' : 'all'
const [readingProgressFilter, setReadingProgressFilter] = useState<ReadingProgressFilterType>(initialFilter) const [readingProgressFilter, setReadingProgressFilter] = useState<ReadingProgressFilterType>(initialFilter)
// 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()
}
}, [])
// Update local state when prop changes // Update local state when prop changes
useEffect(() => { useEffect(() => {
if (propActiveTab) { if (propActiveTab) {
@@ -108,8 +174,20 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
try { try {
if (!hasBeenLoaded) setLoading(true) if (!hasBeenLoaded) setLoading(true)
const userHighlights = await fetchHighlights(relayPool, viewingPubkey)
setHighlights(userHighlights) // For own profile, highlights come from controller subscription (sync effect handles it)
// For viewing other users, seed with cached data then fetch fresh
if (!isOwnProfile) {
// Seed with cached highlights first
if (cachedHighlights.length > 0) {
setHighlights(cachedHighlights.sort((a, b) => b.created_at - a.created_at))
}
// Fetch fresh highlights
const userHighlights = await fetchHighlights(relayPool, viewingPubkey)
setHighlights(userHighlights)
}
setLoadedTabs(prev => new Set(prev).add('highlights')) setLoadedTabs(prev => new Set(prev).add('highlights'))
} catch (err) { } catch (err) {
console.error('Failed to load highlights:', err) console.error('Failed to load highlights:', err)
@@ -125,6 +203,17 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
try { try {
if (!hasBeenLoaded) setLoading(true) if (!hasBeenLoaded) setLoading(true)
// Seed with cached writings first
if (!isOwnProfile && cachedWritings.length > 0) {
setWritings(cachedWritings.sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
}))
}
// Fetch fresh writings
const userWritings = await fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS) const userWritings = await fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
setWritings(userWritings) setWritings(userWritings)
setLoadedTabs(prev => new Set(prev).add('writings')) setLoadedTabs(prev => new Set(prev).add('writings'))
@@ -142,14 +231,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
try { try {
if (!hasBeenLoaded) setLoading(true) if (!hasBeenLoaded) setLoading(true)
try { // Bookmarks come from centralized loading in App.tsx
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
setBookmarks(newBookmarks)
})
} catch (err) {
console.warn('Failed to load bookmarks:', err)
setBookmarks([])
}
setLoadedTabs(prev => new Set(prev).add('reading-list')) setLoadedTabs(prev => new Set(prev).add('reading-list'))
} catch (err) { } catch (err) {
console.error('Failed to load reading list:', err) console.error('Failed to load reading list:', err)
@@ -166,22 +248,8 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
try { try {
if (!hasBeenLoaded) setLoading(true) if (!hasBeenLoaded) setLoading(true)
// Ensure bookmarks are loaded // Derive reads from bookmarks immediately (bookmarks come from centralized loading in App.tsx)
let fetchedBookmarks: Bookmark[] = bookmarks const initialReads = deriveReadsFromBookmarks(bookmarks)
if (bookmarks.length === 0) {
try {
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
fetchedBookmarks = newBookmarks
setBookmarks(newBookmarks)
})
} catch (err) {
console.warn('Failed to load bookmarks:', err)
fetchedBookmarks = []
}
}
// Derive reads from bookmarks immediately
const initialReads = deriveReadsFromBookmarks(fetchedBookmarks)
const initialMap = new Map(initialReads.map(item => [item.id, item])) const initialMap = new Map(initialReads.map(item => [item.id, item]))
setReadsMap(initialMap) setReadsMap(initialMap)
setReads(initialReads) setReads(initialReads)
@@ -190,7 +258,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
// Background enrichment: merge reading progress and mark-as-read // Background enrichment: merge reading progress and mark-as-read
// Only update items that are already in our map // Only update items that are already in our map
fetchAllReads(relayPool, viewingPubkey, fetchedBookmarks, (item) => { fetchAllReads(relayPool, viewingPubkey, bookmarks, (item) => {
console.log('📈 [Reads] Enrichment item received:', { console.log('📈 [Reads] Enrichment item received:', {
id: item.id.slice(0, 20) + '...', id: item.id.slice(0, 20) + '...',
progress: item.readingProgress, progress: item.readingProgress,
@@ -230,22 +298,8 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
try { try {
if (!hasBeenLoaded) setLoading(true) if (!hasBeenLoaded) setLoading(true)
// Ensure bookmarks are loaded // Derive links from bookmarks immediately (bookmarks come from centralized loading in App.tsx)
let fetchedBookmarks: Bookmark[] = bookmarks const initialLinks = deriveLinksFromBookmarks(bookmarks)
if (bookmarks.length === 0) {
try {
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
fetchedBookmarks = newBookmarks
setBookmarks(newBookmarks)
})
} catch (err) {
console.warn('Failed to load bookmarks:', err)
fetchedBookmarks = []
}
}
// Derive links from bookmarks immediately
const initialLinks = deriveLinksFromBookmarks(fetchedBookmarks)
const initialMap = new Map(initialLinks.map(item => [item.id, item])) const initialMap = new Map(initialLinks.map(item => [item.id, item]))
setLinksMap(initialMap) setLinksMap(initialMap)
setLinks(initialLinks) setLinks(initialLinks)
@@ -287,7 +341,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
const cached = getCachedMeData(viewingPubkey) const cached = getCachedMeData(viewingPubkey)
if (cached) { if (cached) {
setHighlights(cached.highlights) setHighlights(cached.highlights)
setBookmarks(cached.bookmarks) // Bookmarks come from App.tsx centralized state, no local caching needed
setReads(cached.reads || []) setReads(cached.reads || [])
setLinks(cached.links || []) setLinks(cached.links || [])
} }
@@ -314,6 +368,12 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab, viewingPubkey, refreshTrigger]) }, [activeTab, viewingPubkey, refreshTrigger])
// Sync myHighlights from controller when viewing own profile
useEffect(() => {
if (isOwnProfile) {
setHighlights(myHighlights)
}
}, [isOwnProfile, myHighlights])
// Pull-to-refresh - reload active tab without clearing state // Pull-to-refresh - reload active tab without clearing state
const { isRefreshing, pullPosition } = usePullToRefresh({ const { isRefreshing, pullPosition } = usePullToRefresh({
@@ -421,16 +481,20 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
// Apply reading progress filter // Apply reading progress filter
const filteredReads = filterByReadingProgress(reads, readingProgressFilter) const filteredReads = filterByReadingProgress(reads, readingProgressFilter)
const filteredLinks = filterByReadingProgress(links, readingProgressFilter) const filteredLinks = filterByReadingProgress(links, readingProgressFilter)
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [ const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
{ key: 'private', title: 'Private Bookmarks', items: groups.privateItems }, groupingMode === 'flat'
{ key: 'public', title: 'Public Bookmarks', items: groups.publicItems }, ? [{ key: 'all', title: `All Bookmarks (${filteredBookmarks.length})`, items: filteredBookmarks }]
{ key: 'web', title: 'Web Bookmarks', items: groups.web }, : [
{ key: 'amethyst', title: 'Legacy Bookmarks', items: groups.amethyst } { key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
] { key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
{ key: 'amethyst-private', title: 'Amethyst Private', items: groups.amethystPrivate },
{ key: 'amethyst-public', title: 'Amethyst Lists', items: groups.amethystPublic },
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb }
]
// Show content progressively - no blocking error screens // Show content progressively - no blocking error screens
const hasData = highlights.length > 0 || bookmarks.length > 0 || reads.length > 0 || links.length > 0 || writings.length > 0 const hasData = highlights.length > 0 || bookmarks.length > 0 || reads.length > 0 || links.length > 0 || writings.length > 0
const showSkeletons = loading && !hasData const showSkeletons = (loading || (isOwnProfile && myHighlightsLoading)) && !hasData
const renderTabContent = () => { const renderTabContent = () => {
switch (activeTab) { switch (activeTab) {
@@ -444,7 +508,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
</div> </div>
) )
} }
return highlights.length === 0 && !loading ? ( return highlights.length === 0 && !loading && !(isOwnProfile && myHighlightsLoading) ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}> <div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No highlights yet. No highlights yet.
</div> </div>
@@ -514,6 +578,13 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
marginTop: '1rem', marginTop: '1rem',
borderTop: '1px solid var(--border-color)' borderTop: '1px solid var(--border-color)'
}}> }}>
<IconButton
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"
/>
<IconButton <IconButton
icon={faList} icon={faList}
onClick={() => setViewMode('compact')} onClick={() => setViewMode('compact')}

View File

@@ -6,6 +6,7 @@ import IconButton from './IconButton'
import { loadFont } from '../utils/fontLoader' import { loadFont } from '../utils/fontLoader'
import ThemeSettings from './Settings/ThemeSettings' import ThemeSettings from './Settings/ThemeSettings'
import ReadingDisplaySettings from './Settings/ReadingDisplaySettings' import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
import ExploreSettings from './Settings/ExploreSettings'
import LayoutBehaviorSettings from './Settings/LayoutBehaviorSettings' import LayoutBehaviorSettings from './Settings/LayoutBehaviorSettings'
import ZapSettings from './Settings/ZapSettings' import ZapSettings from './Settings/ZapSettings'
import RelaySettings from './Settings/RelaySettings' import RelaySettings from './Settings/RelaySettings'
@@ -29,6 +30,9 @@ const DEFAULT_SETTINGS: UserSettings = {
defaultHighlightVisibilityNostrverse: true, defaultHighlightVisibilityNostrverse: true,
defaultHighlightVisibilityFriends: true, defaultHighlightVisibilityFriends: true,
defaultHighlightVisibilityMine: true, defaultHighlightVisibilityMine: true,
defaultExploreScopeNostrverse: false,
defaultExploreScopeFriends: true,
defaultExploreScopeMine: false,
zapSplitHighlighterWeight: 50, zapSplitHighlighterWeight: 50,
zapSplitBorisWeight: 2.1, zapSplitBorisWeight: 2.1,
zapSplitAuthorWeight: 50, zapSplitAuthorWeight: 50,
@@ -163,6 +167,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
<div className="settings-content"> <div className="settings-content">
<ThemeSettings settings={localSettings} onUpdate={handleUpdate} /> <ThemeSettings settings={localSettings} onUpdate={handleUpdate} />
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} /> <ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
<ExploreSettings settings={localSettings} onUpdate={handleUpdate} />
<ZapSettings settings={localSettings} onUpdate={handleUpdate} /> <ZapSettings settings={localSettings} onUpdate={handleUpdate} />
<LayoutBehaviorSettings settings={localSettings} onUpdate={handleUpdate} /> <LayoutBehaviorSettings settings={localSettings} onUpdate={handleUpdate} />
<PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} /> <PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />

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

@@ -1,11 +1,10 @@
import React, { useState } from 'react' import React from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight, faRightFromBracket, 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 { Hooks } from 'applesauce-react'
import { useEventModel } from 'applesauce-react/hooks' import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core' import { Models } from 'applesauce-core'
import { Accounts } from 'applesauce-accounts'
import IconButton from './IconButton' import IconButton from './IconButton'
interface SidebarHeaderProps { interface SidebarHeaderProps {
@@ -16,26 +15,10 @@ interface SidebarHeaderProps {
} }
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, isMobile = false }) => { const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, isMobile = false }) => {
const [isConnecting, setIsConnecting] = useState(false)
const navigate = useNavigate() const navigate = useNavigate()
const activeAccount = Hooks.useActiveAccount() const activeAccount = Hooks.useActiveAccount()
const accountManager = Hooks.useAccountManager()
const profile = useEventModel(Models.ProfileModel, activeAccount ? [activeAccount.pubkey] : null) 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 = () => { const getProfileImage = () => {
return profile?.picture || null return profile?.picture || null
} }
@@ -73,22 +56,20 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
</button> </button>
)} )}
<div className="sidebar-header-right"> <div className="sidebar-header-right">
<div {activeAccount && (
className="profile-avatar" <div
title={activeAccount ? getUserDisplayName() : "Login"} className="profile-avatar"
onClick={ title={getUserDisplayName()}
activeAccount onClick={() => navigate('/me')}
? () => navigate('/me') style={{ cursor: 'pointer' }}
: (isConnecting ? () => {} : handleLogin) >
} {profileImage ? (
style={{ cursor: 'pointer' }} <img src={profileImage} alt={getUserDisplayName()} />
> ) : (
{profileImage ? ( <FontAwesomeIcon icon={faUserCircle} />
<img src={profileImage} alt={getUserDisplayName()} /> )}
) : ( </div>
<FontAwesomeIcon icon={faUserCircle} /> )}
)}
</div>
<IconButton <IconButton
icon={faHome} icon={faHome}
onClick={() => navigate('/')} onClick={() => navigate('/')}
@@ -110,7 +91,7 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
ariaLabel="Settings" ariaLabel="Settings"
variant="ghost" variant="ghost"
/> />
{activeAccount ? ( {activeAccount && (
<IconButton <IconButton
icon={faRightFromBracket} icon={faRightFromBracket}
onClick={onLogout} onClick={onLogout}
@@ -118,14 +99,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
ariaLabel="Logout" ariaLabel="Logout"
variant="ghost" variant="ghost"
/> />
) : (
<IconButton
icon={faRightToBracket}
onClick={isConnecting ? () => {} : handleLogin}
title={isConnecting ? "Connecting..." : "Login"}
ariaLabel="Login"
variant="ghost"
/>
)} )}
</div> </div>
</div> </div>

View File

@@ -414,7 +414,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
/> />
</div> </div>
</div> </div>
{props.hasActiveAccount && ( {props.hasActiveAccount && props.readerContent && (
<HighlightButton <HighlightButton
ref={props.highlightButtonRef} ref={props.highlightButtonRef}
onHighlight={props.onCreateHighlight} onHighlight={props.onCreateHighlight}

View File

@@ -1,62 +1,83 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback, useMemo } from 'react'
import { RelayPool } from 'applesauce-relay' 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 { Bookmark } from '../types/bookmarks'
import { Highlight } from '../types/highlights' import { Highlight } from '../types/highlights'
import { fetchBookmarks } from '../services/bookmarkService' import { fetchHighlightsForArticle } from '../services/highlightService'
import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService'
import { fetchContacts } from '../services/contactService'
import { UserSettings } from '../services/settingsService' 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'
interface UseBookmarksDataParams { interface UseBookmarksDataParams {
relayPool: RelayPool | null relayPool: RelayPool | null
activeAccount: IAccount | undefined activeAccount: IAccount | undefined
accountManager: AccountManager
naddr?: string naddr?: string
externalUrl?: string externalUrl?: string
currentArticleCoordinate?: string currentArticleCoordinate?: string
currentArticleEventId?: string currentArticleEventId?: string
settings?: UserSettings 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 = ({ export const useBookmarksData = ({
relayPool, relayPool,
activeAccount, activeAccount,
accountManager,
naddr, naddr,
externalUrl, externalUrl,
currentArticleCoordinate, currentArticleCoordinate,
currentArticleEventId, currentArticleEventId,
settings settings,
}: UseBookmarksDataParams) => { eventStore,
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]) onRefreshBookmarks
const [bookmarksLoading, setBookmarksLoading] = useState(true) }: Omit<UseBookmarksDataParams, 'bookmarks' | 'bookmarksLoading'>) => {
const [highlights, setHighlights] = useState<Highlight[]>([]) const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
const [articleHighlights, setArticleHighlights] = useState<Highlight[]>([])
const [highlightsLoading, setHighlightsLoading] = useState(true) const [highlightsLoading, setHighlightsLoading] = useState(true)
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set()) const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
const [isRefreshing, setIsRefreshing] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false)
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null) const [lastFetchTime, setLastFetchTime] = useState<number | null>(null)
const handleFetchContacts = useCallback(async () => { // Load cached article-specific highlights from event store
if (!relayPool || !activeAccount) return const articleFilter = useMemo(() => {
const contacts = await fetchContacts(relayPool, activeAccount.pubkey) if (!currentArticleCoordinate) return null
setFollowedPubkeys(contacts) return {
}, [relayPool, activeAccount]) kinds: [KINDS.Highlights],
'#a': [currentArticleCoordinate],
const handleFetchBookmarks = useCallback(async () => { ...(currentArticleEventId ? { '#e': [currentArticleEventId] } : {})
if (!relayPool || !activeAccount) return
// don't clear existing bookmarks: we keep UI stable and show spinner unobtrusively
setBookmarksLoading(true)
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)
} }
}, [relayPool, activeAccount, accountManager, settings]) }, [currentArticleCoordinate, currentArticleEventId])
const cachedArticleHighlights = useStoreTimeline(
eventStore || null,
articleFilter || { kinds: [KINDS.Highlights], limit: 0 }, // empty filter if no article
eventToHighlight,
[currentArticleCoordinate, 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 () => { const handleFetchHighlights = useCallback(async () => {
if (!relayPool) return if (!relayPool) return
@@ -64,7 +85,16 @@ export const useBookmarksData = ({
setHighlightsLoading(true) setHighlightsLoading(true)
try { try {
if (currentArticleCoordinate) { if (currentArticleCoordinate) {
// 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>() const highlightsMap = new Map<string, Highlight>()
// Seed map with cached highlights
cachedArticleHighlights.forEach(h => highlightsMap.set(h.id, h))
await fetchHighlightsForArticle( await fetchHighlightsForArticle(
relayPool, relayPool,
currentArticleCoordinate, currentArticleCoordinate,
@@ -74,68 +104,67 @@ export const useBookmarksData = ({
if (!highlightsMap.has(highlight.id)) { if (!highlightsMap.has(highlight.id)) {
highlightsMap.set(highlight.id, highlight) highlightsMap.set(highlight.id, highlight)
const highlightsList = Array.from(highlightsMap.values()) 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 {
} else if (activeAccount) { // No article selected - clear article highlights
const fetchedHighlights = await fetchHighlights(relayPool, activeAccount.pubkey, undefined, settings) setArticleHighlights([])
setHighlights(fetchedHighlights)
} }
} catch (err) { } catch (err) {
console.error('Failed to fetch highlights:', err) console.error('Failed to fetch highlights:', err)
} finally { } finally {
setHighlightsLoading(false) setHighlightsLoading(false)
} }
}, [relayPool, activeAccount, currentArticleCoordinate, currentArticleEventId, settings]) }, [relayPool, currentArticleCoordinate, currentArticleEventId, settings, eventStore, cachedArticleHighlights])
const handleRefreshAll = useCallback(async () => { const handleRefreshAll = useCallback(async () => {
if (!relayPool || !activeAccount || isRefreshing) return if (!relayPool || !activeAccount || isRefreshing) return
setIsRefreshing(true) setIsRefreshing(true)
try { try {
await handleFetchBookmarks() await onRefreshBookmarks()
await handleFetchHighlights() await handleFetchHighlights()
await handleFetchContacts() // Contacts and own highlights are managed by controllers
setLastFetchTime(Date.now()) setLastFetchTime(Date.now())
} catch (err) { } catch (err) {
console.error('Failed to refresh data:', err) console.error('Failed to refresh data:', err)
} finally { } finally {
setIsRefreshing(false) 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(() => { useEffect(() => {
if (!relayPool || !activeAccount) return if (!relayPool || !activeAccount) return
// Only (re)fetch bookmarks when account or relayPool changes, not on naddr route changes // Fetch article-specific highlights when viewing an article
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
// External URLs have their highlights fetched by useExternalUrlLoader // External URLs have their highlights fetched by useExternalUrlLoader
if (!naddr && !externalUrl) { if (currentArticleCoordinate && !externalUrl) {
handleFetchHighlights() handleFetchHighlights()
} else if (!naddr && !externalUrl) {
// Clear article highlights when not viewing an article
setArticleHighlights([])
setHighlightsLoading(false)
} }
handleFetchContacts() }, [relayPool, activeAccount, currentArticleCoordinate, naddr, externalUrl, handleFetchHighlights])
}, [relayPool, activeAccount, naddr, externalUrl, handleFetchHighlights, handleFetchContacts])
// Merge highlights from controller with article-specific highlights
const highlights = [...myHighlights, ...articleHighlights]
.filter((h, i, arr) => arr.findIndex(x => x.id === h.id) === i) // Deduplicate
.sort((a, b) => b.created_at - a.created_at)
return { return {
bookmarks,
bookmarksLoading,
highlights, highlights,
setHighlights, setHighlights: setArticleHighlights, // For external updates (like from useExternalUrlLoader)
highlightsLoading, highlightsLoading,
setHighlightsLoading, setHighlightsLoading,
followedPubkeys, followedPubkeys,
isRefreshing, isRefreshing,
lastFetchTime, lastFetchTime,
handleFetchBookmarks,
handleFetchHighlights, handleFetchHighlights,
handleRefreshAll handleRefreshAll
} }

View File

@@ -1,8 +1,12 @@
import { useEffect } from 'react' import { useEffect, useMemo } from 'react'
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { fetchReadableContent, ReadableContent } from '../services/readerService' import { fetchReadableContent, ReadableContent } from '../services/readerService'
import { fetchHighlightsForUrl } from '../services/highlightService' import { fetchHighlightsForUrl } from '../services/highlightService'
import { Highlight } from '../types/highlights' 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 // Helper to extract filename from URL
function getFilenameFromUrl(url: string): string { function getFilenameFromUrl(url: string): string {
@@ -20,6 +24,7 @@ function getFilenameFromUrl(url: string): string {
interface UseExternalUrlLoaderProps { interface UseExternalUrlLoaderProps {
url: string | undefined url: string | undefined
relayPool: RelayPool | null relayPool: RelayPool | null
eventStore?: IEventStore | null
setSelectedUrl: (url: string) => void setSelectedUrl: (url: string) => void
setReaderContent: (content: ReadableContent | undefined) => void setReaderContent: (content: ReadableContent | undefined) => void
setReaderLoading: (loading: boolean) => void setReaderLoading: (loading: boolean) => void
@@ -33,6 +38,7 @@ interface UseExternalUrlLoaderProps {
export function useExternalUrlLoader({ export function useExternalUrlLoader({
url, url,
relayPool, relayPool,
eventStore,
setSelectedUrl, setSelectedUrl,
setReaderContent, setReaderContent,
setReaderLoading, setReaderLoading,
@@ -42,6 +48,19 @@ export function useExternalUrlLoader({
setCurrentArticleCoordinate, setCurrentArticleCoordinate,
setCurrentArticleEventId setCurrentArticleEventId
}: UseExternalUrlLoaderProps) { }: 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(() => { useEffect(() => {
if (!relayPool || !url) return if (!relayPool || !url) return
@@ -66,11 +85,20 @@ export function useExternalUrlLoader({
// Fetch highlights for this URL asynchronously // Fetch highlights for this URL asynchronously
try { try {
setHighlightsLoading(true) 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 // Check if fetchHighlightsForUrl exists, otherwise skip
if (typeof fetchHighlightsForUrl === 'function') { if (typeof fetchHighlightsForUrl === 'function') {
const seen = new Set<string>() const seen = new Set<string>()
// Seed with cached IDs
cachedUrlHighlights.forEach(h => seen.add(h.id))
await fetchHighlightsForUrl( await fetchHighlightsForUrl(
relayPool, relayPool,
url, url,
@@ -82,13 +110,11 @@ export function useExternalUrlLoader({
const next = [...prev, highlight] const next = [...prev, highlight]
return next.sort((a, b) => b.created_at - a.created_at) return next.sort((a, b) => b.created_at - a.created_at)
}) })
} },
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) { } catch (err) {
console.error('Failed to fetch highlights:', err) console.error('Failed to fetch highlights:', err)
@@ -109,6 +135,6 @@ export function useExternalUrlLoader({
} }
loadExternalUrl() 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

@@ -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/me.css';
@import './styles/components/pull-to-refresh.css'; @import './styles/components/pull-to-refresh.css';
@import './styles/components/skeletons.css'; @import './styles/components/skeletons.css';
@import './styles/components/login.css';
@import './styles/utils/animations.css'; @import './styles/utils/animations.css';
@import './styles/utils/utilities.css'; @import './styles/utils/utilities.css';
@import './styles/utils/legacy.css'; @import './styles/utils/legacy.css';

View File

@@ -0,0 +1,472 @@
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) {
console.warn('[bookmark] ⚠️ EventLoader not initialized')
return
}
// Filter to unique IDs not already hydrated
const unique = Array.from(new Set(ids)).filter(id => !idToEvent.has(id))
if (unique.length === 0) {
console.log('[bookmark] 🔧 All IDs already hydrated, skipping')
return
}
console.log('[bookmark] 🔧 Hydrating', unique.length, 'IDs using EventLoader')
// 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: (error) => {
console.error('[bookmark] ❌ EventLoader error:', error)
}
})
}
/**
* 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) {
console.warn('[bookmark] ⚠️ AddressLoader not initialized')
return
}
if (coords.length === 0) return
console.log('[bookmark] 🔧 Hydrating', coords.length, 'coordinates using AddressLoader')
// 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: (error) => {
console.error('[bookmark] ❌ AddressLoader error:', error)
}
})
}
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))
})
const unencryptedCount = allEvents.filter(evt => !hasEncryptedContent(evt)).length
const decryptedCount = readyEvents.length - unencryptedCount
console.log('[bookmark] 📋 Building bookmarks:', unencryptedCount, 'unencrypted,', decryptedCount, 'decrypted, of', allEvents.length, 'total')
if (readyEvents.length === 0) {
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))
console.log('[bookmark] 🔧 Processing', unencryptedEvents.length, 'unencrypted events')
// Process unencrypted events
const { publicItemsAll: publicUnencrypted, privateItemsAll: privateUnencrypted, newestCreatedAt, latestContent, allTags } =
await collectBookmarksFromEvents(unencryptedEvents, activeAccount, signerCandidate)
console.log('[bookmark] 🔧 Unencrypted returned:', publicUnencrypted.length, 'public,', privateUnencrypted.length, 'private')
// Merge in decrypted results
let publicItemsAll = [...publicUnencrypted]
let privateItemsAll = [...privateUnencrypted]
console.log('[bookmark] 🔧 Merging', decryptedEvents.length, 'decrypted events')
decryptedEvents.forEach(evt => {
const eventKey = getEventKey(evt)
const decrypted = this.decryptedResults.get(eventKey)
if (decrypted) {
publicItemsAll = [...publicItemsAll, ...decrypted.publicItems]
privateItemsAll = [...privateItemsAll, ...decrypted.privateItems]
}
})
console.log('[bookmark] 🔧 Total after merge:', publicItemsAll.length, 'public,', privateItemsAll.length, 'private')
const allItems = [...publicItemsAll, ...privateItemsAll]
console.log('[bookmark] 🔧 Total items to process:', allItems.length)
// 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>) => {
console.log('[bookmark] 🔧 Building final bookmarks list...')
const allBookmarks = dedupeBookmarksById([
...hydrateItems(publicItemsAll, idToEvent),
...hydrateItems(privateItemsAll, idToEvent)
])
console.log('[bookmark] 🔧 After hydration and dedup:', allBookmarks.length, 'bookmarks')
console.log('[bookmark] 🔧 Enriching and sorting...')
const enriched = allBookmarks.map(b => ({
...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)))
console.log('[bookmark] 🔧 Sorted:', sortedBookmarks.length, 'bookmarks')
console.log('[bookmark] 🔧 Creating final Bookmark object...')
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
}
console.log('[bookmark] 📋 Built bookmark with', sortedBookmarks.length, 'items')
console.log('[bookmark] 📤 Emitting to', this.bookmarksListeners.length, 'listeners')
this.bookmarksListeners.forEach(cb => cb([bookmark]))
}
// Emit immediately with empty metadata (show placeholders)
const idToEvent: Map<string, NostrEvent> = new Map()
console.log('[bookmark] 🚀 Emitting initial bookmarks with placeholders (IDs only)...')
emitBookmarks(idToEvent)
// Now fetch events progressively in background using batched hydrators
console.log('[bookmark] 🔧 Background hydration:', noteIds.length, 'note IDs and', coordinates.length, 'coordinates')
const generation = this.hydrationGeneration
const 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('[bookmark] ❌ Failed to build bookmarks:', error)
console.error('[bookmark] ❌ Error details:', error instanceof Error ? error.message : String(error))
console.error('[bookmark] ❌ Stack:', error instanceof Error ? error.stack : 'no stack')
this.bookmarksListeners.forEach(cb => cb([]))
}
}
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') {
console.error('[bookmark] Invalid activeAccount')
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
console.log('[bookmark] 🔧 Initializing EventLoader and AddressLoader with', RELAYS.length, 'relays')
this.eventLoader = createEventLoader(relayPool, {
eventStore: this.eventStore,
extraRelays: RELAYS
})
this.addressLoader = createAddressLoader(relayPool, {
eventStore: this.eventStore,
extraRelays: RELAYS
})
this.setLoading(true)
console.log('[bookmark] 🔍 Starting bookmark load for', account.pubkey.slice(0, 8))
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)
console.log('[bookmark] 📨 Event:', evt.kind, evt.id.slice(0, 8), 'encrypted:', hasEncryptedContent(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(err => console.error('[bookmark] ❌ Failed to update after event:', err))
}
// Auto-decrypt if event has encrypted content (fire-and-forget, non-blocking)
if (isEncrypted) {
console.log('[bookmark] 🔓 Auto-decrypting event', evt.id.slice(0, 8))
// 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
})
console.log('[bookmark] ✅ Auto-decrypted:', evt.id.slice(0, 8), {
public: publicItemsAll.length,
private: privateItemsAll.length
})
// 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(err => console.error('[bookmark] ❌ Failed to update after decrypt:', err))
})
.catch((error) => {
console.error('[bookmark] ❌ Auto-decrypt failed:', evt.id.slice(0, 8), error)
})
}
}
}
)
// Final update after EOSE
await this.buildAndEmitBookmarks(maybeAccount, signerCandidate)
console.log('[bookmark] ✅ Bookmark load complete')
} catch (error) {
console.error('[bookmark] ❌ Failed to load bookmarks:', error)
this.bookmarksListeners.forEach(cb => cb([]))
} finally {
this.setLoading(false)
}
}
}
// Singleton instance
export const bookmarkController = new BookmarkController()

View File

@@ -12,15 +12,93 @@ type HiddenContentSigner = Parameters<UnlockHiddenTagsFn>[1]
type UnlockMode = Parameters<UnlockHiddenTagsFn>[2] type UnlockMode = Parameters<UnlockHiddenTagsFn>[2]
/** /**
* Wrap a decrypt promise with a timeout to prevent hanging (using 30s timeout for bunker) * Decrypt/unlock a single event and return private bookmarks
*/ */
function withDecryptTimeout<T>(promise: Promise<T>, timeoutMs = 30000): Promise<T> { async function decryptEvent(
return Promise.race([ evt: NostrEvent,
promise, activeAccount: ActiveAccount,
new Promise<T>((_, reject) => signerCandidate: unknown,
setTimeout(() => reject(new Error(`Decrypt timeout after ${timeoutMs}ms`)), timeoutMs) 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) {
console.log("[bunker] ❌ nip44.decrypt failed:", err instanceof Error ? err.message : String(err))
}
}
} 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) {
console.log("[bunker] ❌ nip44.decrypt failed:", err instanceof Error ? err.message : String(err))
}
}
// 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) {
console.log("[bunker] ❌ nip04.decrypt failed:", err instanceof Error ? err.message : String(err))
}
}
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( export async function collectBookmarksFromEvents(
@@ -35,21 +113,23 @@ export async function collectBookmarksFromEvents(
allTags: string[][] allTags: string[][]
}> { }> {
const publicItemsAll: IndividualBookmark[] = [] const publicItemsAll: IndividualBookmark[] = []
const privateItemsAll: IndividualBookmark[] = []
let newestCreatedAt = 0 let newestCreatedAt = 0
let latestContent = '' let latestContent = ''
let allTags: string[][] = [] 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) { for (const evt of bookmarkListEvents) {
newestCreatedAt = Math.max(newestCreatedAt, evt.created_at || 0) newestCreatedAt = Math.max(newestCreatedAt, evt.created_at || 0)
if (!latestContent && evt.content && !Helpers.hasHiddenContent(evt)) latestContent = evt.content if (!latestContent && evt.content && !Helpers.hasHiddenContent(evt)) latestContent = evt.content
if (Array.isArray(evt.tags)) allTags = allTags.concat(evt.tags) 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 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 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 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 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 // Handle web bookmarks (kind:39701) as individual bookmarks
if (evt.kind === 39701) { if (evt.kind === 39701) {
@@ -85,72 +165,22 @@ export async function collectBookmarksFromEvents(
})) }))
) )
try { // Schedule decrypt if needed
if (Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt) && signerCandidate) { // Check for NIP-44 (Helpers.hasHiddenContent), NIP-04 (?iv= in content), or encrypted tags
try { const hasNip04Content = evt.content && evt.content.includes('?iv=')
await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner) const needsDecrypt = signerCandidate && (
} catch { (Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt)) ||
try { Helpers.hasHiddenContent(evt) ||
await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner, 'nip44' as UnlockMode) hasNip04Content
} catch (err) { )
console.log("[bunker] ❌ nip44.decrypt failed:", err instanceof Error ? err.message : String(err))
// ignore if (needsDecrypt) {
} decryptJobs.push({ evt, metadata })
} } else {
} else if (evt.content && evt.content.length > 0 && signerCandidate) { // Check for already-unlocked hidden bookmarks
let decryptedContent: string | undefined
try {
if (hasNip44Decrypt(signerCandidate)) {
decryptedContent = await withDecryptTimeout((signerCandidate as { nip44: { decrypt: DecryptFn } }).nip44.decrypt(
evt.pubkey,
evt.content
))
}
} catch (err) {
console.log("[bunker] ❌ nip44.decrypt failed:", err instanceof Error ? err.message : String(err))
// ignore
}
if (!decryptedContent) {
try {
if (hasNip04Decrypt(signerCandidate)) {
decryptedContent = await withDecryptTimeout((signerCandidate as { nip04: { decrypt: DecryptFn } }).nip04.decrypt(
evt.pubkey,
evt.content
))
}
} catch (err) {
console.log("[bunker] ❌ nip04.decrypt failed:", err instanceof Error ? err.message : String(err))
// 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 (err) {
// ignore
}
}
}
const priv = Helpers.getHiddenBookmarks(evt) const priv = Helpers.getHiddenBookmarks(evt)
if (priv) { if (priv) {
privateItemsAll.push( publicItemsAll.push(
...processApplesauceBookmarks(priv, activeAccount, true).map(i => ({ ...processApplesauceBookmarks(priv, activeAccount, true).map(i => ({
...i, ...i,
sourceKind: evt.kind, sourceKind: evt.kind,
@@ -161,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,241 +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'
import { KINDS } from '../config/kinds'
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: [KINDS.ListSimple, KINDS.ListReplaceable, KINDS.List, KINDS.WebBookmark], 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 === KINDS.ListSimple && 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('[bunker] 🔐 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('[bunker] 🔑 Signer candidate:', !!signerCandidate, typeof signerCandidate)
if (signerCandidate) {
console.log('[bunker] 🔑 Signer has nip04:', hasNip04Decrypt(signerCandidate))
console.log('[bunker] 🔑 Signer has nip44:', hasNip44Decrypt(signerCandidate))
}
// Debug relay connectivity for bunker relays
try {
const urls = Array.from(relayPool.relays.values()).map(r => ({ url: r.url, connected: (r as unknown as { connected?: boolean }).connected }))
console.log('[bunker] Relay connections:', urls)
} catch (err) { console.warn('[bunker] Failed to read relay connections', err) }
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 { RelayPool } from 'applesauce-relay'
import { prioritizeLocalRelays } from '../utils/helpers' import { prioritizeLocalRelays } from '../utils/helpers'
import { queryEvents } from './dataFetch' import { queryEvents } from './dataFetch'
import { CONTACTS_REMOTE_TIMEOUT_MS } from '../config/network'
/** /**
* Fetches the contact list (follows) for a specific user * Fetches the contact list (follows) for a specific user
@@ -24,7 +23,6 @@ export const fetchContacts = async (
{ kinds: [3], authors: [pubkey] }, { kinds: [3], authors: [pubkey] },
{ {
relayUrls, relayUrls,
remoteTimeoutMs: CONTACTS_REMOTE_TIMEOUT_MS,
onEvent: (event: { created_at: number; tags: string[][] }) => { onEvent: (event: { created_at: number; tags: string[][] }) => {
// Stream partials as we see any contact list // Stream partials as we see any contact list
for (const tag of event.tags) { for (const tag of event.tags) {

View File

@@ -0,0 +1,114 @@
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)) {
console.log('[contacts] ✅ Already loaded for', pubkey.slice(0, 8))
this.emitContacts(this.currentContacts)
return
}
this.setLoading(true)
console.log('[contacts] 🔍 Loading contacts for', pubkey.slice(0, 8))
try {
const contacts = await fetchContacts(
relayPool,
pubkey,
(partial) => {
// Stream partial updates
this.currentContacts = new Set(partial)
this.emitContacts(this.currentContacts)
console.log('[contacts] 📥 Partial contacts:', partial.size)
}
)
// Store final result
this.currentContacts = new Set(contacts)
this.lastLoadedPubkey = pubkey
this.emitContacts(this.currentContacts)
console.log('[contacts] ✅ Loaded', contacts.size, 'contacts')
} catch (error) {
console.error('[contacts] ❌ Failed to load contacts:', error)
this.currentContacts.clear()
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 { 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 { NostrEvent } from 'nostr-tools'
import { Filter } from 'nostr-tools/filter' import { Filter } from 'nostr-tools/filter'
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers' import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
import { LOCAL_TIMEOUT_MS, REMOTE_TIMEOUT_MS } from '../config/network'
export interface QueryOptions { export interface QueryOptions {
relayUrls?: string[] relayUrls?: string[]
localTimeoutMs?: number
remoteTimeoutMs?: number
onEvent?: (event: NostrEvent) => void onEvent?: (event: NostrEvent) => void
} }
/** /**
* Unified local-first query helper with optional streaming callback. * 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( export async function queryEvents(
relayPool: RelayPool, relayPool: RelayPool,
@@ -23,8 +21,6 @@ export async function queryEvents(
): Promise<NostrEvent[]> { ): Promise<NostrEvent[]> {
const { const {
relayUrls, relayUrls,
localTimeoutMs = LOCAL_TIMEOUT_MS,
remoteTimeoutMs = REMOTE_TIMEOUT_MS,
onEvent onEvent
} = options } = options
@@ -41,8 +37,7 @@ export async function queryEvents(
.pipe( .pipe(
onlyEvents(), onlyEvents(),
onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}), onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}),
completeOnEose(), completeOnEose()
takeUntil(timer(localTimeoutMs))
) as unknown as Observable<NostrEvent> ) as unknown as Observable<NostrEvent>
: new Observable<NostrEvent>((sub) => sub.complete()) : new Observable<NostrEvent>((sub) => sub.complete())
@@ -52,8 +47,7 @@ export async function queryEvents(
.pipe( .pipe(
onlyEvents(), onlyEvents(),
onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}), onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}),
completeOnEose(), completeOnEose()
takeUntil(timer(remoteTimeoutMs))
) as unknown as Observable<NostrEvent> ) as unknown as Observable<NostrEvent>
: new Observable<NostrEvent>((sub) => sub.complete()) : new Observable<NostrEvent>((sub) => sub.complete())

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

View File

@@ -1,95 +1,81 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { IEventStore } from 'applesauce-core'
import { Highlight } from '../../types/highlights' import { Highlight } from '../../types/highlights'
import { RELAYS } from '../../config/relays' import { KINDS } from '../../config/kinds'
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor' import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
import { UserSettings } from '../settingsService' import { UserSettings } from '../settingsService'
import { rebroadcastEvents } from '../rebroadcastService' import { rebroadcastEvents } from '../rebroadcastService'
import { queryEvents } from '../dataFetch'
import { highlightCache } from './cache'
export const fetchHighlightsForArticle = async ( export const fetchHighlightsForArticle = async (
relayPool: RelayPool, relayPool: RelayPool,
articleCoordinate: string, articleCoordinate: string,
eventId?: string, eventId?: string,
onHighlight?: (highlight: Highlight) => void, onHighlight?: (highlight: Highlight) => void,
settings?: UserSettings settings?: UserSettings,
force = false,
eventStore?: IEventStore
): Promise<Highlight[]> => { ): Promise<Highlight[]> => {
// Check cache first unless force refresh
if (!force) {
const cacheKey = highlightCache.articleKey(articleCoordinate)
const cached = highlightCache.get(cacheKey)
if (cached) {
console.log(`📌 Using cached highlights for article (${cached.length} items)`)
// Stream cached highlights if callback provided
if (onHighlight) {
cached.forEach(h => onHighlight(h))
}
return cached
}
}
try { try {
const seenIds = new Set<string>() const seenIds = new Set<string>()
const processEvent = (event: NostrEvent): Highlight | null => { const onEvent = (event: NostrEvent) => {
if (seenIds.has(event.id)) return null if (seenIds.has(event.id)) return
seenIds.add(event.id) 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) // Query for both #a and #e tags in parallel
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays) const [aTagEvents, eTagEvents] = await Promise.all([
queryEvents(relayPool, { kinds: [KINDS.Highlights], '#a': [articleCoordinate] }, { onEvent }),
const aLocal$ = localRelays.length > 0 eventId
? relayPool ? queryEvents(relayPool, { kinds: [KINDS.Highlights], '#e': [eventId] }, { onEvent })
.req(localRelays, { kinds: [9802], '#a': [articleCoordinate] }) : Promise.resolve([] as NostrEvent[])
.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()))
}
const rawEvents = [...aTagEvents, ...eTagEvents] const rawEvents = [...aTagEvents, ...eTagEvents]
await rebroadcastEvents(rawEvents, relayPool, settings) console.log(`📌 Fetched ${rawEvents.length} highlight events for article:`, articleCoordinate)
// 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 uniqueEvents = dedupeHighlights(rawEvents)
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight) 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 { } catch {
return [] return []
} }

View File

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

View File

@@ -1,5 +1,6 @@
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { IEventStore } from 'applesauce-core'
import { Highlight } from '../../types/highlights' import { Highlight } from '../../types/highlights'
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor' import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
import { queryEvents } from '../dataFetch' import { queryEvents } from '../dataFetch'
@@ -9,12 +10,14 @@ import { queryEvents } from '../dataFetch'
* @param relayPool - The relay pool to query * @param relayPool - The relay pool to query
* @param pubkeys - Array of pubkeys to fetch highlights from * @param pubkeys - Array of pubkeys to fetch highlights from
* @param onHighlight - Optional callback for streaming highlights as they arrive * @param onHighlight - Optional callback for streaming highlights as they arrive
* @param eventStore - Optional event store to persist events
* @returns Array of highlights * @returns Array of highlights
*/ */
export const fetchHighlightsFromAuthors = async ( export const fetchHighlightsFromAuthors = async (
relayPool: RelayPool, relayPool: RelayPool,
pubkeys: string[], pubkeys: string[],
onHighlight?: (highlight: Highlight) => void onHighlight?: (highlight: Highlight) => void,
eventStore?: IEventStore
): Promise<Highlight[]> => { ): Promise<Highlight[]> => {
try { try {
if (pubkeys.length === 0) { if (pubkeys.length === 0) {
@@ -32,12 +35,23 @@ export const fetchHighlightsFromAuthors = async (
onEvent: (event: NostrEvent) => { onEvent: (event: NostrEvent) => {
if (!seenIds.has(event.id)) { if (!seenIds.has(event.id)) {
seenIds.add(event.id) seenIds.add(event.id)
// Store in event store if provided
if (eventStore) {
eventStore.add(event)
}
if (onHighlight) onHighlight(eventToHighlight(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 uniqueEvents = dedupeHighlights(rawEvents)
const highlights = uniqueEvents.map(eventToHighlight) const highlights = uniqueEvents.map(eventToHighlight)

View File

@@ -0,0 +1,208 @@
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)) {
console.log('[highlights] ✅ Already loaded for', pubkey.slice(0, 8))
this.emitHighlights(this.currentHighlights)
return
}
// Increment generation to cancel any in-flight work
this.generation++
const currentGeneration = this.generation
this.setLoading(true)
console.log('[highlights] 🔍 Loading highlights for', pubkey.slice(0, 8))
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
console.log('[highlights] 📅 Incremental sync since', new Date(lastSyncedAt * 1000).toISOString())
}
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) {
console.log('[highlights] ⚠️ Load cancelled (generation mismatch)')
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)
}
console.log('[highlights] ✅ Loaded', sorted.length, 'highlights')
} 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

@@ -1,6 +1,6 @@
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core' import { Helpers, IEventStore } from 'applesauce-core'
import { BlogPostPreview } from './exploreService' import { BlogPostPreview } from './exploreService'
import { Highlight } from '../types/highlights' import { Highlight } from '../types/highlights'
import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor' import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor'
@@ -13,15 +13,17 @@ const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary
* @param relayPool - The relay pool to query * @param relayPool - The relay pool to query
* @param relayUrls - Array of relay URLs to query * @param relayUrls - Array of relay URLs to query
* @param limit - Maximum number of posts to fetch (default: 50) * @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 * @returns Array of blog post previews
*/ */
export const fetchNostrverseBlogPosts = async ( export const fetchNostrverseBlogPosts = async (
relayPool: RelayPool, relayPool: RelayPool,
relayUrls: string[], relayUrls: string[],
limit = 50 limit = 50,
eventStore?: IEventStore
): Promise<BlogPostPreview[]> => { ): Promise<BlogPostPreview[]> => {
try { try {
console.log('📚 Fetching nostrverse blog posts (kind 30023), limit:', limit) console.log('[NOSTRVERSE] 📚 Fetching blog posts (kind 30023), limit:', limit)
// Deduplicate replaceable events by keeping the most recent version // Deduplicate replaceable events by keeping the most recent version
const uniqueEvents = new Map<string, NostrEvent>() const uniqueEvents = new Map<string, NostrEvent>()
@@ -32,6 +34,11 @@ export const fetchNostrverseBlogPosts = async (
{ {
relayUrls, relayUrls,
onEvent: (event: NostrEvent) => { 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 dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${event.pubkey}:${dTag}` const key = `${event.pubkey}:${dTag}`
const existing = uniqueEvents.get(key) const existing = uniqueEvents.get(key)
@@ -42,7 +49,7 @@ export const fetchNostrverseBlogPosts = async (
} }
) )
console.log('📊 Nostrverse blog post events fetched (unique):', uniqueEvents.size) console.log('[NOSTRVERSE] 📊 Blog post events fetched (unique):', uniqueEvents.size)
// Convert to blog post previews and sort by published date (most recent first) // Convert to blog post previews and sort by published date (most recent first)
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values()) const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
@@ -60,7 +67,7 @@ export const fetchNostrverseBlogPosts = async (
return timeB - timeA // Most recent first return timeB - timeA // Most recent first
}) })
console.log('📰 Processed', blogPosts.length, 'unique nostrverse blog posts') console.log('[NOSTRVERSE] 📰 Processed', blogPosts.length, 'unique blog posts')
return blogPosts return blogPosts
} catch (error) { } catch (error) {
@@ -73,25 +80,43 @@ export const fetchNostrverseBlogPosts = async (
* Fetches public highlights (kind:9802) from the nostrverse (not filtered by author) * Fetches public highlights (kind:9802) from the nostrverse (not filtered by author)
* @param relayPool - The relay pool to query * @param relayPool - The relay pool to query
* @param limit - Maximum number of highlights to fetch (default: 100) * @param limit - Maximum number of highlights to fetch (default: 100)
* @param eventStore - Optional event store to persist fetched events
* @returns Array of highlights * @returns Array of highlights
*/ */
export const fetchNostrverseHighlights = async ( export const fetchNostrverseHighlights = async (
relayPool: RelayPool, relayPool: RelayPool,
limit = 100 limit = 100,
eventStore?: IEventStore
): Promise<Highlight[]> => { ): Promise<Highlight[]> => {
try { try {
console.log('💡 Fetching nostrverse highlights (kind 9802), limit:', limit) console.log('[NOSTRVERSE] 💡 Fetching highlights (kind 9802), limit:', limit)
const seenIds = new Set<string>()
const rawEvents = await queryEvents( const rawEvents = await queryEvents(
relayPool, relayPool,
{ kinds: [9802], limit }, { 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)
}
}
}
) )
// 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(rawEvents) const uniqueEvents = dedupeHighlights(rawEvents)
const highlights = uniqueEvents.map(eventToHighlight) const highlights = uniqueEvents.map(eventToHighlight)
console.log('💡 Processed', highlights.length, 'unique nostrverse highlights') console.log('[NOSTRVERSE] 💡 Processed', highlights.length, 'unique highlights')
return sortHighlights(highlights) return sortHighlights(highlights)
} catch (error) { } catch (error) {

View File

@@ -36,6 +36,10 @@ export interface UserSettings {
defaultHighlightVisibilityNostrverse?: boolean defaultHighlightVisibilityNostrverse?: boolean
defaultHighlightVisibilityFriends?: boolean defaultHighlightVisibilityFriends?: boolean
defaultHighlightVisibilityMine?: boolean defaultHighlightVisibilityMine?: boolean
// Default explore scope
defaultExploreScopeNostrverse?: boolean
defaultExploreScopeFriends?: boolean
defaultExploreScopeMine?: boolean
// Zap split weights (treated as relative weights, not strict percentages) // Zap split weights (treated as relative weights, not strict percentages)
zapSplitHighlighterWeight?: number // default 50 zapSplitHighlighterWeight?: number // default 50
zapSplitBorisWeight?: number // default 2.1 zapSplitBorisWeight?: number // default 2.1

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

@@ -73,7 +73,7 @@
.highlight-mode-toggle .mode-btn.active { background: var(--color-primary); color: rgb(255 255 255); /* white */ } .highlight-mode-toggle .mode-btn.active { background: var(--color-primary); color: rgb(255 255 255); /* white */ }
/* Three-level highlight toggles */ /* 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-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; } .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; }

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,30 @@ export const sortIndividualBookmarks = (items: IndividualBookmark[]) => {
export function groupIndividualBookmarks(items: IndividualBookmark[]) { export function groupIndividualBookmarks(items: IndividualBookmark[]) {
const sorted = sortIndividualBookmarks(items) 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 // Group by source list, not by content type
const amethyst = sorted.filter(i => i.sourceKind === 30001 && !i.isPrivate) const nip51Public = sorted.filter(i => i.sourceKind === 10003 && !i.isPrivate)
const isIn = (list: IndividualBookmark[], x: IndividualBookmark) => list.some(i => i.id === x.id) const nip51Private = sorted.filter(i => i.sourceKind === 10003 && i.isPrivate)
// Private items include encrypted legacy bookmarks // Amethyst bookmarks: kind:30001 (any d-tag or undefined)
const privateItems = sorted.filter(i => i.isPrivate && !isIn(web, i)) const amethystPublic = sorted.filter(i => i.sourceKind === 30001 && !i.isPrivate)
const publicItems = sorted.filter(i => !i.isPrivate && !isIn(amethyst, i) && !isIn(web, i)) const amethystPrivate = sorted.filter(i => i.sourceKind === 30001 && i.isPrivate)
return { privateItems, publicItems, web, amethyst } 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 { 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
} }
// Bookmark sets helpers (kind 30003) // Bookmark sets helpers (kind 30003)

35
src/utils/dedupe.ts Normal file
View File

@@ -0,0 +1,35 @@
import { Highlight } from '../types/highlights'
import { BlogPostPreview } from '../services/exploreService'
/**
* Deduplicate highlights by ID
*/
export function dedupeHighlightsById(highlights: Highlight[]): Highlight[] {
const byId = new Map<string, Highlight>()
for (const highlight of highlights) {
byId.set(highlight.id, highlight)
}
return Array.from(byId.values())
}
/**
* Deduplicate blog posts by replaceable event key (author:d-tag)
* Keeps the newest version when duplicates exist
*/
export function dedupeWritingsByReplaceable(posts: BlogPostPreview[]): BlogPostPreview[] {
const byKey = new Map<string, BlogPostPreview>()
for (const post of posts) {
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${post.author}:${dTag}`
const existing = byKey.get(key)
// Keep the newer version
if (!existing || post.event.created_at > existing.event.created_at) {
byKey.set(key, post)
}
}
return Array.from(byKey.values())
}

View File

@@ -123,7 +123,8 @@ export default defineConfig({
}, },
injectManifest: { injectManifest: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,webp}'], globPatterns: ['**/*.{js,css,html,ico,png,svg,webp}'],
globIgnores: ['**/_headers', '**/_redirects', '**/robots.txt'] globIgnores: ['**/_headers', '**/_redirects', '**/robots.txt'],
maximumFileSizeToCacheInBytes: 3 * 1024 * 1024 // 3 MiB
}, },
devOptions: { devOptions: {
enabled: true, enabled: true,