Compare commits

...

176 Commits

Author SHA1 Message Date
Gigi
e514a5f063 chore: bump version to 0.3.6 2025-10-10 13:14:41 +01:00
Gigi
880b7974f4 style: make connecting notification more subtle with muted blue background 2025-10-10 13:12:03 +01:00
Gigi
47048f435f Revert "fix(ui): prevent highlight panel UI breaks with long content or formatting"
This reverts commit a31f05d498.
2025-10-10 06:04:57 +01:00
Gigi
53ad492729 fix(ui): remove incorrect padding-right from highlights container 2025-10-09 21:31:17 +01:00
Gigi
eb4da419ae chore: update Boris pubkey for zap splits to npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x 2025-10-09 21:30:43 +01:00
Gigi
c66dfc9e2e feat(ui): use compact date format for highlights (now, 5m, 3h, 2d, 1mo, 1y) 2025-10-09 21:28:01 +01:00
Gigi
a31f05d498 fix(ui): prevent highlight panel UI breaks with long content or formatting 2025-10-09 21:27:08 +01:00
Gigi
6548e89c54 fix(ui): reduce font size of highlight metadata for cleaner look 2025-10-09 21:25:54 +01:00
Gigi
8a21b46ebd fix(ui): position highlight FAB button relative to article pane, not viewport 2025-10-09 21:23:21 +01:00
Gigi
bc5fe1ae30 fix(ui): adjust relay indicator position for better visual alignment 2025-10-09 21:22:02 +01:00
Gigi
b57ea3f640 fix(ui): ensure highlight metadata elements align on single visual line with consistent line-height 2025-10-09 21:18:14 +01:00
Gigi
3b55d64468 feat(ui): ultra-compact date format for bookmarks sidebar (now, 5m, 3h, 2d, 1mo, 1y) 2025-10-09 21:17:14 +01:00
Gigi
4caf1f0b22 fix(ui): prevent bookmark icons from being cut off in compact view 2025-10-09 21:16:20 +01:00
Gigi
1eb9911645 feat(highlights): encode event links as nevent/naddr per NIP-19 2025-10-09 21:15:03 +01:00
Gigi
38268c453c fix(ui): clean up nested borders in bookmark items for cleaner look 2025-10-09 21:13:47 +01:00
Gigi
9686b80b09 fix(ui): clean up nested borders in bookmarks sidebar view mode controls 2025-10-09 21:12:50 +01:00
Gigi
f32dec16fb fix(ui): align highlight metadata elements on single line in sidebar 2025-10-09 21:12:06 +01:00
Gigi
cb444b532f fix(explore): change header icon from compass to newspaper 2025-10-09 21:11:17 +01:00
Gigi
962062130a feat(routing): render /explore via Bookmarks to keep side panels 2025-10-09 21:10:51 +01:00
Gigi
e429931139 feat(layout): render Explore within ThreePaneLayout so side panels remain 2025-10-09 21:10:33 +01:00
Gigi
e56d28f82a chore: update highlight alt tag domain to read.withboris.com 2025-10-09 21:08:45 +01:00
Gigi
13a30d35c4 docs: update README app link to https://read.withboris.com/ 2025-10-09 21:08:31 +01:00
Gigi
e3174d8777 chore(seo): update robots.txt sitemap to https://read.withboris.com/ 2025-10-09 21:08:22 +01:00
Gigi
829a8d5dca chore(seo): update canonical and social URLs to https://read.withboris.com/ 2025-10-09 21:08:13 +01:00
Gigi
00978e2e64 chore: commit pending RelayStatusIndicator changes before URL update 2025-10-09 21:08:00 +01:00
Gigi
a5fcf36e83 docs: update CHANGELOG for v0.3.5 2025-10-09 20:28:50 +01:00
Gigi
a92a9ee3a3 chore: bump version to 0.3.5 2025-10-09 20:27:59 +01:00
Gigi
f39e34c699 fix: ensure connecting state shows for minimum 15s to prevent premature offline display 2025-10-09 20:27:20 +01:00
Gigi
b58f34d587 fix: add Cloudflare Pages routing config for SPA paths
Add _routes.json configuration to properly handle direct /r/ and /a/ paths
on Cloudflare Pages deployments. This ensures that client-side routes are
served correctly instead of returning 404 errors.
2025-10-09 20:22:08 +01:00
Gigi
76d1d4544e feat: extend connecting state to 8 seconds and remove subtitle text
- Increase 'Connecting' timeout from 4 to 8 seconds
- Remove explanatory subtitle 'Establishing connections...'
- Cleaner, simpler connecting state display
2025-10-09 20:17:29 +01:00
Gigi
5e56176e2d docs: update CHANGELOG for v0.3.3 and v0.3.4 2025-10-09 18:39:30 +01:00
Gigi
a2a4e7e454 chore: bump version to 0.3.4 2025-10-09 18:38:32 +01:00
Gigi
b266288b0f fix: add p tag (author tag) to highlights of nostr-native content
- Highlights now include p tag referencing original article author
- Allows authors to discover highlights of their work
- Follows NIP-84 best practices for highlight attribution
2025-10-09 18:36:20 +01:00
Gigi
1619e328da chore: bump version to 0.3.3 2025-10-09 18:34:12 +01:00
Gigi
b852dad243 fix: resolve linter errors for unused parameters
- Add eslint-disable comments for intentionally unused _settings parameters
- Parameters kept for API compatibility with existing code
- All linter and type checks now pass
2025-10-09 18:31:08 +01:00
Gigi
1552a5f106 feat: reorganize bookmarks UI - add explore button and move refresh
- Move refresh button from top bar to end of bookmarks list
- Show relative time of last fetch next to refresh button
- Add 'Explore' button (fa-newspaper icon) to top bar that links to /explore
- Track lastFetchTime in useBookmarksData hook
- Better UX with explore more prominent and refresh less intrusive
2025-10-09 18:29:41 +01:00
Gigi
0feaffb21b feat: make explore page article cards proper links
- Replace div+onClick with Link components
- Enable CMD+click to open articles in new tabs
- Preserve SPA navigation for normal clicks
- Better UX with standard browser link behavior
2025-10-09 18:27:18 +01:00
Gigi
9b3a4e20de feat: show 'Connecting' instead of 'Offline' on page load
- Display 'Connecting' with spinner for first 4 seconds after page load
- Give relays time to establish connections before showing 'Offline'
- Immediately switch to normal state once any relay connects
- Better UX - most refreshes aren't actually offline, just connecting
2025-10-09 18:26:01 +01:00
Gigi
c83b972a68 fix: correct TypeScript types for cache stats state 2025-10-09 18:24:49 +01:00
Gigi
2e96f93d81 refactor: simplify image caching to use Service Worker only
- Remove complex Cache API management with blob URLs and metadata
- useImageCache now simply returns the URL (Service Worker handles caching)
- imageCacheService reduced to just stats and clear functions
- Service Worker automatically caches all images on fetch
- Much simpler, DRY code that 'just works' for offline mode
- Stats now read directly from Cache API instead of localStorage metadata
2025-10-09 18:24:22 +01:00
Gigi
1e8182d984 feat: add Service Worker for robust offline image caching
- Implement Service Worker to intercept and cache image requests
- Service Worker persists across hard reloads unlike Cache API alone
- Simplify useImageCache hook to work with Service Worker
- Images now work offline even after hard reload
- Service Worker handles transparent cache-first serving for images
2025-10-09 18:17:27 +01:00
Gigi
b20a67d4d0 fix: improve image cache resilience for offline viewing
- Clean up stale metadata when Cache API doesn't have cached data
- Handle online/offline state properly in image loading
- Show original URL when online, blob URL from cache when offline
- Prevent cache misses when browser clears Cache API on hard reload
2025-10-09 18:15:30 +01:00
Gigi
60975b449d fix: import useEventModel from applesauce-react/hooks for proper type safety 2025-10-09 18:10:00 +01:00
Gigi
704fce4d80 fix: import Models from applesauce-core instead of applesauce-react 2025-10-09 18:07:45 +01:00
Gigi
4d1eb0f9fd fix: use correct useEventModel hook for profile loading in BlogPostCard 2025-10-09 18:03:32 +01:00
Gigi
ceafe277d3 feat: add /explore route to discover blog posts from friends
- Create exploreService to fetch kind:30023 events from followed users
- Add BlogPostCard component for displaying blog post previews
- Add Explore page component with grid layout
- Add /explore route to App.tsx (not linked in navigation yet)
- Add responsive CSS styles for explore page and blog post cards
- Clicking blog post cards navigates to article view
2025-10-09 18:02:07 +01:00
Gigi
8f2ecd5fe1 chore: bump version to 0.3.2 2025-10-09 17:49:08 +01:00
Gigi
d6be6f364b refactor: migrate image cache from localStorage to Cache API
BREAKING CHANGE: Image cache now uses Cache API instead of localStorage

Benefits:
- Support for actual 210MB cache size (localStorage limited to 5-10MB)
- Store native Response objects (no base64 overhead)
- Asynchronous, non-blocking operations
- Better suited for large binary blobs like images
- Can handle hundreds of MB to several GB

Changes:
- Rewrite imageCacheService to use Cache API for image storage
- Keep metadata in localStorage for LRU tracking (small footprint)
- Update useImageCache hook to handle async Cache API
- Add blob URL cleanup to prevent memory leaks
- Update clearImageCache to async function

The cache now works as advertised and won't hit quota limits.
2025-10-09 17:48:59 +01:00
Gigi
035d4d3bd0 chore: bump version to 0.3.1 2025-10-09 17:36:37 +01:00
Gigi
43d5554c0c feat: change default image cache size to 210MB
Increase default cache size from 50MB to 210MB for better offline experience
2025-10-09 17:31:53 +01:00
Gigi
724a3e5cfa refactor: move 'Rebroadcast events' setting to Startup & Behavior section
Move the rebroadcast setting from Flight Mode to Startup & Behavior as it's more about behavior than offline mode
2025-10-09 17:31:07 +01:00
Gigi
0c49988d36 refactor: rename 'Startup Preferences' to 'Startup & Behavior' 2025-10-09 17:30:40 +01:00
Gigi
70de68848b refactor: move image cache setting to top of Flight Mode section
Reorder settings so 'Use local image cache' appears before 'Use local relays as cache'
2025-10-09 17:29:37 +01:00
Gigi
8a12ae72cb fix: ensure cache size input uses same font and size as surrounding text
Use inherit for fontSize, fontFamily, and color to match parent styling
2025-10-09 17:29:03 +01:00
Gigi
f8d5d19a9f refactor: inline textbox in cache stats display
Move max cache size input inline with stats text: '( X MB / [input] MB used )'
2025-10-09 17:28:20 +01:00
Gigi
dbd20e676f refactor: use IconButton component for cache clear button
Replace inline styled button with existing IconButton component to keep code DRY
2025-10-09 17:27:53 +01:00
Gigi
bbdf47fb94 refactor: update cache stats display format
Change from '(X.X MB, N images)' to '( X.X MB / [ max ] MB used )'
2025-10-09 17:27:21 +01:00
Gigi
1b754e02dc refactor: update cache setting label to 'Use local image cache' 2025-10-09 17:26:52 +01:00
Gigi
a2e410252a refactor: condense cache settings to single line
- Combine all cache settings into one horizontal line
- Shorten 'Cache images for offline viewing' to 'Cache images'
- Shorten 'Max cache size (MB):' to 'Max (MB):'
- Simplify current stats display with parentheses
- Use flexbox with wrap for responsive layout
2025-10-09 17:26:13 +01:00
Gigi
c9a14d151d refactor: simplify image cache settings UI
- Remove 'Image Cache' heading
- Remove explanatory text about localStorage
- Replace slider with simple number input for cache size
- Replace 'Clear Cache' button text with trash icon
- Make cache stats display more compact
2025-10-09 17:25:18 +01:00
Gigi
b286562e86 fix: extend article hero image to pane edges
Remove padding/margins from article hero images so they extend all the way
to the top, left, and right edges of the article pane. Uses negative margins
to counteract the reader container's padding.
2025-10-09 17:24:24 +01:00
Gigi
507288f51c feat: add image caching for offline mode
- Add imageCacheService with localStorage-based image caching and LRU eviction
- Create useImageCache hook for React components to fetch and cache images
- Integrate image caching with article service to cache cover images on load
- Add image cache settings (enable/disable, size limit) to user settings
- Update ReaderHeader to use cached images for article covers
- Update BookmarkViews (CardView, LargeView) to use cached images
- Add image cache configuration UI in OfflineModeSettings with:
  - Toggle to enable/disable image caching
  - Slider to set cache size limit (10-200 MB)
  - Display current cache stats (size and image count)
  - Clear cache button

Images are cached in localStorage for offline viewing, with a configurable
size limit (default 50MB). LRU eviction ensures cache stays within limits.
2025-10-09 17:23:31 +01:00
Gigi
e08bc54f15 refactor(relay): adjust offline indicator polling to 5s 2025-10-09 17:01:20 +01:00
Gigi
4306069191 fix(relay): make offline indicator poll every 3s for better responsiveness 2025-10-09 17:00:53 +01:00
Gigi
56e56af8ec docs: update CHANGELOG for version 0.3.0 2025-10-09 16:59:05 +01:00
Gigi
4d65cd73a7 chore: bump version to 0.3.0 2025-10-09 16:56:56 +01:00
Gigi
d36d5b33b6 fix(lint): remove unused isLocalRelay import
Remove unused import to fix linter error
2025-10-09 16:56:36 +01:00
Gigi
4cd54834ce fix(highlights): update relay info after automatic sync completes
When offline sync completes successfully, update the highlight's publishedRelays to show all relays and change icon from plane to server. Previously only manual rebroadcast updated this info.
2025-10-09 16:51:10 +01:00
Gigi
1134a41192 fix(highlights): always show relay list in tooltip
Remove special case text - always show the actual list of relays. Use plane icon for local-only highlights but still show relay list in tooltip.
2025-10-09 16:47:25 +01:00
Gigi
aced38b147 fix(highlights): only show successfully reachable relays in flight mode
When creating highlights in flight mode (no remote connection), only show local relays in the relay indicator tooltip. Check connection status to determine which relays are actually reachable before setting publishedRelays field.
2025-10-09 16:41:45 +01:00
Gigi
82f52f73cc fix(highlights): always publish to all configured relays
Remove relay.connected filter when publishing/rebroadcasting. Now always attempts to publish to all configured relays and lets the relay pool handle connection state management. This ensures highlights are broadcast to all relays, not just those that report as connected at publish time.
2025-10-09 16:27:00 +01:00
Gigi
4239f50129 fix(highlights): include local relays in relay indicator tooltip
Remove filter that excluded local relays from the fallback tooltip - now shows all configured relays including localhost
2025-10-09 16:24:32 +01:00
Gigi
4e3bb36ea5 perf: reduce relay status polling interval to 20 seconds
Change relay status polling from 5s (default) and 2s (Settings/Indicator) to 20s across the board to reduce CPU usage and network requests
2025-10-09 16:23:59 +01:00
Gigi
0c58f4347b fix(highlights): show remote relay list for fetched highlights
Instead of 'no relay info', show the list of remote relays we're connected to as a fallback for highlights that don't have publishedRelays metadata (i.e., highlights fetched from other users)
2025-10-09 16:23:28 +01:00
Gigi
2dd0711a20 fix(types): add missing eventStore prop to ThreePaneLayoutProps
Add eventStore property to ThreePaneLayoutProps interface and import IEventStore to fix TypeScript errors
2025-10-09 16:22:06 +01:00
Gigi
53b3dd1c7f refactor(highlights): simplify relay indicator tooltip to show only relay list
Remove verbose text from tooltip - just show the relay URLs for debug purposes
2025-10-09 16:21:12 +01:00
Gigi
47e2204c3f fix(highlights): improve relay indicator tooltip accuracy
Update tooltip text to be more accurate about relay information:
- Show 'Published to X relays' for user-created highlights with publishedRelays
- Show 'Seen on X relays' for highlights with seenOnRelays tracking
- Show 'Fetched from network' for highlights without relay metadata
- Add seenOnRelays field to Highlight type for future relay tracking
2025-10-09 16:16:50 +01:00
Gigi
cc8b742731 fix(highlights): always show relay indicator icon
Previously the relay indicator was only shown for highlights with publishedRelays info (user-created highlights). Now it's always visible:
- Show server icon by default for all highlights
- Show plane icon for local-only/offline highlights
- Show spinner during rebroadcast/sync
- Always allow clicking to rebroadcast any highlight
2025-10-09 16:14:50 +01:00
Gigi
529fc6b630 fix(settings): make Relays heading same level as Flight Mode
Add section-title class to Relays heading to match Flight Mode formatting
2025-10-09 16:12:18 +01:00
Gigi
0c5c4b6c23 refactor(highlights): consolidate sync state into relay indicator
Show automatic rebroadcast/sync state in the relay indicator instead of separate meta spinner:
- Relay indicator shows spinner during both manual rebroadcast and auto-sync
- Update tooltip to distinguish between manual rebroadcast and auto-sync
- Remove redundant syncing indicator from meta area
- Clean up unused CSS for syncing indicator

This provides a single, consistent visual indicator for all relay broadcast states.
2025-10-09 16:11:33 +01:00
Gigi
d7320c4bc8 feat(highlights): add click-to-rebroadcast functionality to relay indicator
Make relay indicator icons clickable to trigger manual rebroadcast to all connected relays:
- Click plane icon (local/offline) to rebroadcast to remote relays
- Click server icon to rebroadcast to all relays
- Show spinner while rebroadcasting
- Update icon from plane to server on successful rebroadcast
- Keep plane icon on failure
- Pass relayPool and eventStore through component chain
- Add local state management for highlight updates in HighlightsPanel
- Enhance CSS with scale animation on hover/active
2025-10-09 16:10:43 +01:00
Gigi
98c107d387 refactor(highlights): consolidate relay/status indicators into single icon
Replace multiple redundant indicators (flight mode, local only, relay info) with a single relay indicator icon in the bottom-left of each highlight:
- Show plane icon for local-only or offline-created highlights
- Show server icon for highlights published to remote relays
- Keep spinner in meta area for actively syncing highlights
- Remove duplicate indicators from meta area
- Clean up unused CSS and imports
2025-10-09 16:07:50 +01:00
Gigi
ebe801ae92 fix(relays): keep all relay connections alive, not just local ones
Previously only local relays had keep-alive subscriptions, causing remote relays to disconnect when no active subscriptions were running. This made the app appear to be in flight mode even when online.

Now create a persistent subscription for all relays to maintain connections.
2025-10-09 16:05:26 +01:00
Gigi
d9730bb5f8 feat(highlights): add relay indicator icon to highlight items
Add a small server icon at the bottom-left of each highlight that shows which relays the highlight was published to. The icon appears when publishedRelays information is available (for user-created highlights) and displays a tooltip with the list of relay URLs on hover.

- Import faServer icon from FontAwesome
- Add relay indicator to HighlightItem component
- Display formatted relay list in tooltip
- Add CSS styling for the indicator with hover effects
- Support both dark and light modes
2025-10-09 16:04:24 +01:00
Gigi
6a142f5163 fix(highlights): publish to all connected relays instead of just one
The highlight creation service was getting all relays from the pool without checking if they were actually connected. This caused highlights to only be published to a subset of relays (sometimes just one).

Now properly filters relays using relay.connected to ensure highlights are published to all actually connected relays when online.
2025-10-09 16:01:16 +01:00
Gigi
2105dfe3f6 refactor: remove calendar icon from publication date
- Remove calendar icon (faCalendar) from publication date display
- Display only the formatted date text
- Remove icon-specific CSS styling (gap, svg styles)
- Cleaner, more minimal date display in top-right corner
2025-10-09 15:57:38 +01:00
Gigi
24c0889e9f refactor: add subtle border to publication date
- Add subtle white border with 20% opacity
- Rounded corners (6px border-radius)
- Helps define the date element without being too prominent
2025-10-09 15:56:47 +01:00
Gigi
db30c05aa0 refactor: simplify publication date styling
- Remove background and border from publication date
- Use white text with subtle drop shadow for all layouts
- Icon now uses drop-shadow filter for better visibility
- Cleaner, more minimal appearance that works well on any background
2025-10-09 15:56:26 +01:00
Gigi
4504377c36 feat: move publication date to top-right corner
- Position publication date in top-right corner of article header
- Works for both hero image and non-image layouts
- Add subtle background and border for better visibility
- On hero images: dark semi-transparent background with backdrop blur
- On regular headers: uses surface-secondary background
- Remove date from inline metadata (reading time and highlights remain)
2025-10-09 15:53:46 +01:00
Gigi
3c1114ad21 fix: resolve TypeScript type errors in offline sync
- Import IAccount from applesauce-accounts (not applesauce-core)
- Remove unused account parameter from syncLocalEventsToRemote
- Fix undefined vs null type mismatch in Bookmarks component
- All linter and type checks now pass cleanly
2025-10-09 15:52:34 +01:00
Gigi
e7c05b2c52 feat: keep local relay connections alive in flight mode
- Add persistent keep-alive subscription for local relays
- Prevents disconnection when no other subscriptions are active
- Uses minimal subscription (kinds: [0], limit: 0) to keep connection open
- Properly cleans up subscription on app unmount
- Resolves issue where local relays disconnect after idle period in flight mode
2025-10-09 14:08:37 +01:00
Gigi
ca35e4e7cc fix: plane icon now shows for offline-created highlights
- Add useEffect to watch highlight.isOfflineCreated prop changes
- State now updates when prop changes (not just on initial mount)
- Add isOfflineCreated to console log for easier debugging
- Fixes issue where plane icon wouldn't appear for new offline highlights

The bug was that showOfflineIndicator state was only set once during
component initialization. If the highlight prop didn't have isOfflineCreated
set at that moment, the icon would never appear even if the prop changed later.
2025-10-09 14:05:58 +01:00
Gigi
2d5e48a64e perf: make highlight creation instant by non-blocking relay publish
Major UX improvement:
- Store event in EventStore FIRST (before publishing)
- Return highlight immediately (no await on relay publish)
- Publish to relays in background asynchronously
- UI now updates instantly (<50ms) instead of waiting seconds

Before:
1. Create event
2. Wait for relay publish (1-5 seconds)
3. Store in EventStore
4. Return to UI

After:
1. Create event
2. Store in EventStore
3. Return to UI immediately 
4. Publish to relays in background

Benefits:
- Instant highlight appearance in UI
- No blocking on network operations
- Better perceived performance
- Especially noticeable in flight mode with slow local relays
- Event still saved even if publishing fails
2025-10-09 14:01:55 +01:00
Gigi
be86634a65 fix: skip rebroadcasting when in flight mode
- Check actual relay connectivity before rebroadcasting
- Skip rebroadcast to all relays if no remote relays connected
- Still allows rebroadcast to local relays in flight mode
- Prevents unnecessary publish attempts to unreachable relays
- Logs: '✈️ Flight mode: skipping rebroadcast to remote relays'

This prevents the app from trying to rebroadcast fetched events to
remote relays when only local relays are connected (flight mode).
2025-10-09 14:00:57 +01:00
Gigi
a2041bd14d feat: show sync progress and hide indicator after successful sync
- Show spinning blue icon while event is syncing to remote relays
- Hide offline indicator completely after successful sync
- Add sync state tracking with listeners for real-time updates
- Track successful vs failed syncs separately
- Only clear offline flag for successfully synced events
- Blue spinner (#3b82f6) indicates active sync
- Clean UI: no indicator after sync completes

Behavior:
1. Create highlight offline → plane icon
2. Come back online → spinner replaces plane
3. Sync completes → no indicator (clean)
4. Sync fails → plane icon returns
2025-10-09 13:56:12 +01:00
Gigi
d294287c64 refactor: use applesauce EventStore for offline event management
Major improvements:
- Store highlights in EventStore immediately when created
- Query EventStore instead of local relays for offline sync
- Pass eventStore to highlight creation service and hooks
- Simplified offline sync: no more relay queries, just EventStore lookups
- More efficient and reliable offline event tracking
- Better integration with applesauce architecture

Benefits:
- Faster sync (no relay queries needed)
- More reliable (events always in EventStore)
- Cleaner code (leveraging applesauce patterns)
- Better separation of concerns
2025-10-09 13:54:47 +01:00
Gigi
95162d4423 feat: add flight mode indicator to offline-created highlights
- Add isOfflineCreated property to Highlight type
- Set flag when highlight is created in local-only mode
- Display small plane icon in highlight sidebar for offline-created highlights
- Lighter amber color (#fbbf24) to distinguish from Local badge
- Tooltip: 'Created while in flight mode'
- Visual indicator helps users track which highlights need syncing
2025-10-09 13:51:04 +01:00
Gigi
4224c989c6 fix: improve offline sync with better tracking and logging
- Track events explicitly when created in offline mode
- Mark highlights as offline-created when isLocalOnly is true
- Add extensive debug logging throughout sync process
- Increase query timeout from 5s to 10s for better reliability
- Add 2-second delay before syncing to allow relays to connect
- Log relay state transitions and event counts
- Log each event received during sync query
- Should help diagnose and fix offline sync issues
2025-10-09 13:49:35 +01:00
Gigi
3330f22f82 fix: clean up sync state tracking in offline sync service
- Remove duplicate state tracking from service
- State transition detection now fully handled by hook
- Fix remaining syncState reference bug
- Simplify sync lock mechanism
2025-10-09 13:38:23 +01:00
Gigi
450776f9d0 feat: automatic offline sync - rebroadcast local events when back online
- Create offlineSyncService to sync local-only events to remote relays
- Create useOfflineSync hook to detect online/offline transitions
- When user comes back online (remote relays connect), automatically:
  - Query local relays for user's events from last 24 hours
  - Rebroadcast highlights and bookmarks to remote relays
- Integrate sync into Bookmarks component
- Enables seamless offline workflow:
  - User can work offline with local relays
  - Events are automatically synced when connection restored
  - No manual intervention required
2025-10-09 13:37:33 +01:00
Gigi
0478713fd5 refactor: rename 'Offline Mode' to 'Flight Mode'
- Change 'Local Only' to 'Flight Mode' in relay status indicator
- Rename settings section from 'Offline Mode' to 'Flight Mode'
- Better reflects the airplane icon metaphor
- More intuitive terminology for local-only relay mode
2025-10-09 13:34:39 +01:00
Gigi
0f2b94cc61 style: use wifi icon for disconnected remote relays
- Replace red dot (faCircle) with red wifi icon (faWifi)
- Better visual representation of network disconnection
- Icon size now consistent at 1rem across all states
2025-10-09 13:32:22 +01:00
Gigi
b511d40375 refactor: improve relay list display with airplane icons
- Remove separate 'Offline' section, show all relays in one list
- Local relays always shown first (sorted to top)
- Local relays use airplane icon instead of checkmark
- Airplane icon is green when connected, red when offline
- Remote relays use green checkmark (connected) or red dot (offline)
- Last seen timestamp shown only for disconnected relays
- Simplified layout for better readability
2025-10-09 13:31:09 +01:00
Gigi
d090b953bf fix: check actual relay connection status instead of pool membership
- Check relay.connected property to determine if relay is actually connected
- Previously only checked if relay was in pool, not if connection was active
- Add debug logging to help diagnose connection status issues
- This should fix the airplane indicator not showing when offline
- Relays should now correctly show as disconnected after being offline
2025-10-09 13:29:43 +01:00
Gigi
19595d19ca feat: improve font size scale and default
- Change font sizes from [14,16,18,20,22,24] to [16,18,21,24,28,32]
- Larger sizes now more spread out (28px and 32px)
- Set default font size to 21px instead of 18px
- Better progression for reading comfort
2025-10-09 13:19:07 +01:00
Gigi
239ebba439 style: improve publishing date display
- Use shorter date format (MMM d, yyyy instead of MMMM d, yyyy)
- Add subtle styling with reduced opacity and smaller font
- Make calendar icon smaller and more muted
- Style overlay version for hero images with subtle white text
2025-10-09 13:16:45 +01:00
Gigi
67c6b75cb7 feat: add 6th font size option for balanced UI
- Add 24px font size option
- Now has 6 font sizes to match 6 color options
- Better visual balance in settings UI
2025-10-09 13:13:40 +01:00
Gigi
502dbd801a feat: place Reading Font and Font Size settings side-by-side
- Wrap both settings in a flex container
- Reading Font takes flexible space with min-width
- Font Size takes only necessary space
- Responsive with flex-wrap for smaller screens
- Better use of horizontal space in settings UI
2025-10-09 13:12:57 +01:00
Gigi
e114223e46 refactor: simplify rebroadcast setting text
- Change 'Rebroadcast events to all relays' to 'Rebroadcast events while browsing'
- More concise and user-friendly wording
2025-10-09 13:11:25 +01:00
Gigi
a9c73d35ef feat: add localhost:4869 as second local relay
- Add ws://localhost:4869 to RELAYS configuration
- Update comment to reflect multiple local relays
- Support additional local relay option for users
2025-10-09 13:10:20 +01:00
Gigi
b8f20b73d1 refactor: simplify text 'local relay(s)' to 'local relays' 2025-10-09 13:08:52 +01:00
Gigi
dc8d687f0c refactor: move local relay info box to Offline Mode section
- Move recommendation text from Relays to Offline Mode section
- Info box about Citrine and nostr-relay-tray now appears at end of Offline Mode
- Remove unused handleLinkClick and useNavigate from RelaySettings
- Add handleLinkClick to OfflineModeSettings for clickable links
- Clean up unused onClose prop in RelaySettings
2025-10-09 13:08:24 +01:00
Gigi
3180fc7c73 refactor: move rebroadcast settings to new Offline Mode section
- Create new OfflineModeSettings component
- Move 'Use local relay(s) as cache' checkbox
- Move 'Rebroadcast events to all relays' checkbox
- Position Offline Mode section before Relays section
- Keep consistent checkbox styling
- Remove settings/onUpdate props from RelaySettings (no longer needed)
2025-10-09 13:07:09 +01:00
Gigi
a0cba9fb6f refactor: use consistent checkbox style for rebroadcast settings
- Match existing checkbox pattern from other settings
- Use setting-group, checkbox-label, and setting-checkbox classes
- Add proper id and htmlFor attributes for accessibility
- Consistent with LayoutNavigationSettings and other checkbox settings
- Keep code DRY with unified styling approach
2025-10-09 13:05:33 +01:00
Gigi
3483532944 refactor: simplify rebroadcast settings UI
- Remove icons from checkbox labels
- Shorten text to simple checkbox labels
- Cleaner, more minimal design
- Settings: 'Use local relay(s) as cache' and 'Rebroadcast events to all relays'
2025-10-09 13:04:50 +01:00
Gigi
db20e73ea3 refactor: integrate rebroadcast settings into Relays section
- Move rebroadcast checkboxes from separate section into Relays section
- Add plane and globe icons to rebroadcast settings
- Remove separate RelayRebroadcastSettings component
- Settings now flow better with rebroadcast options at top, relay list below
- Maintains all functionality while improving UI organization
2025-10-09 13:04:12 +01:00
Gigi
b055294afc feat: add relay rebroadcast settings for caching and propagation
- Add two new settings:
  - Use local relay(s) as cache (default: enabled)
  - Rebroadcast events to all relays (default: disabled)
- Create rebroadcastService to handle rebroadcasting events
- Hook into article, bookmark, and highlight fetching services
- Automatically rebroadcast fetched events based on settings:
  - Articles when opened
  - Bookmarks when fetched
  - Highlights when fetched
- Add RelayRebroadcastSettings component with plane/globe icons
- Benefits:
  - Local caching for offline access
  - Content propagation across nostr network
  - User control over bandwidth usage
2025-10-09 13:01:38 +01:00
Gigi
831cb18b66 feat: improve relay status responsiveness for flight mode
- Reduce relay connection window from 20 minutes to 10 seconds
- Change 'Recently Seen' section to 'Offline' with red styling
- Use red circle icon for offline relays instead of gray
- Poll relay status every 2 seconds in settings (faster feedback)
- Poll relay status every 2 seconds in status indicator
- Now when entering flight mode:
  - Local relay stays connected (green checkmark with plane icon)
  - All remote relays move to red 'Offline' section within 10 seconds
  - Status is highly responsive and clear
2025-10-09 12:49:37 +01:00
Gigi
bb51788a1d feat: add plane icon indicator for local relays in settings
- Show plane icon badge next to local relays in relay list
- Badge appears for both active and recently seen local relays
- Uses amber styling to match local-only mode theme
- Includes tooltip explaining the relay is local
- Makes it easy to identify local relays at a glance in settings
2025-10-09 12:48:18 +01:00
Gigi
4cf2ac9172 feat: add relay status indicator for local-only/offline mode
- Create RelayStatusIndicator component with plane icon for local-only mode
- Position indicator in bottom-left corner with amber styling
- Show when only local relays are connected or completely offline
- Hide indicator when remote relays are available (normal operation)
- Add pulsing globe icon animation to indicate checking for connection
- Include hover effects and smooth transitions
- Auto-adjust position when sidebar is collapsed
- Display relay count and clear status messages
2025-10-09 12:47:29 +01:00
Gigi
bdab9c06e4 fix: make highlight creation resilient to offline/flight mode
- Wrap relay publish in try-catch to handle failures gracefully
- Attempt to publish to local relay even when no relays are connected
- Always return highlight object even if publish fails completely
- Add detailed logging to track publish status and failures
- Mark highlights as local-only when publish fails or only local relays available
- Ensure UI always displays newly created highlights immediately
2025-10-09 12:45:51 +01:00
Gigi
6636d540aa feat: add offline highlight creation with local relay tracking
- Add relay tracking to Highlight type (publishedRelays, isLocalOnly fields)
- Create utility functions to identify local relays (localhost/127.0.0.1)
- Update highlight creation service to track which relays received the event
- Detect when highlights are only on local relays and mark accordingly
- Add visual indicator in UI for local-only highlights with amber badge
- Enable immediate display of highlights created offline
- Ensure highlights work even when only local relay is available
2025-10-09 12:40:04 +01:00
Gigi
aa8332831f docs: update CHANGELOG for versions 0.2.7-0.2.10 2025-10-09 12:35:05 +01:00
Gigi
4ea03c9042 chore: bump version to 0.2.10 2025-10-09 12:33:38 +01:00
Gigi
4720416f2c fix(settings): remove trailing slash from relay URLs
- Update formatRelayUrl to strip trailing / from URLs
- Cleaner display of relay addresses
2025-10-09 12:31:30 +01:00
Gigi
8ad9e652fb feat(settings): highlight active zap split preset
- Add isPresetActive function to detect current preset
- Add 'active' class to preset button matching current weights
- Organize presets in a central object for easier maintenance
- Users can now see which preset is currently applied
2025-10-09 12:31:02 +01:00
Gigi
98c72389e2 refactor(settings): rename 'Default View Mode' to 'Default Bookmark View'
- More descriptive label clarifying this controls bookmark display
- Better indicates what view is being configured
2025-10-09 12:30:18 +01:00
Gigi
e032f432dd refactor(settings): move Show highlights checkbox after Default Highlight Visibility
- Reorder settings for better logical flow
- Show highlights toggle now appears after visibility controls
- Positioned right before the preview section
2025-10-09 12:29:49 +01:00
Gigi
852465bee7 fix(settings): constrain Reading Font dropdown width
- Wrap FontSelector in setting-control div
- Prevents dropdown from stretching across full page width
- Matches layout of other inline controls like color pickers
2025-10-09 12:29:13 +01:00
Gigi
39d0147cfa feat(routing): add /settings route and URL-based settings navigation
- Add /settings route to router
- Derive showSettings from location.pathname instead of state
- Update onOpenSettings to navigate to /settings
- Update onCloseSettings to navigate back to previous location
- Track previous location to restore context when closing settings
- Remove showSettings state from useBookmarksUI hook
2025-10-09 12:27:43 +01:00
Gigi
10cc7ce9b0 refactor(settings): move Default Highlight Visibility to Reading & Display
- Move setting from Startup Preferences to Reading & Display section
- Position above preview so changes are immediately visible
- Update preview to respect defaultHighlightVisibility settings
- Each highlight level (mine/friends/nostrverse) now toggles in preview
2025-10-09 12:24:50 +01:00
Gigi
6b8442ebdd refactor(settings): combine relay info into single paragraph
- Merge two separate paragraphs into one continuous text
- Remove line break between relay recommendations and educational links
2025-10-09 12:23:40 +01:00
Gigi
5aba283e92 refactor(settings): use sidebar-style colored buttons for highlight visibility
- Replace generic IconButton with colored level-toggle-btn elements
- Match the UI style from HighlightsPanelHeader in sidebar
- Show highlight colors (purple, orange, yellow) when active
- Use same CSS classes and structure for visual consistency
2025-10-09 12:23:05 +01:00
Gigi
59df232e2e refactor(settings): simplify Relays section by removing summary text
- Remove 'X active relays' summary count
- Remove 'Active' heading for cleaner UI
- Keep 'Recently Seen' heading for context since those are different
2025-10-09 12:21:36 +01:00
Gigi
702c001d46 feat(settings): add educational links about relays in reader view
- Add message with links to learn about relays (nostr.how and substack article)
- Links open in Boris's reader view via /r/ route instead of external tabs
- Close settings panel when links are clicked to show the content
- Use react-router navigation for seamless in-app experience
2025-10-09 12:21:09 +01:00
Gigi
48a9919db8 feat(reader): display article publication date
- Add published field to ReadableContent interface
- Pass published date from article loader through component chain
- Display formatted publication date in ReaderHeader with calendar icon
- Format date as 'MMMM d, yyyy' using date-fns
2025-10-09 12:15:28 +01:00
Gigi
d6d0755b89 feat(settings): add local relay recommendations to Relays section
- Add informational message recommending Citrine or nostr-relay-tray
- Include direct links to download pages for both local relay options
2025-10-09 12:13:41 +01:00
Gigi
facdd36145 feat(settings): add Relays section showing active and recently connected relays
- Add relayStatusService to track relay connections with 20-minute history
- Add useRelayStatus hook for polling relay status updates
- Create RelaySettings component to display active and recent relays
- Update Settings and ThreePaneLayout to integrate relay status display
- Shows relay connection status with visual indicators and timestamps
2025-10-09 12:09:53 +01:00
Gigi
5d379a280b chore: bump version to 0.2.9 2025-10-08 13:14:28 +01:00
Gigi
22a02d228d fix: deduplicate highlights in streaming callbacks
- Replace array accumulation with Map keyed by highlight ID
- Prevents duplicate highlights when same event arrives from multiple relays
- Fix applied in useArticleLoader and useBookmarksData hooks
- Only updates UI when new unique highlight arrives
- Resolves issue where highlights appeared twice in sidebar
2025-10-08 12:55:27 +01:00
Gigi
61fd5bbadc chore: bump version to 0.2.8 2025-10-08 12:42:24 +01:00
Gigi
d642c87527 fix: pass article summary through to ReadableContent
- Add summary field when converting ArticleContent to ReadableContent
- Fix contentLoader.ts to include article.summary
- Fix useArticleLoader.ts to include article.summary
- Article summaries now properly display in reader header
- Resolves missing summary display for kind:30023 articles
2025-10-08 12:41:03 +01:00
Gigi
fea425b5d0 feat: display article summary in header
- Add summary field to ReadableContent interface
- Pass summary through ContentPanel to ReaderHeader
- Display summary below title in both overlay and standard layouts
- Style summary with reading font for consistency
- Summary appears in white with shadow in image overlays
- Summary appears in gray (#aaa) in standard headers
- Enhances article preview and reading experience
2025-10-08 12:35:05 +01:00
Gigi
1609c6e580 feat: overlay title and metadata on hero images
- Position title and metadata absolutely over hero images
- Add gradient background for text readability (dark at bottom)
- Use backdrop-filter blur for metadata badges
- White text with shadow for better contrast
- Maintain original layout when no image present
- Creates more immersive reading experience
2025-10-08 12:30:00 +01:00
Gigi
270ea94c70 feat: apply reading font to article titles
- Add font-family: var(--reading-font) to .reader-title class
- Ensures consistent typography between titles and body text
- Titles now respect user's reading font preference from settings
2025-10-08 12:09:36 +01:00
Gigi
83e2f23357 chore: update homepage URL to read.withboris.com 2025-10-08 12:08:11 +01:00
Gigi
9df0261071 fix: correct Jina AI Reader proxy URL format
- Remove hardcoded http:// prefix in proxy URL
- Preserve original protocol (http/https) when constructing proxy URL
- Fix: https://r.jina.ai/https://example.com instead of /http://example.com
- Resolves metadata fetching issues for HTTPS URLs
2025-10-08 12:00:03 +01:00
Gigi
1dfe66651a refactor: reorder toolbar buttons
- New order: Profile, Home, Settings, Refresh, Plus, Logout
- Navigation first (Home, Settings)
- Actions in middle (Refresh, Plus)
- Logout at end
2025-10-08 11:58:28 +01:00
Gigi
dcb7933ede refactor: reorder toolbar buttons
- New order: Profile, Home, Refresh, Add, Settings, Logout
- Groups navigation (Profile, Home) at start
- Action buttons (Refresh, Add) in middle
- Settings and Logout at end
2025-10-08 11:48:19 +01:00
Gigi
aa72ac44c8 chore: bump version to 0.2.7 2025-10-08 11:45:52 +01:00
Gigi
44fb07033b refactor: reorder toolbar buttons for better UX
- New order: User, Home, Settings, Add, Refresh, Logout
- Profile/user first for identity prominence
- Core navigation (Home, Settings) before actions
- Actions (Add, Refresh) grouped together
- Logout at the end as final action
2025-10-08 11:45:07 +01:00
Gigi
7e2d412869 refactor: swap refresh and profile button positions in toolbar
- Move profile avatar before refresh button
- New order: Home, Add, Profile, Refresh, Settings, Logout/Login
- Improves toolbar organization and button flow
2025-10-08 11:39:50 +01:00
Gigi
19021af49a fix: revert to fetchReadableContent to avoid CORS issues
- url-metadata package doesn't work in browser due to CORS
- Restore use of fetchReadableContent with Jina AI proxy
- Extract helper functions for cleaner metadata parsing
- Maintain same functionality with proper browser compatibility
- Remove url-metadata dependency (25 packages)
2025-10-08 11:16:59 +01:00
Gigi
bdbb89c50e feat: only add boris tag when metadata is auto-extracted
- Start with empty tags field instead of pre-filling 'boris'
- Track if any metadata was successfully extracted (title, description, or tags)
- Only add 'boris' tag if we extracted something automatically
- Makes the boris tag more meaningful and intentional
- User can still manually add tags for URLs without metadata
2025-10-08 11:15:34 +01:00
Gigi
687f60db3f refactor: DRY tag extraction with normalizeTags helper
- Create normalizeTags helper to eliminate duplication
- Handle both string and array tag formats uniformly
- Reduce tag extraction code from 25 lines to 10 lines
- Cleaner, more maintainable code
2025-10-08 11:12:34 +01:00
Gigi
8ee7d347be refactor: use url-metadata package for robust metadata extraction
- Replace manual regex-based HTML parsing with url-metadata package
- Cleaner code with proper handling of OpenGraph, Twitter Cards, and standard meta tags
- Better handling of keywords (supports both string and array formats)
- More reliable extraction across different website structures
- Removes dependency on fetchReadableContent for metadata
- Significantly reduces code complexity (60+ lines to ~20 lines)
2025-10-08 11:10:10 +01:00
Gigi
8e9242e6f2 feat: auto-extract tags from metadata and add boris as default tag
- Set 'boris' as default tag in bookmark creation modal
- Extract tags from keywords meta tag (comma/semicolon separated)
- Extract tags from OpenGraph article:tag properties
- Deduplicate and limit to 5 extracted tags
- Prepend 'boris' to any extracted tags
- Only extract tags if user hasn't modified the tags field
- Use ref to prevent re-fetching same URL
- Improves bookmark organization and discoverability
2025-10-08 11:06:14 +01:00
Gigi
1df3962064 fix: improve modal spacing with proper box-sizing
- Add box-sizing: border-box to modal-content
- Add box-sizing: border-box to form inputs and textareas
- Ensures padding is included in width calculation
- Fixes right margin and prevents content from touching edges
2025-10-08 11:04:44 +01:00
Gigi
4edc22cec2 feat: prioritize OpenGraph tags for metadata extraction
- Extract title with priority: og:title > twitter:title > <title>
- Extract description with priority: og:description > twitter:description > meta description > first <p>
- OpenGraph tags provide better, curated metadata for sharing
- Twitter Card tags as fallback for social media compatibility
- Improved metadata quality for most modern websites
2025-10-08 11:01:51 +01:00
Gigi
82977fa5d4 feat: auto-fetch title and description when URL is pasted
- Automatically fetch page metadata using r.jina.ai proxy
- Debounced (800ms) to avoid API spam while typing
- Only auto-fills if fields are empty (won't overwrite user input)
- Extracts title from page
- Extracts description from meta tag or first paragraph
- Shows spinner indicator while fetching
- Gracefully handles fetch errors (just skips auto-fill)
- Uses existing fetchReadableContent service
2025-10-08 11:00:51 +01:00
Gigi
1a84817453 perf: publish bookmarks to relays in background
- Remove await on relayPool.publish() to not block UI
- Bookmark modal now closes immediately after signing
- Publishing happens asynchronously in the background
- Added error handling for failed relay publishes
- Fixes slow save issue caused by waiting for all 12 relays
2025-10-08 10:02:04 +01:00
Gigi
a0b98231b7 feat: add tags support to web bookmarks per NIP-B0
- Added tags input field to bookmark modal (comma-separated)
- Updated createWebBookmark to accept tags array
- Tags are added as 't' tags per NIP-B0 spec
- Added published_at tag with current timestamp
- Moved description to content field (per spec, not summary tag)
- d tag now uses URL without scheme (host + path + search + hash)
- Added helper text to explain tag formatting
- Styled form-helper-text for better UX
2025-10-08 09:51:33 +01:00
Gigi
d452f96f79 fix: pass relayPool as prop instead of using non-existent hook
- Fixed crash when opening bookmark bar
- Removed non-existent Hooks.useRelayPool() call
- Added relayPool prop to SidebarHeader, BookmarkList, and ThreePaneLayout
- Threaded relayPool through component hierarchy from Bookmarks down
- Web bookmark creation now works correctly
2025-10-08 09:49:02 +01:00
Gigi
dcf43cfce1 feat: add web bookmark creation (NIP-B0, kind:39701)
- Created webBookmarkService for creating web bookmarks
- Added AddBookmarkModal component with URL, title, and description fields
- Added plus button to sidebar header (visible when logged in)
- Modal validates URL format and publishes to relays
- Auto-refreshes bookmarks after creation
- Styled modal with dark theme matching app design
- Follows NIP-B0 spec: URL in 'd' tag, title and summary tags
2025-10-08 09:44:45 +01:00
Gigi
815b3cc57d fix: correct type signature for addZapTags function
- Changed event parameter type from NostrEvent to { tags: string[][] }
- Allows function to accept both EventTemplate and NostrEvent
- Fixes TypeScript error where EventTemplate was passed before signing
- No functional changes, just type safety improvement
2025-10-08 09:27:36 +01:00
Gigi
7e54a01237 feat: add zap split preset buttons
- Added 4 preset buttons: Default, Generous, Selfless, and Boris 🧡
- Default: 50/2.1/50 (You: 49%, Author: 49%, Boris: 2%)
- Generous: 5/10/75 (You: 6%, Author: 83%, Boris: 11%)
- Selfless: 1/19/80 (You: 1%, Author: 80%, Boris: 19%)
- Boris 🧡: 10/80/10 (You: 10%, Author: 10%, Boris: 80%)
- One-click setup for common zap split configurations
- Hover tooltips show actual percentage splits
2025-10-08 09:25:37 +01:00
Gigi
ec4692da15 fix: prevent sliders from jumping when resetting settings
- Added debouncing (300ms) to settings auto-save
- Added flag to prevent external settings updates during local editing
- External updates are blocked for 500ms after save completes
- Fixes issue where rapid save/subscription cycle caused sliders to jump
- Settings now update smoothly when resetting to defaults
2025-10-08 07:14:06 +01:00
Gigi
f6d2f98eae refactor: make zap split sliders independent using weights
- Changed from percentage-based to weight-based zap splits
- All three sliders (highlighter, author, Boris) are now independent
- Weights are normalized to calculate actual percentages
- UI shows both weight value and calculated percentage
- Added migration logic for users with old percentage-based settings
- Each slider can be adjusted without affecting the others
- Prevents interdependent slider behavior that was confusing

Breaking change: Settings now use zapSplitHighlighterWeight,
zapSplitAuthorWeight, and zapSplitBorisWeight instead of
zapSplitPercentage and borisSupportPercentage
2025-10-08 07:06:42 +01:00
Gigi
9b97715274 feat: add Boris support percentage to zap splits
- Added borisSupportPercentage setting (default 2.1%)
- Added separate slider in ZapSettings for Boris support
- Updated zap split calculation to include three-way split:
  - Highlighter gets their configured percentage
  - Boris gets their support percentage (0-10%)
  - Author(s) get remaining percentage, split proportionally
- Display all three percentages in the UI
- Updated addZapTags to include Boris as zap recipient
- Boris support is optional and adjustable (0-10% range)
2025-10-08 07:03:47 +01:00
Gigi
fa1e536a26 refactor: move zap splits to dedicated settings section
- Created new ZapSettings component as separate section
- Moved zap split slider from ReadingDisplaySettings
- Placed at end of settings page as requested
- Updated description to mention multiple authors support
2025-10-08 07:02:02 +01:00
Gigi
238aac1921 docs: add zap splits feature to README 2025-10-08 06:55:32 +01:00
Gigi
29edd159e7 feat: respect existing zap tags in source content when creating highlights
- Updated addZapTags function to check for existing zap tags in source event
- When source has zap tags (author group), split proportionally:
  - Highlighter gets their configured percentage
  - Remaining percentage distributed among existing authors proportionally
- Example: 50/50 split with 2 source authors = 50% highlighter, 25% each author
- Falls back to simple two-way split if no existing zap tags
- Prevents duplicate entries if highlighter is already in author group
2025-10-08 06:53:53 +01:00
Gigi
a3edb64e4c docs: add CHANGELOG.md based on git history 2025-10-08 06:43:08 +01:00
Gigi
1609416e1e chore: bump version to 0.2.6 2025-10-08 06:40:39 +01:00
Gigi
00d9fbdbab feat: add home button to bookmark bar
- Add home icon button to sidebar header
- Clicking home button navigates to root path '/'
- Position home button before refresh and settings buttons
2025-10-08 06:34:22 +01:00
Gigi
239ab5763d feat: add configurable zap split for highlights on nostr-native content
- Add zapSplitPercentage setting (default 50%) to UserSettings
- Implement NIP-57 Appendix G zap tags for highlight events
- Add zap tags when creating highlights of nostr-native content
- Split zaps between highlighter and article author based on setting
- Add UI slider in settings to configure split percentage
- Include relay URL in zap tags for metadata lookup
- Only add author zap tag if different from highlighter
2025-10-08 06:32:02 +01:00
60 changed files with 4123 additions and 234 deletions

View File

@@ -0,0 +1,20 @@
---
description: Specification for zaps and zaps splits
alwaysApply: false
---
When we create highlights, we want to add `zap` tags to the event, to allow for value splits between the highlighter/curator and the author (or authors).
`zap` tags are defined in Appendix G of NIP-57:
- https://github.com/nostr-protocol/nips/blob/master/57.md
More on `zap` tags here:
- https://nostrbook.dev/tags/zap
Note that nostr-native content might have `zap` tags already, which can be seen as the "author group" of e.g. the long-form article (writer, editor, designer, etc). We should respect these `zap` tags and include them into our "zap splits" appropriately.
Example: if our zap-split setting is 50/50, and the nostr-native blog post has two authors, our zap splits should be as follows:
- Highlighter: 50%
- Author1: 25%
- Author2: 25%

544
CHANGELOG.md Normal file
View File

@@ -0,0 +1,544 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.3.5] - 2025-10-09
### Fixed
- Ensure connecting state shows for minimum 15 seconds to prevent premature offline display
- Add Cloudflare Pages routing config for SPA paths
### Changed
- Extend connecting state duration and remove subtitle text for cleaner UI
## [0.3.4] - 2025-10-09
### Fixed
- Add p tag (author tag) to highlights of nostr-native content for proper attribution
## [0.3.3] - 2025-10-09
### Added
- Service Worker for robust offline image caching
- /explore route to discover blog posts from friends on Nostr
- Explore button (newspaper icon) in bookmarks header
- "Connecting" status indicator on page load (instead of immediately showing "Offline")
- Last fetch time display with relative timestamps in bookmarks list
### Changed
- Simplify image caching to use Service Worker transparently
- Move refresh button from top bar to end of bookmarks list
- Make explore page article cards proper links (supports CMD+click to open in new tab)
- Reorganize bookmarks UI for better UX
### Fixed
- Improve image cache resilience for offline viewing and hard reloads
- Correct TypeScript types for cache stats state
- Resolve linter errors for unused parameters
- Import useEventModel from applesauce-react/hooks for proper type safety
- Import Models from applesauce-core instead of applesauce-react
- Use correct useEventModel hook for profile loading in BlogPostCard
## [0.3.0] - 2025-10-09
### Added
- Flight Mode with offline highlight creation and local relay support
- Automatic offline sync - rebroadcast local events when back online
- Relay indicator icon on highlight items showing sync status
- Click-to-rebroadcast functionality for highlights
- Flight mode indicator (plane icon) on offline-created highlights
- Relay rebroadcast settings for caching and propagation
- Local relay status indicator for local-only/offline mode
- Second local relay support (localhost:4869)
- Relay connection status tracking and display
- 6th font size option for better UI scaling
### Fixed
- Highlight creation resilient to offline/flight mode
- TypeScript type errors in offline sync
- Relay indicator tooltip accuracy and reliability
- Always show relay indicator icon on highlights
- Show remote relay list for fetched highlights
- Publish highlights to all connected relays instead of just one
- Keep all relay connections alive, not just local ones
- Check actual relay connection status instead of pool membership
- Skip rebroadcasting when in flight mode
- Update relay info after automatic sync completes
- Only show successfully reachable relays in flight mode
- Include local relays in relay indicator tooltip
### Changed
- Rename 'Offline Mode' to 'Flight Mode' throughout UI
- Move publication date to top-right corner with subtle border styling
- Consolidate relay/status indicators into single unified icon
- Simplify relay indicator tooltip to show relay list
- Move rebroadcast settings to dedicated Flight Mode section
- Place Reading Font and Font Size settings side-by-side
- Improve font size scale and default value
- Use wifi icon for disconnected remote relays
- Use airplane icons for local relay indicators
- Make Relays heading same level as Flight Mode in settings
- Simplify rebroadcast settings UI with consistent checkbox style
### Performance
- Make highlight creation instant with non-blocking relay publish
- Reduce relay status polling interval to 20 seconds
- Show sync progress and hide indicator after successful sync
## [0.2.10] - 2025-10-09
### Added
- URL-based settings navigation with /settings route
- Active zap split preset highlighting
- Educational links about relays in reader view
- Article publication date display in reader
- Local relay recommendations in settings
- Relays section showing active and recently connected relays
### Fixed
- Remove trailing slash from relay URLs
- Constrain Reading Font dropdown width
### Changed
- Rename 'Default View Mode' to 'Default Bookmark View' in settings
- Reorganize settings layout for better UX
- Use sidebar-style colored buttons for highlight visibility
- Simplify Relays section presentation
## [0.2.9] - 2025-10-09
### Fixed
- Deduplicate highlights in streaming callbacks
## [0.2.8] - 2025-10-09
### Added
- Display article summary in header
- Overlay title and metadata on hero images
- Apply reading font to article titles
### Fixed
- Pass article summary through to ReadableContent
- Correct Jina AI Reader proxy URL format
### Changed
- Update homepage URL to read.withboris.com
- Reorder toolbar buttons for better UX
## [0.2.7] - 2025-10-08
### Added
- Web bookmark creation (NIP-B0, kind:39701)
- Tags support for web bookmarks per NIP-B0
- Auto-fetch title and description when URL is pasted
- Prioritize OpenGraph tags for metadata extraction
- Auto-extract tags from metadata with boris as default tag
- Zap split preset buttons
- Boris support percentage to zap splits
- Respect existing zap tags in source content when creating highlights
### Fixed
- Revert to fetchReadableContent to avoid CORS issues
- Improve modal spacing with proper box-sizing
- Prevent sliders from jumping when resetting settings
- Pass relayPool as prop instead of using non-existent hook
- Correct type signature for addZapTags function
### Changed
- Reorder toolbar buttons for better UX
- DRY up tag extraction with normalizeTags helper
- Use url-metadata package for robust metadata extraction
- Make zap split sliders independent using weights
- Move zap splits to dedicated settings section
- Publish bookmarks to relays in background for better performance
## [0.2.6] - 2025-10-08
### Added
- Home button to bookmark bar
- Configurable zap split for highlights on nostr-native content
## [0.2.5] - 2025-10-07
### Fixed
- Wire preview ref to markdown conversion hook
- Add missing useEffect dependencies for article loading
### Changed
- DRY up highlight classification and URL normalization
- Split highlighting utilities into modules
- Extract highlights panel components
- Extract content rendering hooks
- Split Settings into section components
- Extract event processing utilities
- Split Bookmarks.tsx into smaller hooks and components
## [0.2.4] - 2025-10-07
### Added
- Domain configuration for https://xn--bris-v0b.com/
- Public assets and deployment configuration
- Hide bookmarks without content or URL
### Fixed
- Encode/decode URLs in /r/ route to preserve special characters
### Changed
- Cleanup after build fixes (remove shims, update locks)
- Stop tracking node_modules/dist
- Update dependencies and dedupe
- Add .gitignore for node_modules and dist
## [0.2.3] - 2025-10-07
### Added
- Parse and display summary tag for nostr articles
- Merge and flatten bookmarks from multiple lists
- Update URL path when opening bookmarks from sidebar
### Fixed
- Ensure bookmarks are sorted newest first after merging lists
- Hide empty bookmarks without content
- Remove encrypted cyphertext display from bookmark list
### Changed
- Remove created date from bookmark list display
## [0.2.2] - 2025-10-06
### Added
- Support for web bookmarks (NIP-B0, kind:39701)
- Default highlight visibility settings
- Proxy.nostr-relay.app relay to configuration
- Comprehensive logging to settings service
### Fixed
- Handle web bookmarks with URLs in d tag and prevent crash
- Load settings from local cache first to eliminate FOUT
- Ensure fonts are fully loaded before applying styles
- Improve highlight rendering pipeline with comprehensive debugging
### Changed
- Use icon toggle buttons for highlight visibility settings
- Change nostrverse icon from fa-globe to fa-network-wired
## [0.2.1] - 2025-10-05
### Added
- Local relay support and centralize relay configuration
- Optimistic updates for highlight creation
- Enable highlight creation from external URLs
- Add routing support for external URLs
- Add context to highlights (previous and next sentences)
- Boris branding to highlight alt tag
### Fixed
- Properly await account loading from localStorage on refresh
- Add protected routes to prevent logout on page refresh
- Use undo icon for reset to defaults button
- Update local relay port to 10547
### Changed
- Remove dedicated login page, handle login through main UI
- Simplify to single RELAYS constant (DRY)
## [0.2.0] - 2025-10-05
### Added
- Simple highlight creation feature (FAB style)
- Reset to defaults button in settings
- Load and apply settings upon login
### Fixed
- Replace any types with proper NostrEvent types
- Move FAB to Bookmarks component for proper floating
- Highlight button positioning with scroll
### Changed
- Update color palette to include default friends/nostrverse colors
- Show author name in highlight cards
- Sync highlight level toggles between sidebar and main article text
- Rename 'underlines' to 'highlights' throughout codebase
## [0.1.11] - 2025-10-05
### Added
- Stream highlights progressively as they arrive from relays
### Fixed
- Display article immediately without waiting for highlights to load
- Show highlights immediately when opening panel if already loaded
- Prevent bookmark text from being cut off in compact view
- Correct default highlight color for 'mine' to yellow (#ffff00)
### Changed
- Reduce padding between bookmark items and panel edge
- Update default highlight colors to orange for friends and purple for nostrverse
## [0.1.10] - 2025-10-05
### Added
- Three-level highlight system (mine/friends/nostrverse)
### Fixed
- Ensure highlights always render on markdown content
- Classify highlights before passing to ContentPanel
- Position toggle buttons directly adjacent to main panel
- Remove redundant setReaderLoading call in error handler
### Changed
- Always show friends and user highlight buttons
- Remove Highlights title and count from panel
## [0.1.9] - 2025-10-05
### Fixed
- Show markdown content immediately when finalHtml is empty
- Prevent highlight bleeding into sidebar
## [0.1.8] - 2025-10-05
### Fixed
- Prevent 'No readable content' flash for markdown articles
- Enable highlights display and scroll-to for markdown content
### Added
- Persist accounts to localStorage
### Changed
- Simplify login by handling it directly in sidebar
## [0.1.7] - 2025-10-05
### Added
- Show highlights in article content and add mode toggle
### Fixed
- Show highlights for nostr articles by skipping URL filter
- Refresh button now works without login for article highlights
- Query highlights using both a-tag and e-tag
### Changed
- Keep Bookmarks.tsx under 210 lines by extracting logic
## [0.1.6] - 2025-10-03
### Added
- Native support for rendering Nostr long-form articles (NIP-23)
- Display article titles for kind:30023 bookmarks
- Enable clicking on kind:30023 articles to open in reader
- Display article hero images in bookmark views and reader
- Configurable highlight colors
- Highlight style setting (marker & underline)
### Fixed
- Use bookmark pubkey for article author instead of tag lookup
- Ensure highlight color CSS variable inherits from parent
### Changed
- Integrate long-form article rendering into existing reader view
- Extract components to keep files under 210 lines
- Make font size and color buttons match icon button size (33px)
## [0.1.5] - 2025-10-03
### Added
- Settings panel with NIP-78 storage
- Auto-save for settings with toast notifications
- Reading time estimate to articles
- Font size setting
- Configurable reading font using Bunny Fonts
- Live preview of reading font in settings
- Settings subscription to watch for Nostr updates
### Fixed
- Prevent settings from saving unnecessarily
- Prevent save settings button from being cut off
- Replace custom reading time with reading-time-estimator package
- Update originalHtmlRef when content changes
### Changed
- Reduce file sizes to meet 210 line limit
- Extract settings logic into custom hook
- Consolidate settings initialization on login
- Remove debounce from settings auto-save
## [0.1.4] - 2025-10-03
### Added
- Inline highlight annotations in content panel
- NIP-84 highlights panel with three-pane layout
- Toggle button to show/hide highlight underlines
- Click-to-scroll for highlights
- Pulsing animation when scrolling to highlight
### Fixed
- Apply highlights to markdown content as well as HTML
- Use requestAnimationFrame for highlight DOM manipulation
- Improve HTML highlight matching with DOM manipulation
- Filter highlights panel to show only current article
### Changed
- Use applesauce helpers for highlight parsing
- DRY up highlightMatching to stay under 210 lines
- Change highlights to fluorescent marker style
- Deduplicate highlight events by ID
## [0.1.3] - 2025-10-03
### Added
- View mode switching for bookmarks with compact list view
- Large preview view mode
- Image preview for large view cards
- Hero images using free CORS proxy
### Changed
- Make entire compact list row clickable to open reader
- Make card view timestamp clickable to open event
- Enhance card view design with modern styling
## [0.1.2] - 2025-10-03
### Added
- Open bookmark URLs in reader instead of new window
- localStorage caching for fetched articles
- Collapsible bookmarks sidebar
### Fixed
- Make sidebar and reader scroll independently
- Replace relative-time with date-fns for timestamp formatting
### Changed
- Display timestamps as relative time
- Replace user text with profile image in sidebar header
- Move user info and logout to sidebar header bar
- Reduce IconButton size by 25%
## [0.1.1] - 2025-10-03
### Added
- Classify URLs by type and adjust action buttons
- Collapse/expand functionality for bookmarks sidebar
- IconButton component with square styling
- Resolve nprofile/npub mentions to names in content
### Fixed
- Enforce 210-char truncation for both plain and parsed content
- Use Rules of Hooks correctly
### Changed
- Use IconButton for all icon-only actions to enforce square sizing
- Sort bookmarks by added_at (recently added first)
- Make kind icon square to match IconButton sizing
- Remove colored borders and gradients; keep neutral cards
## [0.1.0] - 2025-10-03
### Added
- Two-pane layout and content fetching pipeline
- ContentPanel component to render readable HTML
- Lightweight readability fetcher via r.jina.ai proxy
- Markdown rendering support with react-markdown and remark-gfm
- READ NOW button to bookmark cards
- Spinner to content loading state
- FontAwesome icons for event kinds
### Fixed
- Show bookmarked event author instead of list signer
- Enable reactive profile fetch via address loader
- Left-align content and constrain images in content panel
### Changed
- Resolve author names using applesauce ProfileModel
- Propagate URL selection through BookmarkList to parent
- Display URLs clearly in individual bookmarks
## [0.0.3] - 2025-10-02
### Added
- Manual decryption for unrecognized event kinds
- Try NIP-44 then NIP-04 for manual decryption
- Detailed debugging for decryption process
- Support for hidden bookmarks decryption
### Fixed
- Surface manually decrypted hidden tags in UI
- Dedupe individual bookmarks by id
### Changed
- Sort individual bookmarks by timestamp (newest first)
- Increase bookmark loading timeout by 2x
- Extract helpers and event processing
## [0.0.2] - 2025-10-02
### Added
- Fetch all NIP-51 events
- Unlock private bookmarks via applesauce helpers
- Copy-to-clipboard icons for event id and author pubkey
- FontAwesome globe/lock icons
- Display content identically for private/public bookmarks
### Fixed
- Properly configure browser extension signer
- Aggregate list(10003) + set(30001)
- Handle applesauce bookmark structure correctly
- Resolve loading state stuck issue
### Changed
- Change bookmarks display from grid to social feed list layout
- Simplify bookmark service using applesauce helpers
- Extract components and utilities to keep files under 210 lines
## [0.0.1] - 2025-10-02
### Added
- Initial release
- Browser extension login support
- NIP-51 bookmark fetching from nostr relays
- User profile display
- Relay pool configuration
- Basic UI with profile resolution
### Changed
- Migrate to applesauce-accounts for proper account management
- Use proper applesauce-loaders for NIP-51 bookmark fetching
- Optimize relay usage following applesauce-relay best practices
- Use applesauce-react event models for better profile handling
[0.3.5]: https://github.com/dergigi/boris/compare/v0.3.4...v0.3.5
[0.3.4]: https://github.com/dergigi/boris/compare/v0.3.3...v0.3.4
[0.3.3]: https://github.com/dergigi/boris/compare/v0.3.2...v0.3.3
[0.3.2]: https://github.com/dergigi/boris/compare/v0.3.1...v0.3.2
[0.3.1]: https://github.com/dergigi/boris/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/dergigi/boris/compare/v0.2.10...v0.3.0
[0.2.10]: https://github.com/dergigi/boris/compare/v0.2.9...v0.2.10
[0.2.9]: https://github.com/dergigi/boris/compare/v0.2.8...v0.2.9
[0.2.8]: https://github.com/dergigi/boris/compare/v0.2.7...v0.2.8
[0.2.7]: https://github.com/dergigi/boris/compare/v0.2.6...v0.2.7
[0.2.6]: https://github.com/dergigi/boris/compare/v0.2.5...v0.2.6
[0.2.5]: https://github.com/dergigi/boris/compare/v0.2.4...v0.2.5
[0.2.4]: https://github.com/dergigi/boris/compare/v0.2.3...v0.2.4
[0.2.3]: https://github.com/dergigi/boris/compare/v0.2.2...v0.2.3
[0.2.2]: https://github.com/dergigi/boris/compare/v0.2.1...v0.2.2
[0.2.1]: https://github.com/dergigi/boris/compare/v0.2.0...v0.2.1
[0.2.0]: https://github.com/dergigi/boris/compare/v0.1.11...v0.2.0
[0.1.11]: https://github.com/dergigi/boris/compare/v0.1.10...v0.1.11
[0.1.10]: https://github.com/dergigi/boris/compare/v0.1.9...v0.1.10
[0.1.9]: https://github.com/dergigi/boris/compare/v0.1.8...v0.1.9
[0.1.8]: https://github.com/dergigi/boris/compare/v0.1.7...v0.1.8
[0.1.7]: https://github.com/dergigi/boris/compare/v0.1.6...v0.1.7
[0.1.6]: https://github.com/dergigi/boris/compare/v0.1.5...v0.1.6
[0.1.5]: https://github.com/dergigi/boris/compare/v0.1.4...v0.1.5
[0.1.4]: https://github.com/dergigi/boris/compare/v0.1.3...v0.1.4
[0.1.3]: https://github.com/dergigi/boris/compare/v0.1.2...v0.1.3
[0.1.2]: https://github.com/dergigi/boris/compare/v0.1.1...v0.1.2
[0.1.1]: https://github.com/dergigi/boris/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/dergigi/boris/compare/v0.0.3...v0.1.0
[0.0.3]: https://github.com/dergigi/boris/compare/v0.0.2...v0.0.3
[0.0.2]: https://github.com/dergigi/boris/compare/v0.0.1...v0.0.2
[0.0.1]: https://github.com/dergigi/boris/releases/tag/v0.0.1

View File

@@ -6,7 +6,7 @@ Boris turns your Nostr bookmarks into a calm, fast, and focused reading experien
## Live
- App: [https://xn--bris-v0b.com/](https://xn--bris-v0b.com/)
- App: [https://read.withboris.com/](https://read.withboris.com/)
## The Vision
@@ -35,6 +35,7 @@ If you bookmark something on nostr, Boris will show it in the bookmarks bar. If
- Collects your saved links from Nostr and shows them as a tidy reading list
- Opens articles in a distractionfree reader with clear typography
- Shows community highlights layered on the article (yours, friends, everyone)
- Splits zaps between you and the author(s) when you highlight
- Lets you collapse sidebars anytime for fullfocus reading
- Remembers simple preferences like view mode, fonts, and highlight style

View File

@@ -6,18 +6,18 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Boris - Nostr Bookmarks</title>
<meta name="description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
<link rel="canonical" href="https://xn--bris-v0b.com/" />
<link rel="canonical" href="https://read.withboris.com/" />
<!-- Open Graph / Social Media -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://xn--bris-v0b.com/" />
<meta property="og:url" content="https://read.withboris.com/" />
<meta property="og:title" content="Boris - Nostr Bookmarks" />
<meta property="og:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
<meta property="og:site_name" content="Boris" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="https://xn--bris-v0b.com/" />
<meta name="twitter:url" content="https://read.withboris.com/" />
<meta name="twitter:title" content="Boris - Nostr Bookmarks" />
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
</head>

4
package-lock.json generated
View File

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

View File

@@ -1,8 +1,8 @@
{
"name": "boris",
"version": "0.2.5",
"version": "0.3.6",
"description": "A minimal nostr client for bookmark management",
"homepage": "https://xn--bris-v0b.com/",
"homepage": "https://read.withboris.com/",
"type": "module",
"scripts": {
"dev": "vite",

6
public/_routes.json Normal file
View File

@@ -0,0 +1,6 @@
{
"version": 1,
"include": ["/*"],
"exclude": ["/assets/*", "/robots.txt", "/sw.js", "/_headers", "/_redirects"]
}

View File

@@ -1,5 +1,5 @@
User-agent: *
Allow: /
Sitemap: https://xn--bris-v0b.com/sitemap.xml
Sitemap: https://read.withboris.com/sitemap.xml

56
public/sw.js Normal file
View File

@@ -0,0 +1,56 @@
// Service Worker for Boris - handles offline image caching
const CACHE_NAME = 'boris-image-cache-v1'
// Install event - activate immediately
self.addEventListener('install', (event) => {
console.log('[SW] Installing service worker...')
self.skipWaiting()
})
// Activate event - take control immediately
self.addEventListener('activate', (event) => {
console.log('[SW] Activating service worker...')
event.waitUntil(self.clients.claim())
})
// Fetch event - intercept image requests
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url)
// Only intercept image requests
const isImage = event.request.destination === 'image' ||
/\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url.pathname)
if (!isImage) {
return // Let other requests pass through
}
event.respondWith(
caches.open(CACHE_NAME).then(cache => {
return cache.match(event.request).then(cachedResponse => {
if (cachedResponse) {
console.log('[SW] Serving cached image:', url.pathname)
return cachedResponse
}
// Not in cache, try to fetch
return fetch(event.request)
.then(response => {
// Only cache successful responses
if (response && response.status === 200) {
// Clone the response before caching
cache.put(event.request, response.clone())
console.log('[SW] Cached new image:', url.pathname)
}
return response
})
.catch(error => {
console.error('[SW] Fetch failed for:', url.pathname, error)
// Return a fallback or let it fail
throw error
})
})
})
)
})

View File

@@ -52,6 +52,24 @@ function AppRoutes({
/>
}
/>
<Route
path="/settings"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route
path="/explore"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
</Routes>
)
@@ -109,6 +127,19 @@ function App() {
console.log('Created relay group with', RELAYS.length, 'relays (including local)')
console.log('Relay URLs:', RELAYS)
// Keep all relay connections alive indefinitely by creating a persistent subscription
// This prevents disconnection when no other subscriptions are active
// Create a minimal subscription that never completes to keep connections alive
const keepAliveSub = pool.subscription(RELAYS, { kinds: [0], limit: 0 }).subscribe({
next: () => {}, // No-op, we don't care about events
error: (err) => console.warn('Keep-alive subscription error:', err)
})
console.log('🔗 Created keep-alive subscription for', RELAYS.length, 'relay(s)')
// Store subscription for cleanup
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(pool as any)._keepAliveSubscription = keepAliveSub
// Attach address/replaceable loaders so ProfileModel can fetch profiles
const addressLoader = createAddressLoader(pool, {
eventStore: store,
@@ -125,6 +156,12 @@ function App() {
return () => {
accountsSub.unsubscribe()
activeSub.unsubscribe()
// Clean up keep-alive subscription if it exists
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((pool as any)._keepAliveSubscription) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(pool as any)._keepAliveSubscription.unsubscribe()
}
}
}

View File

@@ -0,0 +1,287 @@
import React, { useState, useEffect, useRef } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faTimes, faSpinner } from '@fortawesome/free-solid-svg-icons'
import IconButton from './IconButton'
import { fetchReadableContent } from '../services/readerService'
interface AddBookmarkModalProps {
onClose: () => void
onSave: (url: string, title?: string, description?: string, tags?: string[]) => Promise<void>
}
// Helper to extract metadata from HTML
function extractMetaTag(html: string, patterns: string[]): string | null {
for (const pattern of patterns) {
const match = html.match(new RegExp(pattern, 'i'))
if (match) return match[1]
}
return null
}
function extractTags(html: string): string[] {
const tags: string[] = []
// Extract keywords meta tag
const keywords = extractMetaTag(html, [
'<meta\\s+name=["\'"]keywords["\'"]\\s+content=["\'"]([^"\']+)["\']'
])
if (keywords) {
keywords.split(/[,;]/)
.map(k => k.trim().toLowerCase())
.filter(k => k.length > 0 && k.length < 30)
.forEach(k => tags.push(k))
}
// Extract article:tag (multiple possible)
const articleTagRegex = /<meta\s+property=["']article:tag["']\s+content=["']([^"']+)["']/gi
let match
while ((match = articleTagRegex.exec(html)) !== null) {
const tag = match[1].trim().toLowerCase()
if (tag && tag.length < 30) tags.push(tag)
}
return Array.from(new Set(tags)).slice(0, 5)
}
const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave }) => {
const [url, setUrl] = useState('')
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [tagsInput, setTagsInput] = useState('')
const [isSaving, setIsSaving] = useState(false)
const [isFetchingMetadata, setIsFetchingMetadata] = useState(false)
const [error, setError] = useState<string | null>(null)
const fetchTimeoutRef = useRef<number | null>(null)
const lastFetchedUrlRef = useRef<string>('')
// Fetch metadata when URL changes
useEffect(() => {
// Clear any pending fetch
if (fetchTimeoutRef.current) {
clearTimeout(fetchTimeoutRef.current)
}
// Don't fetch if URL is empty or invalid
if (!url.trim()) return
// Validate URL format first
let parsedUrl: URL
try {
parsedUrl = new URL(url.trim())
} catch {
return // Invalid URL, don't fetch
}
// Skip if we've already fetched this URL
const normalizedUrl = parsedUrl.toString()
if (lastFetchedUrlRef.current === normalizedUrl) {
return
}
// Debounce the fetch to avoid spamming the API
fetchTimeoutRef.current = window.setTimeout(async () => {
setIsFetchingMetadata(true)
try {
const content = await fetchReadableContent(normalizedUrl)
lastFetchedUrlRef.current = normalizedUrl
let extractedAnything = false
// Extract title: prioritize og:title > twitter:title > <title>
if (!title && content.html) {
const extractedTitle = extractMetaTag(content.html, [
'<meta\\s+property=["\'"]og:title["\'"]\\s+content=["\'"]([^"\']+)["\']',
'<meta\\s+name=["\'"]twitter:title["\'"]\\s+content=["\'"]([^"\']+)["\']'
]) || content.title
if (extractedTitle) {
setTitle(extractedTitle)
extractedAnything = true
}
}
// Extract description: prioritize og:description > twitter:description > meta description
if (!description && content.html) {
const extractedDesc = extractMetaTag(content.html, [
'<meta\\s+property=["\'"]og:description["\'"]\\s+content=["\'"]([^"\']+)["\']',
'<meta\\s+name=["\'"]twitter:description["\'"]\\s+content=["\'"]([^"\']+)["\']',
'<meta\\s+name=["\'"]description["\'"]\\s+content=["\'"]([^"\']+)["\']'
])
if (extractedDesc) {
setDescription(extractedDesc)
extractedAnything = true
}
}
// Extract tags from keywords and article:tag (only if user hasn't modified tags)
if (!tagsInput && content.html) {
const extractedTags = extractTags(content.html)
// Only add boris tag if we extracted something
if (extractedAnything || extractedTags.length > 0) {
const allTags = extractedTags.length > 0
? ['boris', ...extractedTags]
: ['boris']
setTagsInput(allTags.join(', '))
}
}
} catch (err) {
console.warn('Failed to fetch metadata:', err)
// Don't show error to user, just skip auto-fill
} finally {
setIsFetchingMetadata(false)
}
}, 800) // Wait 800ms after user stops typing
return () => {
if (fetchTimeoutRef.current) {
clearTimeout(fetchTimeoutRef.current)
}
}
}, [url]) // Only depend on url
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
if (!url.trim()) {
setError('URL is required')
return
}
// Validate URL
try {
new URL(url)
} catch {
setError('Please enter a valid URL')
return
}
try {
setIsSaving(true)
// Parse tags from comma-separated input
const tags = tagsInput
.split(',')
.map(tag => tag.trim())
.filter(tag => tag.length > 0)
await onSave(
url.trim(),
title.trim() || undefined,
description.trim() || undefined,
tags.length > 0 ? tags : undefined
)
onClose()
} catch (err) {
console.error('Failed to save bookmark:', err)
setError(err instanceof Error ? err.message : 'Failed to save bookmark')
} finally {
setIsSaving(false)
}
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Add Bookmark</h2>
<IconButton
icon={faTimes}
onClick={onClose}
title="Close"
ariaLabel="Close modal"
variant="ghost"
/>
</div>
<form onSubmit={handleSubmit} className="modal-form">
<div className="form-group">
<label htmlFor="bookmark-url">
URL *
{isFetchingMetadata && (
<span className="fetching-indicator">
<FontAwesomeIcon icon={faSpinner} spin /> Fetching details...
</span>
)}
</label>
<input
id="bookmark-url"
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com"
disabled={isSaving}
autoFocus
/>
</div>
<div className="form-group">
<label htmlFor="bookmark-title">Title</label>
<input
id="bookmark-title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Optional title"
disabled={isSaving}
/>
</div>
<div className="form-group">
<label htmlFor="bookmark-description">Description</label>
<textarea
id="bookmark-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional description"
disabled={isSaving}
rows={3}
/>
</div>
<div className="form-group">
<label htmlFor="bookmark-tags">Tags</label>
<input
id="bookmark-tags"
type="text"
value={tagsInput}
onChange={(e) => setTagsInput(e.target.value)}
placeholder="comma, separated, tags"
disabled={isSaving}
/>
<div className="form-helper-text">
Separate tags with commas (e.g., "nostr, web3, article")
</div>
</div>
{error && (
<div className="modal-error">{error}</div>
)}
<div className="modal-actions">
<button
type="button"
onClick={onClose}
className="btn-secondary"
disabled={isSaving}
>
Cancel
</button>
<button
type="submit"
className="btn-primary"
disabled={isSaving}
>
{isSaving ? 'Saving...' : 'Save Bookmark'}
</button>
</div>
</form>
</div>
</div>
)
}
export default AddBookmarkModal

View File

@@ -0,0 +1,61 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCalendar, faUser } from '@fortawesome/free-solid-svg-icons'
import { formatDistance } from 'date-fns'
import { BlogPostPreview } from '../services/exploreService'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
interface BlogPostCardProps {
post: BlogPostPreview
href: string
}
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href }) => {
const profile = useEventModel(Models.ProfileModel, [post.author])
const displayName = profile?.name || profile?.display_name ||
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
const publishedDate = post.published || post.event.created_at
const formattedDate = formatDistance(new Date(publishedDate * 1000), new Date(), {
addSuffix: true
})
return (
<Link
to={href}
className="blog-post-card"
style={{ textDecoration: 'none', color: 'inherit' }}
>
{post.image && (
<div className="blog-post-card-image">
<img
src={post.image}
alt={post.title}
loading="lazy"
/>
</div>
)}
<div className="blog-post-card-content">
<h3 className="blog-post-card-title">{post.title}</h3>
{post.summary && (
<p className="blog-post-card-summary">{post.summary}</p>
)}
<div className="blog-post-card-meta">
<span className="blog-post-card-author">
<FontAwesomeIcon icon={faUser} />
{displayName}
</span>
<span className="blog-post-card-date">
<FontAwesomeIcon icon={faCalendar} />
{formattedDate}
</span>
</div>
</div>
</Link>
)
}
export default BlogPostCard

View File

@@ -11,15 +11,17 @@ import { getPreviewImage, fetchOgImage } from '../utils/imagePreview'
import { CompactView } from './BookmarkViews/CompactView'
import { LargeView } from './BookmarkViews/LargeView'
import { CardView } from './BookmarkViews/CardView'
import { UserSettings } from '../services/settingsService'
interface BookmarkItemProps {
bookmark: IndividualBookmark
index: number
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
viewMode?: ViewMode
settings?: UserSettings
}
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards' }) => {
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards', settings }) => {
const [ogImage, setOgImage] = useState<string | null>(null)
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
@@ -115,7 +117,8 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
getAuthorDisplayName,
handleReadNow,
articleImage,
articleSummary
articleSummary,
settings
}
if (viewMode === 'compact') {

View File

@@ -1,12 +1,15 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage, faRotate } from '@fortawesome/free-solid-svg-icons'
import { formatDistanceToNow } from 'date-fns'
import { RelayPool } from 'applesauce-relay'
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
import { BookmarkItem } from './BookmarkItem'
import SidebarHeader from './SidebarHeader'
import IconButton from './IconButton'
import { ViewMode } from './Bookmarks'
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
import { UserSettings } from '../services/settingsService'
interface BookmarkListProps {
bookmarks: Bookmark[]
@@ -20,7 +23,10 @@ interface BookmarkListProps {
onOpenSettings: () => void
onRefresh?: () => void
isRefreshing?: boolean
lastFetchTime?: number | null
loading?: boolean
relayPool: RelayPool | null
settings?: UserSettings
}
export const BookmarkList: React.FC<BookmarkListProps> = ({
@@ -35,7 +41,10 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
onOpenSettings,
onRefresh,
isRefreshing,
loading = false
lastFetchTime,
loading = false,
relayPool,
settings
}) => {
// Helper to check if a bookmark has either content or a URL
const hasContentOrUrl = (ib: IndividualBookmark) => {
@@ -96,8 +105,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
onToggleCollapse={onToggleCollapse}
onLogout={onLogout}
onOpenSettings={onOpenSettings}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
relayPool={relayPool}
/>
{loading ? (
@@ -119,9 +127,38 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
index={index}
onSelectUrl={onSelectUrl}
viewMode={viewMode}
settings={settings}
/>
)}
</div>
{onRefresh && (
<div className="refresh-section" style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
padding: '1rem',
marginTop: '1rem',
borderTop: '1px solid var(--border-color)',
fontSize: '0.85rem',
color: 'var(--text-secondary)'
}}>
<IconButton
icon={faRotate}
onClick={onRefresh}
title="Refresh bookmarks"
ariaLabel="Refresh bookmarks"
variant="ghost"
disabled={isRefreshing}
spin={isRefreshing}
/>
{lastFetchTime && (
<span>
Updated {formatDistanceToNow(lastFetchTime, { addSuffix: true })}
</span>
)}
</div>
)}
</div>
)}
<div className="view-mode-controls">

View File

@@ -7,6 +7,8 @@ import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
import IconButton from '../IconButton'
import { classifyUrl } from '../../utils/helpers'
import { IconGetter } from './shared'
import { useImageCache } from '../../hooks/useImageCache'
import { UserSettings } from '../../services/settingsService'
interface CardViewProps {
bookmark: IndividualBookmark
@@ -22,6 +24,7 @@ interface CardViewProps {
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
articleImage?: string
articleSummary?: string
settings?: UserSettings
}
export const CardView: React.FC<CardViewProps> = ({
@@ -37,8 +40,10 @@ export const CardView: React.FC<CardViewProps> = ({
getAuthorDisplayName,
handleReadNow,
articleImage,
articleSummary
articleSummary,
settings
}) => {
const cachedImage = useImageCache(articleImage, settings)
const [expanded, setExpanded] = useState(false)
const [urlsExpanded, setUrlsExpanded] = useState(false)
const contentLength = (bookmark.content || '').length
@@ -48,10 +53,10 @@ export const CardView: React.FC<CardViewProps> = ({
return (
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
{isArticle && articleImage && (
{isArticle && cachedImage && (
<div
className="article-hero-image"
style={{ backgroundImage: `url(${articleImage})` }}
style={{ backgroundImage: `url(${cachedImage})` }}
onClick={() => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)}
/>
)}

View File

@@ -2,7 +2,7 @@ import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBookmark, faUserLock, faGlobe } from '@fortawesome/free-solid-svg-icons'
import { IndividualBookmark } from '../../types/bookmarks'
import { formatDate } from '../../utils/bookmarkUtils'
import { formatDateCompact } from '../../utils/bookmarkUtils'
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
import { IconGetter } from './shared'
@@ -75,7 +75,7 @@ export const CompactView: React.FC<CompactViewProps> = ({
<ContentWithResolvedProfiles content={displayText.slice(0, 60) + (displayText.length > 60 ? '…' : '')} />
</div>
)}
<span className="bookmark-date-compact">{formatDate(bookmark.created_at)}</span>
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at)}</span>
{isClickable && (
<button
className="compact-read-btn"

View File

@@ -4,6 +4,8 @@ import { IndividualBookmark } from '../../types/bookmarks'
import { formatDate } from '../../utils/bookmarkUtils'
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
import { IconGetter } from './shared'
import { useImageCache } from '../../hooks/useImageCache'
import { UserSettings } from '../../services/settingsService'
interface LargeViewProps {
bookmark: IndividualBookmark
@@ -19,6 +21,7 @@ interface LargeViewProps {
getAuthorDisplayName: () => string
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
articleSummary?: string
settings?: UserSettings
}
export const LargeView: React.FC<LargeViewProps> = ({
@@ -34,13 +37,15 @@ export const LargeView: React.FC<LargeViewProps> = ({
eventNevent,
getAuthorDisplayName,
handleReadNow,
articleSummary
articleSummary,
settings
}) => {
const cachedImage = useImageCache(previewImage || undefined, settings)
const isArticle = bookmark.kind === 30023
return (
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark large ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
{(hasUrls || (isArticle && previewImage)) && (
{(hasUrls || (isArticle && cachedImage)) && (
<div
className="large-preview-image"
onClick={() => {
@@ -50,7 +55,7 @@ export const LargeView: React.FC<LargeViewProps> = ({
onSelectUrl?.(extractedUrls[0])
}
}}
style={previewImage ? { backgroundImage: `url(${previewImage})` } : undefined}
style={cachedImage ? { backgroundImage: `url(${cachedImage})` } : undefined}
>
{!previewImage && hasUrls && (
<div className="preview-placeholder">

View File

@@ -1,5 +1,5 @@
import React, { useMemo } from 'react'
import { useParams, useLocation } from 'react-router-dom'
import React, { useMemo, useEffect, useRef } from 'react'
import { useParams, useLocation, useNavigate } from 'react-router-dom'
import { Hooks } from 'applesauce-react'
import { useEventStore } from 'applesauce-react/hooks'
import { RelayPool } from 'applesauce-relay'
@@ -10,7 +10,10 @@ import { useBookmarksData } from '../hooks/useBookmarksData'
import { useContentSelection } from '../hooks/useContentSelection'
import { useHighlightCreation } from '../hooks/useHighlightCreation'
import { useBookmarksUI } from '../hooks/useBookmarksUI'
import { useRelayStatus } from '../hooks/useRelayStatus'
import { useOfflineSync } from '../hooks/useOfflineSync'
import ThreePaneLayout from './ThreePaneLayout'
import Explore from './Explore'
import { classifyHighlights } from '../utils/highlightClassification'
export type ViewMode = 'compact' | 'cards' | 'large'
@@ -23,10 +26,22 @@ interface BookmarksProps {
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
const { naddr } = useParams<{ naddr?: string }>()
const location = useLocation()
const navigate = useNavigate()
const previousLocationRef = useRef<string>()
const externalUrl = location.pathname.startsWith('/r/')
? decodeURIComponent(location.pathname.slice(3))
: undefined
const showSettings = location.pathname === '/settings'
const showExplore = location.pathname === '/explore'
// Track previous location for going back from settings
useEffect(() => {
if (!showSettings) {
previousLocationRef.current = location.pathname
}
}, [location.pathname, showSettings])
const activeAccount = Hooks.useActiveAccount()
const accountManager = Hooks.useAccountManager()
@@ -39,6 +54,18 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
accountManager
})
// Monitor relay status for offline sync
const relayStatuses = useRelayStatus({ relayPool })
// Automatically sync local events to remote relays when coming back online
useOfflineSync({
relayPool,
account: activeAccount || null,
eventStore,
relayStatuses,
enabled: true
})
const {
isCollapsed,
setIsCollapsed,
@@ -50,8 +77,6 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
setShowHighlights,
selectedHighlightId,
setSelectedHighlightId,
showSettings,
setShowSettings,
currentArticleCoordinate,
setCurrentArticleCoordinate,
currentArticleEventId,
@@ -71,6 +96,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
setHighlightsLoading,
followedPubkeys,
isRefreshing,
lastFetchTime,
handleFetchHighlights,
handleRefreshAll
} = useBookmarksData({
@@ -79,7 +105,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
accountManager,
naddr,
currentArticleCoordinate,
currentArticleEventId
currentArticleEventId,
settings
})
const {
@@ -94,7 +121,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
relayPool,
settings,
setIsCollapsed,
setShowSettings,
setShowSettings: () => {}, // No-op since we use route-based settings now
setCurrentArticle
})
@@ -106,10 +133,12 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
} = useHighlightCreation({
activeAccount,
relayPool,
eventStore,
currentArticle,
selectedUrl,
readerContent,
onHighlightCreated: (highlight) => setHighlights(prev => [highlight, ...prev])
onHighlightCreated: (highlight) => setHighlights(prev => [highlight, ...prev]),
settings
})
// Load nostr-native article if naddr is in URL
@@ -124,7 +153,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
setHighlightsLoading,
setCurrentArticleCoordinate,
setCurrentArticleEventId,
setCurrentArticle
setCurrentArticle,
settings
})
// Load external URL if /r/* route is used
@@ -151,25 +181,33 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
isCollapsed={isCollapsed}
isHighlightsCollapsed={isHighlightsCollapsed}
showSettings={showSettings}
showExplore={showExplore}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
viewMode={viewMode}
isRefreshing={isRefreshing}
lastFetchTime={lastFetchTime}
onToggleSidebar={() => setIsCollapsed(!isCollapsed)}
onLogout={onLogout}
onViewModeChange={setViewMode}
onOpenSettings={() => {
setShowSettings(true)
navigate('/settings')
setIsCollapsed(true)
setIsHighlightsCollapsed(true)
}}
onRefresh={handleRefreshAll}
relayPool={relayPool}
eventStore={eventStore}
readerLoading={readerLoading}
readerContent={readerContent}
selectedUrl={selectedUrl}
settings={settings}
onSaveSettings={saveSettings}
onCloseSettings={() => setShowSettings(false)}
onCloseSettings={() => {
// Navigate back to previous location or default
const backTo = previousLocationRef.current || '/'
navigate(backTo)
}}
classifiedHighlights={classifiedHighlights}
showHighlights={showHighlights}
selectedHighlightId={selectedHighlightId}
@@ -192,6 +230,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
highlightButtonRef={highlightButtonRef}
onCreateHighlight={handleCreateHighlight}
hasActiveAccount={!!(activeAccount && relayPool)}
explore={showExplore ? (
relayPool ? <Explore relayPool={relayPool} /> : null
) : undefined}
toastMessage={toastMessage ?? undefined}
toastType={toastType}
onClearToast={clearToast}

View File

@@ -11,6 +11,7 @@ import { HighlightVisibility } from './HighlightsPanel'
import { useMarkdownToHTML } from '../hooks/useMarkdownToHTML'
import { useHighlightedContent } from '../hooks/useHighlightedContent'
import { useHighlightInteractions } from '../hooks/useHighlightInteractions'
import { UserSettings } from '../services/settingsService'
interface ContentPanelProps {
loading: boolean
@@ -19,6 +20,8 @@ interface ContentPanelProps {
markdown?: string
selectedUrl?: string
image?: string
summary?: string
published?: number
highlights?: Highlight[]
showHighlights?: boolean
highlightStyle?: 'marker' | 'underline'
@@ -28,6 +31,7 @@ interface ContentPanelProps {
highlightVisibility?: HighlightVisibility
currentUserPubkey?: string
followedPubkeys?: Set<string>
settings?: UserSettings
// For highlight creation
onTextSelection?: (text: string) => void
onClearSelection?: () => void
@@ -40,10 +44,13 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
markdown,
selectedUrl,
image,
summary,
published,
highlights = [],
showHighlights = true,
highlightStyle = 'marker',
highlightColor = '#ffff00',
settings,
onHighlightClick,
selectedHighlightId,
highlightVisibility = { nostrverse: true, friends: true, mine: true },
@@ -117,9 +124,12 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
<ReaderHeader
title={title}
image={image}
summary={summary}
published={published}
readingTimeText={readingStats ? readingStats.text : null}
hasHighlights={hasHighlights}
highlightCount={relevantHighlights.length}
settings={settings}
/>
{markdown || html ? (
markdown ? (

129
src/components/Explore.tsx Normal file
View File

@@ -0,0 +1,129 @@
import React, { useState, useEffect } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner, faExclamationCircle, faNewspaper } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools'
import { fetchContacts } from '../services/contactService'
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
import BlogPostCard from './BlogPostCard'
interface ExploreProps {
relayPool: RelayPool
}
const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
const activeAccount = Hooks.useActiveAccount()
const [blogPosts, setBlogPosts] = useState<BlogPostPreview[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const loadBlogPosts = async () => {
if (!activeAccount) {
setError('Please log in to explore content from your friends')
setLoading(false)
return
}
try {
setLoading(true)
setError(null)
// Fetch the user's contacts (friends)
const contacts = await fetchContacts(relayPool, activeAccount.pubkey)
if (contacts.size === 0) {
setError('You are not following anyone yet. Follow some people to see their blog posts!')
setLoading(false)
return
}
// Get relay URLs from pool
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
// Fetch blog posts from friends
const posts = await fetchBlogPostsFromAuthors(
relayPool,
Array.from(contacts),
relayUrls
)
if (posts.length === 0) {
setError('No blog posts found from your friends yet')
}
setBlogPosts(posts)
} catch (err) {
console.error('Failed to load blog posts:', err)
setError('Failed to load blog posts. Please try again.')
} finally {
setLoading(false)
}
}
loadBlogPosts()
}, [relayPool, activeAccount])
const getPostUrl = (post: BlogPostPreview) => {
// Get the d-tag identifier
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
// Create naddr
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: post.author,
identifier: dTag
})
return `/a/${naddr}`
}
if (loading) {
return (
<div className="explore-container">
<div className="explore-loading">
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
<p>Loading blog posts from your friends...</p>
</div>
</div>
)
}
if (error) {
return (
<div className="explore-container">
<div className="explore-error">
<FontAwesomeIcon icon={faExclamationCircle} size="2x" />
<p>{error}</p>
</div>
</div>
)
}
return (
<div className="explore-container">
<div className="explore-header">
<h1>
<FontAwesomeIcon icon={faNewspaper} />
Explore
</h1>
<p className="explore-subtitle">
Discover blog posts from your friends on Nostr
</p>
</div>
<div className="explore-grid">
{blogPosts.map((post) => (
<BlogPostCard
key={`${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1]}`}
post={post}
href={getPostUrl(post)}
/>
))}
</div>
</div>
)
}
export default Explore

View File

@@ -1,10 +1,15 @@
import React, { useEffect, useRef } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faQuoteLeft, faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons'
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faServer } from '@fortawesome/free-solid-svg-icons'
import { Highlight } from '../types/highlights'
import { formatDistanceToNow } from 'date-fns'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import { Models, IEventStore } from 'applesauce-core'
import { RelayPool } from 'applesauce-relay'
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
import { RELAYS } from '../config/relays'
import { areAllRelaysLocal } from '../utils/helpers'
import { nip19 } from 'nostr-tools'
import { formatDateCompact } from '../utils/bookmarkUtils'
interface HighlightWithLevel extends Highlight {
level?: 'mine' | 'friends' | 'nostrverse'
@@ -15,10 +20,24 @@ interface HighlightItemProps {
onSelectUrl?: (url: string) => void
isSelected?: boolean
onHighlightClick?: (highlightId: string) => void
relayPool?: RelayPool | null
eventStore?: IEventStore | null
onHighlightUpdate?: (highlight: Highlight) => void
}
export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelectUrl, isSelected, onHighlightClick }) => {
export const HighlightItem: React.FC<HighlightItemProps> = ({
highlight,
onSelectUrl,
isSelected,
onHighlightClick,
relayPool,
eventStore,
onHighlightUpdate
}) => {
const itemRef = useRef<HTMLDivElement>(null)
const [isSyncing, setIsSyncing] = useState(() => isEventSyncing(highlight.id))
const [showOfflineIndicator, setShowOfflineIndicator] = useState(() => highlight.isOfflineCreated && !isSyncing)
const [isRebroadcasting, setIsRebroadcasting] = useState(false)
// Resolve the profile of the user who made the highlight
const profile = useEventModel(Models.ProfileModel, [highlight.pubkey])
@@ -30,6 +49,39 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelec
return `${highlight.pubkey.slice(0, 8)}...` // fallback to short pubkey
}
// Update offline indicator when highlight prop changes
useEffect(() => {
if (highlight.isOfflineCreated && !isSyncing) {
setShowOfflineIndicator(true)
}
}, [highlight.isOfflineCreated, isSyncing])
// Listen to sync state changes
useEffect(() => {
const unsubscribe = onSyncStateChange((eventId, syncingState) => {
if (eventId === highlight.id) {
setIsSyncing(syncingState)
// When sync completes successfully, update highlight to show all relays
if (!syncingState) {
setShowOfflineIndicator(false)
// Update the highlight with all relays after successful sync
if (onHighlightUpdate && highlight.isLocalOnly) {
const updatedHighlight = {
...highlight,
publishedRelays: RELAYS,
isLocalOnly: false,
isOfflineCreated: false
}
onHighlightUpdate(updatedHighlight)
}
}
}
})
return unsubscribe
}, [highlight, onHighlightUpdate])
useEffect(() => {
if (isSelected && itemRef.current) {
itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' })
@@ -51,13 +103,146 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelec
const getSourceLink = () => {
if (highlight.eventReference) {
return `https://search.dergigi.com/e/${highlight.eventReference}`
// Check if it's a coordinate string (kind:pubkey:identifier) or a simple event ID
if (highlight.eventReference.includes(':')) {
// It's an addressable event coordinate, encode as naddr
const parts = highlight.eventReference.split(':')
if (parts.length === 3) {
const [kindStr, pubkey, identifier] = parts
const kind = parseInt(kindStr, 10)
// Get non-local relays for the hint
const relayHints = RELAYS.filter(r =>
!r.includes('localhost') && !r.includes('127.0.0.1')
).slice(0, 3) // Include up to 3 relay hints
const naddr = nip19.naddrEncode({
kind,
pubkey,
identifier,
relays: relayHints
})
return `https://njump.me/${naddr}`
}
} else {
// It's a simple event ID, encode as nevent
// Get non-local relays for the hint
const relayHints = RELAYS.filter(r =>
!r.includes('localhost') && !r.includes('127.0.0.1')
).slice(0, 3) // Include up to 3 relay hints
const nevent = nip19.neventEncode({
id: highlight.eventReference,
relays: relayHints,
author: highlight.author
})
return `https://njump.me/${nevent}`
}
}
return highlight.urlReference
}
const sourceLink = getSourceLink()
// Handle rebroadcast to all relays
const handleRebroadcast = async (e: React.MouseEvent) => {
e.stopPropagation() // Prevent triggering highlight selection
if (!relayPool || !eventStore || isRebroadcasting) return
setIsRebroadcasting(true)
try {
// Get the event from the event store
const event = eventStore.getEvent(highlight.id)
if (!event) {
console.error('Event not found in store:', highlight.id)
return
}
// Publish to all configured relays - let the relay pool handle connection state
const targetRelays = RELAYS
console.log('📡 Rebroadcasting highlight to', targetRelays.length, 'relay(s):', targetRelays)
await relayPool.publish(targetRelays, event)
console.log('✅ Rebroadcast successful!')
// Update the highlight with new relay info
const isLocalOnly = areAllRelaysLocal(targetRelays)
const updatedHighlight = {
...highlight,
publishedRelays: targetRelays,
isLocalOnly,
isOfflineCreated: false
}
// Notify parent of the update
if (onHighlightUpdate) {
onHighlightUpdate(updatedHighlight)
}
// Update local state
setShowOfflineIndicator(false)
} catch (error) {
console.error('❌ Failed to rebroadcast:', error)
} finally {
setIsRebroadcasting(false)
}
}
// Determine relay indicator icon and tooltip
const getRelayIndicatorInfo = () => {
// Show spinner if manually rebroadcasting OR auto-syncing
if (isRebroadcasting || isSyncing) {
return {
icon: faSpinner,
tooltip: isRebroadcasting ? 'rebroadcasting...' : 'syncing...',
spin: true
}
}
// Always show relay list, use plane icon for local-only
const isLocalOrOffline = highlight.isLocalOnly || showOfflineIndicator
// Show server icon with relay info if available
if (highlight.publishedRelays && highlight.publishedRelays.length > 0) {
const relayNames = highlight.publishedRelays.map(url =>
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
)
return {
icon: isLocalOrOffline ? faPlane : faServer,
tooltip: relayNames.join('\n'),
spin: false
}
}
if (highlight.seenOnRelays && highlight.seenOnRelays.length > 0) {
const relayNames = highlight.seenOnRelays.map(url =>
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
)
return {
icon: faServer,
tooltip: relayNames.join('\n'),
spin: false
}
}
// Fallback: show all relays we queried (where this was likely fetched from)
const relayNames = RELAYS.map(url =>
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
)
return {
icon: faServer,
tooltip: relayNames.join('\n'),
spin: false
}
}
const relayIndicator = getRelayIndicatorInfo()
return (
<div
ref={itemRef}
@@ -68,6 +253,16 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelec
>
<div className="highlight-quote-icon">
<FontAwesomeIcon icon={faQuoteLeft} />
{relayIndicator && (
<div
className="highlight-relay-indicator"
title={relayIndicator.tooltip}
onClick={handleRebroadcast}
style={{ cursor: relayPool && eventStore ? 'pointer' : 'default' }}
>
<FontAwesomeIcon icon={relayIndicator.icon} spin={relayIndicator.spin} />
</div>
)}
</div>
<div className="highlight-content">
@@ -88,7 +283,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelec
</span>
<span className="highlight-meta-separator"></span>
<span className="highlight-time">
{formatDistanceToNow(new Date(highlight.created_at * 1000), { addSuffix: true })}
{formatDateCompact(highlight.created_at)}
</span>
{sourceLink && (

View File

@@ -6,6 +6,8 @@ import { HighlightItem } from './HighlightItem'
import { useFilteredHighlights } from '../hooks/useFilteredHighlights'
import HighlightsPanelCollapsed from './HighlightsPanel/HighlightsPanelCollapsed'
import HighlightsPanelHeader from './HighlightsPanel/HighlightsPanelHeader'
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
export interface HighlightVisibility {
nostrverse: boolean
@@ -28,6 +30,8 @@ interface HighlightsPanelProps {
highlightVisibility?: HighlightVisibility
onHighlightVisibilityChange?: (visibility: HighlightVisibility) => void
followedPubkeys?: Set<string>
relayPool?: RelayPool | null
eventStore?: IEventStore | null
}
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
@@ -44,9 +48,12 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
currentUserPubkey,
highlightVisibility = { nostrverse: true, friends: true, mine: true },
onHighlightVisibilityChange,
followedPubkeys = new Set()
followedPubkeys = new Set(),
relayPool,
eventStore
}) => {
const [showHighlights, setShowHighlights] = useState(true)
const [localHighlights, setLocalHighlights] = useState(highlights)
const handleToggleHighlights = () => {
const newValue = !showHighlights
@@ -54,8 +61,19 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
onToggleHighlights?.(newValue)
}
// Keep track of highlight updates
React.useEffect(() => {
setLocalHighlights(highlights)
}, [highlights])
const handleHighlightUpdate = (updatedHighlight: Highlight) => {
setLocalHighlights(prev =>
prev.map(h => h.id === updatedHighlight.id ? updatedHighlight : h)
)
}
const filteredHighlights = useFilteredHighlights({
highlights,
highlights: localHighlights,
selectedUrl,
highlightVisibility,
currentUserPubkey,
@@ -108,6 +126,9 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
onSelectUrl={onSelectUrl}
isSelected={highlight.id === selectedHighlightId}
onHighlightClick={onHighlightClick}
relayPool={relayPool}
eventStore={eventStore}
onHighlightUpdate={handleHighlightUpdate}
/>
))}
</div>

View File

@@ -1,32 +1,77 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faClock } from '@fortawesome/free-solid-svg-icons'
import { format } from 'date-fns'
import { useImageCache } from '../hooks/useImageCache'
import { UserSettings } from '../services/settingsService'
interface ReaderHeaderProps {
title?: string
image?: string
summary?: string
published?: number
readingTimeText?: string | null
hasHighlights: boolean
highlightCount: number
settings?: UserSettings
}
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
title,
image,
summary,
published,
readingTimeText,
hasHighlights,
highlightCount
highlightCount,
settings
}) => {
const cachedImage = useImageCache(image, settings)
const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null
if (cachedImage) {
return (
<div className="reader-hero-image">
<img src={cachedImage} alt={title || 'Article image'} />
{formattedDate && (
<div className="publish-date-topright">
{formattedDate}
</div>
)}
{title && (
<div className="reader-header-overlay">
<h2 className="reader-title">{title}</h2>
{summary && <p className="reader-summary">{summary}</p>}
<div className="reader-meta">
{readingTimeText && (
<div className="reading-time">
<FontAwesomeIcon icon={faClock} />
<span>{readingTimeText}</span>
</div>
)}
{hasHighlights && (
<div className="highlight-indicator">
<FontAwesomeIcon icon={faHighlighter} />
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
</div>
)}
</div>
</div>
)}
</div>
)
}
return (
<>
{image && (
<div className="reader-hero-image">
<img src={image} alt={title || 'Article image'} />
</div>
)}
{title && (
<div className="reader-header">
{formattedDate && (
<div className="publish-date-topright">
{formattedDate}
</div>
)}
<h2 className="reader-title">{title}</h2>
{summary && <p className="reader-summary">{summary}</p>}
<div className="reader-meta">
{readingTimeText && (
<div className="reading-time">

View File

@@ -0,0 +1,94 @@
import React, { useState, useEffect } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faPlane, faGlobe, faCircle, faSpinner } from '@fortawesome/free-solid-svg-icons'
import { RelayPool } from 'applesauce-relay'
import { useRelayStatus } from '../hooks/useRelayStatus'
import { isLocalRelay } from '../utils/helpers'
interface RelayStatusIndicatorProps {
relayPool: RelayPool | null
}
export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ relayPool }) => {
// Poll frequently for responsive offline indicator (5s instead of default 20s)
const relayStatuses = useRelayStatus({ relayPool, pollingInterval: 5000 })
const [isConnecting, setIsConnecting] = useState(true)
if (!relayPool) return null
// Get currently connected relays
const connectedRelays = relayStatuses.filter(r => r.isInPool)
const connectedUrls = connectedRelays.map(r => r.url)
// Determine connection status
const hasLocalRelay = connectedUrls.some(url => isLocalRelay(url))
const hasRemoteRelay = connectedUrls.some(url => !isLocalRelay(url))
const localOnlyMode = hasLocalRelay && !hasRemoteRelay
const offlineMode = connectedUrls.length === 0
// Show "Connecting" for first few seconds or until relays connect
useEffect(() => {
if (connectedUrls.length > 0) {
// Connected! Stop showing connecting state
setIsConnecting(false)
} else {
// No connections yet - show connecting for 8 seconds
setIsConnecting(true)
const timeout = setTimeout(() => {
setIsConnecting(false)
}, 8000)
return () => clearTimeout(timeout)
}
}, [connectedUrls.length])
// Debug logging
useEffect(() => {
console.log('🔌 Relay Status Indicator:', {
mode: isConnecting ? 'CONNECTING' : offlineMode ? 'OFFLINE' : localOnlyMode ? 'LOCAL_ONLY' : 'ONLINE',
totalStatuses: relayStatuses.length,
connectedCount: connectedUrls.length,
connectedUrls: connectedUrls.map(u => u.replace(/^wss?:\/\//, '')),
hasLocalRelay,
hasRemoteRelay,
isConnecting
})
}, [offlineMode, localOnlyMode, connectedUrls.length, relayStatuses.length, hasLocalRelay, hasRemoteRelay, isConnecting])
// Don't show indicator when fully connected (but show when connecting)
if (!localOnlyMode && !offlineMode && !isConnecting) return null
return (
<div className={`relay-status-indicator ${isConnecting ? 'connecting' : ''}`} title={
isConnecting
? 'Connecting to relays...'
: offlineMode
? 'Offline - No relays connected'
: 'Local Relays Only - Highlights will be marked as local'
}>
<div className="relay-status-icon">
<FontAwesomeIcon icon={isConnecting ? faSpinner : offlineMode ? faCircle : faPlane} spin={isConnecting} />
</div>
<div className="relay-status-text">
{isConnecting ? (
<span className="relay-status-title">Connecting</span>
) : offlineMode ? (
<>
<span className="relay-status-title">Offline</span>
<span className="relay-status-subtitle">No relays connected</span>
</>
) : (
<>
<span className="relay-status-title">Flight Mode</span>
<span className="relay-status-subtitle">{connectedUrls.length} local relay{connectedUrls.length !== 1 ? 's' : ''}</span>
</>
)}
</div>
{!offlineMode && !isConnecting && (
<div className="relay-status-pulse">
<FontAwesomeIcon icon={faGlobe} className="pulse-icon" />
</div>
)}
</div>
)
}

View File

@@ -1,11 +1,16 @@
import React, { useState, useEffect, useRef } from 'react'
import { faTimes, faUndo } from '@fortawesome/free-solid-svg-icons'
import { RelayPool } from 'applesauce-relay'
import { UserSettings } from '../services/settingsService'
import IconButton from './IconButton'
import { loadFont } from '../utils/fontLoader'
import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
import LayoutNavigationSettings from './Settings/LayoutNavigationSettings'
import StartupPreferencesSettings from './Settings/StartupPreferencesSettings'
import ZapSettings from './Settings/ZapSettings'
import OfflineModeSettings from './Settings/OfflineModeSettings'
import RelaySettings from './Settings/RelaySettings'
import { useRelayStatus } from '../hooks/useRelayStatus'
const DEFAULT_SETTINGS: UserSettings = {
collapseOnArticleOpen: true,
@@ -14,7 +19,7 @@ const DEFAULT_SETTINGS: UserSettings = {
sidebarCollapsed: true,
highlightsCollapsed: true,
readingFont: 'source-serif-4',
fontSize: 18,
fontSize: 21,
highlightStyle: 'marker',
highlightColor: '#ffff00',
highlightColorNostrverse: '#9333ea',
@@ -23,20 +28,57 @@ const DEFAULT_SETTINGS: UserSettings = {
defaultHighlightVisibilityNostrverse: true,
defaultHighlightVisibilityFriends: true,
defaultHighlightVisibilityMine: true,
zapSplitHighlighterWeight: 50,
zapSplitBorisWeight: 2.1,
zapSplitAuthorWeight: 50,
useLocalRelayAsCache: true,
rebroadcastToAllRelays: false,
}
interface SettingsProps {
settings: UserSettings
onSave: (settings: UserSettings) => Promise<void>
onClose: () => void
relayPool: RelayPool | null
}
const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
const [localSettings, setLocalSettings] = useState<UserSettings>(settings)
const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPool }) => {
const [localSettings, setLocalSettings] = useState<UserSettings>(() => {
// Migrate old settings format to new weight-based format
const migrated = { ...settings }
const anySettings = migrated as Record<string, unknown>
if ('zapSplitPercentage' in anySettings && !('zapSplitHighlighterWeight' in migrated)) {
migrated.zapSplitHighlighterWeight = (anySettings.zapSplitPercentage as number) ?? 50
migrated.zapSplitAuthorWeight = 100 - ((anySettings.zapSplitPercentage as number) ?? 50)
}
if ('borisSupportPercentage' in anySettings && !('zapSplitBorisWeight' in migrated)) {
migrated.zapSplitBorisWeight = (anySettings.borisSupportPercentage as number) ?? 2.1
}
return migrated
})
const isInitialMount = useRef(true)
const saveTimeoutRef = useRef<number | null>(null)
const isLocallyUpdating = useRef(false)
// Poll for relay status updates
const relayStatuses = useRelayStatus({ relayPool })
useEffect(() => {
setLocalSettings(settings)
// Don't update from external settings if we're currently making local changes
if (isLocallyUpdating.current) {
return
}
const migrated = { ...settings }
const anySettings = migrated as Record<string, unknown>
if ('zapSplitPercentage' in anySettings && !('zapSplitHighlighterWeight' in migrated)) {
migrated.zapSplitHighlighterWeight = (anySettings.zapSplitPercentage as number) ?? 50
migrated.zapSplitAuthorWeight = 100 - ((anySettings.zapSplitPercentage as number) ?? 50)
}
if ('borisSupportPercentage' in anySettings && !('zapSplitBorisWeight' in migrated)) {
migrated.zapSplitBorisWeight = (anySettings.borisSupportPercentage as number) ?? 2.1
}
setLocalSettings(migrated)
}, [settings])
useEffect(() => {
@@ -57,7 +99,30 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
isInitialMount.current = false
return
}
onSave(localSettings)
// Mark that we're making local updates
isLocallyUpdating.current = true
// Clear any pending save
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current)
}
// Debounce the save to avoid rapid updates
saveTimeoutRef.current = setTimeout(() => {
onSave(localSettings).finally(() => {
// Allow external updates again after a short delay
setTimeout(() => {
isLocallyUpdating.current = false
}, 500)
})
}, 300)
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current)
}
}
}, [localSettings, onSave])
const handleResetToDefaults = () => {
@@ -96,6 +161,9 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
<LayoutNavigationSettings settings={localSettings} onUpdate={handleUpdate} />
<StartupPreferencesSettings settings={localSettings} onUpdate={handleUpdate} />
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
<OfflineModeSettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
</div>
</div>
)

View File

@@ -14,7 +14,7 @@ const LayoutNavigationSettings: React.FC<LayoutNavigationSettingsProps> = ({ set
<h3 className="section-title">Layout & Navigation</h3>
<div className="setting-group setting-inline">
<label>Default View Mode</label>
<label>Default Bookmark View</label>
<div className="setting-buttons">
<IconButton
icon={faList}

View File

@@ -0,0 +1,173 @@
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { faTrash } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../../services/settingsService'
import { getImageCacheStatsAsync, clearImageCache } from '../../services/imageCacheService'
import IconButton from '../IconButton'
interface OfflineModeSettingsProps {
settings: UserSettings
onUpdate: (updates: Partial<UserSettings>) => void
onClose?: () => void
}
const OfflineModeSettings: React.FC<OfflineModeSettingsProps> = ({ settings, onUpdate, onClose }) => {
const navigate = useNavigate()
const [cacheStats, setCacheStats] = useState<{
totalSizeMB: number
itemCount: number
items: Array<{ url: string, sizeMB: number }>
}>({ totalSizeMB: 0, itemCount: 0, items: [] })
const handleLinkClick = (url: string) => {
if (onClose) onClose()
navigate(`/r/${encodeURIComponent(url)}`)
}
const handleClearCache = async () => {
if (confirm('Are you sure you want to clear all cached images?')) {
await clearImageCache()
const stats = await getImageCacheStatsAsync()
setCacheStats(stats)
}
}
// Update cache stats periodically
useEffect(() => {
const updateStats = async () => {
const stats = await getImageCacheStatsAsync()
setCacheStats(stats)
}
updateStats() // Initial load
const interval = setInterval(updateStats, 3000) // Update every 3 seconds
return () => clearInterval(interval)
}, [])
return (
<div className="settings-section">
<h3 className="section-title">Flight Mode</h3>
<div className="setting-group" style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<label htmlFor="enableImageCache" className="checkbox-label" style={{ marginBottom: 0 }}>
<input
id="enableImageCache"
type="checkbox"
checked={settings.enableImageCache ?? true}
onChange={(e) => onUpdate({ enableImageCache: e.target.checked })}
className="setting-checkbox"
/>
<span>Use local image cache</span>
</label>
{(settings.enableImageCache ?? true) && (
<div style={{
fontSize: '0.85rem',
color: 'var(--text-secondary)',
display: 'flex',
alignItems: 'center',
gap: '0.5rem'
}}>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
( {cacheStats.totalSizeMB.toFixed(1)} MB /
<input
id="imageCacheSizeMB"
type="number"
min="10"
max="500"
value={settings.imageCacheSizeMB ?? 210}
onChange={(e) => onUpdate({ imageCacheSizeMB: parseInt(e.target.value) || 210 })}
style={{
width: '50px',
padding: '0.15rem 0.35rem',
background: 'var(--surface-secondary)',
border: '1px solid var(--border-color, #333)',
borderRadius: '4px',
color: 'inherit',
fontSize: 'inherit',
fontFamily: 'inherit',
textAlign: 'center'
}}
/>
MB used )
</span>
<IconButton
icon={faTrash}
onClick={handleClearCache}
title="Clear cache"
variant="ghost"
size={28}
/>
</div>
)}
</div>
<div className="setting-group">
<label htmlFor="useLocalRelayAsCache" className="checkbox-label">
<input
id="useLocalRelayAsCache"
type="checkbox"
checked={settings.useLocalRelayAsCache ?? true}
onChange={(e) => onUpdate({ useLocalRelayAsCache: e.target.checked })}
className="setting-checkbox"
/>
<span>Use local relays as cache</span>
</label>
</div>
<div style={{
marginTop: '1.5rem',
padding: '1rem',
background: 'var(--surface-secondary)',
borderRadius: '6px',
fontSize: '0.9rem',
lineHeight: '1.6'
}}>
<p style={{ margin: 0, color: 'var(--text-secondary)' }}>
Boris works best with a local relay. Consider running{' '}
<a
href="https://github.com/greenart7c3/Citrine?tab=readme-ov-file#download"
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--accent, #8b5cf6)' }}
>
Citrine
</a>
{' or '}
<a
href="https://github.com/CodyTseng/nostr-relay-tray/releases"
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--accent, #8b5cf6)' }}
>
nostr-relay-tray
</a>
. Don't know what relays are? Learn more{' '}
<a
onClick={(e) => {
e.preventDefault()
handleLinkClick('https://nostr.how/en/relays')
}}
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
>
here
</a>
{' and '}
<a
onClick={(e) => {
e.preventDefault()
handleLinkClick('https://davidebtc186.substack.com/p/the-importance-of-hosting-your-own')
}}
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
>
here
</a>
.
</p>
</div>
</div>
)
}
export default OfflineModeSettings

View File

@@ -1,5 +1,6 @@
import React from 'react'
import { faHighlighter, faUnderline } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faUnderline, faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../../services/settingsService'
import IconButton from '../IconButton'
import ColorPicker from '../ColorPicker'
@@ -19,42 +20,33 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
<div className="settings-section">
<h3 className="section-title">Reading & Display</h3>
<div className="setting-group setting-inline">
<label htmlFor="readingFont">Reading Font</label>
<FontSelector
value={settings.readingFont || 'source-serif-4'}
onChange={(font) => onUpdate({ readingFont: font })}
/>
</div>
<div className="setting-group setting-inline">
<label>Font Size</label>
<div className="setting-buttons">
{[14, 16, 18, 20, 22].map(size => (
<button
key={size}
onClick={() => onUpdate({ fontSize: size })}
className={`font-size-btn ${(settings.fontSize || 18) === size ? 'active' : ''}`}
title={`${size}px`}
style={{ fontSize: `${size - 2}px` }}
>
A
</button>
))}
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
<div className="setting-group setting-inline" style={{ flex: '1 1 auto', minWidth: '200px' }}>
<label htmlFor="readingFont">Reading Font</label>
<div className="setting-control">
<FontSelector
value={settings.readingFont || 'source-serif-4'}
onChange={(font) => onUpdate({ readingFont: font })}
/>
</div>
</div>
</div>
<div className="setting-group">
<label htmlFor="showHighlights" className="checkbox-label">
<input
id="showHighlights"
type="checkbox"
checked={settings.showHighlights !== false}
onChange={(e) => onUpdate({ showHighlights: e.target.checked })}
className="setting-checkbox"
/>
<span>Show highlights</span>
</label>
<div className="setting-group setting-inline" style={{ flex: '0 1 auto' }}>
<label>Font Size</label>
<div className="setting-buttons">
{[16, 18, 21, 24, 28, 32].map(size => (
<button
key={size}
onClick={() => onUpdate({ fontSize: size })}
className={`font-size-btn ${(settings.fontSize || 21) === size ? 'active' : ''}`}
title={`${size}px`}
style={{ fontSize: `${size - 2}px` }}
>
A
</button>
))}
</div>
</div>
</div>
<div className="setting-group setting-inline">
@@ -107,20 +99,66 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
</div>
</div>
<div className="setting-group setting-inline">
<label>Default Highlight Visibility</label>
<div className="highlight-level-toggles">
<button
onClick={() => onUpdate({ defaultHighlightVisibilityNostrverse: !(settings.defaultHighlightVisibilityNostrverse !== false) })}
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityNostrverse !== false) ? 'active' : ''}`}
title="Nostrverse highlights"
aria-label="Toggle nostrverse highlights by default"
style={{ color: (settings.defaultHighlightVisibilityNostrverse !== false) ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }}
>
<FontAwesomeIcon icon={faNetworkWired} />
</button>
<button
onClick={() => onUpdate({ defaultHighlightVisibilityFriends: !(settings.defaultHighlightVisibilityFriends !== false) })}
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityFriends !== false) ? 'active' : ''}`}
title="Friends highlights"
aria-label="Toggle friends highlights by default"
style={{ color: (settings.defaultHighlightVisibilityFriends !== false) ? 'var(--highlight-color-friends, #f97316)' : undefined }}
>
<FontAwesomeIcon icon={faUserGroup} />
</button>
<button
onClick={() => onUpdate({ defaultHighlightVisibilityMine: !(settings.defaultHighlightVisibilityMine !== false) })}
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityMine !== false) ? 'active' : ''}`}
title="My highlights"
aria-label="Toggle my highlights by default"
style={{ color: (settings.defaultHighlightVisibilityMine !== false) ? 'var(--highlight-color-mine, #eab308)' : undefined }}
>
<FontAwesomeIcon icon={faUser} />
</button>
</div>
</div>
<div className="setting-group">
<label htmlFor="showHighlights" className="checkbox-label">
<input
id="showHighlights"
type="checkbox"
checked={settings.showHighlights !== false}
onChange={(e) => onUpdate({ showHighlights: e.target.checked })}
className="setting-checkbox"
/>
<span>Show highlights</span>
</label>
</div>
<div className="setting-preview">
<div className="preview-label">Preview</div>
<div
className="preview-content"
style={{
fontFamily: previewFontFamily,
fontSize: `${settings.fontSize || 18}px`,
fontSize: `${settings.fontSize || 21}px`,
'--highlight-rgb': hexToRgb(settings.highlightColor || '#ffff00')
} as React.CSSProperties}
>
<h3>The Quick Brown Fox</h3>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <span className={settings.showHighlights !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-mine` : ""}>Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</span> Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. <span className={settings.showHighlights !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-friends` : ""}>Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</span> Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.</p>
<p>Totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. <span className={settings.showHighlights !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-nostrverse` : ""}>Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</span> Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit.</p>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityMine !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-mine` : ""}>Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</span> Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityFriends !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-friends` : ""}>Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</span> Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.</p>
<p>Totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityNostrverse !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-nostrverse` : ""}>Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</span> Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit.</p>
<p>Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</p>
</div>
</div>

View File

@@ -0,0 +1,143 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCheckCircle, faWifi, faClock, faPlane } from '@fortawesome/free-solid-svg-icons'
import { RelayStatus } from '../../services/relayStatusService'
import { formatDistanceToNow } from 'date-fns'
import { isLocalRelay } from '../../utils/helpers'
interface RelaySettingsProps {
relayStatuses: RelayStatus[]
onClose?: () => void
}
const RelaySettings: React.FC<RelaySettingsProps> = ({ relayStatuses }) => {
const formatRelayUrl = (url: string) => {
return url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
}
const formatLastSeen = (timestamp: number) => {
try {
return formatDistanceToNow(timestamp, { addSuffix: true })
} catch {
return 'just now'
}
}
// Sort relays: local relays first, then by connection status, then by URL
const sortedRelays = [...relayStatuses].sort((a, b) => {
const aIsLocal = isLocalRelay(a.url)
const bIsLocal = isLocalRelay(b.url)
// Local relays always first
if (aIsLocal && !bIsLocal) return -1
if (!aIsLocal && bIsLocal) return 1
// Within local or remote groups, connected before disconnected
if (a.isInPool !== b.isInPool) return a.isInPool ? -1 : 1
// Finally sort by URL
return a.url.localeCompare(b.url)
})
const getRelayIcon = (relay: RelayStatus) => {
const isLocal = isLocalRelay(relay.url)
const isConnected = relay.isInPool
if (isLocal) {
return {
icon: faPlane,
color: isConnected ? '#22c55e' : '#ef4444',
size: '1rem'
}
} else {
if (isConnected) {
return {
icon: faCheckCircle,
color: '#22c55e',
size: '1rem'
}
} else {
return {
icon: faWifi,
color: '#ef4444',
size: '1rem'
}
}
}
}
return (
<div className="settings-section">
<h3 className="section-title">Relays</h3>
{sortedRelays.length > 0 && (
<div className="relay-group">
<div className="relay-list">
{sortedRelays.map((relay) => {
const iconConfig = getRelayIcon(relay)
const isDisconnected = !relay.isInPool
return (
<div
key={relay.url}
className="relay-item"
style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.75rem',
background: 'var(--surface-secondary)',
borderRadius: '6px',
marginBottom: '0.5rem',
opacity: isDisconnected ? 0.7 : 1
}}
>
<FontAwesomeIcon
icon={iconConfig.icon}
style={{
color: iconConfig.color,
fontSize: iconConfig.size
}}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: '0.9rem',
fontFamily: 'var(--font-mono, monospace)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}>
{formatRelayUrl(relay.url)}
</div>
</div>
{isDisconnected && (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
fontSize: '0.8rem',
color: 'var(--text-tertiary)',
whiteSpace: 'nowrap'
}}>
<FontAwesomeIcon icon={faClock} />
{formatLastSeen(relay.lastSeen)}
</div>
)}
</div>
)
})}
</div>
</div>
)}
{relayStatuses.length === 0 && (
<p style={{ color: 'var(--text-secondary)', fontStyle: 'italic' }}>
No relay connections found
</p>
)}
</div>
)
}
export default RelaySettings

View File

@@ -1,7 +1,5 @@
import React from 'react'
import { faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../../services/settingsService'
import IconButton from '../IconButton'
interface StartupPreferencesSettingsProps {
settings: UserSettings
@@ -11,7 +9,7 @@ interface StartupPreferencesSettingsProps {
const StartupPreferencesSettings: React.FC<StartupPreferencesSettingsProps> = ({ settings, onUpdate }) => {
return (
<div className="settings-section">
<h3 className="section-title">Startup Preferences</h3>
<h3 className="section-title">Startup & Behavior</h3>
<div className="setting-group">
<label htmlFor="sidebarCollapsed" className="checkbox-label">
@@ -39,31 +37,17 @@ const StartupPreferencesSettings: React.FC<StartupPreferencesSettingsProps> = ({
</label>
</div>
<div className="setting-group setting-inline">
<label>Default Highlight Visibility</label>
<div className="setting-buttons">
<IconButton
icon={faNetworkWired}
onClick={() => onUpdate({ defaultHighlightVisibilityNostrverse: !(settings.defaultHighlightVisibilityNostrverse !== false) })}
title="Nostrverse highlights"
ariaLabel="Toggle nostrverse highlights by default"
variant={(settings.defaultHighlightVisibilityNostrverse !== false) ? 'primary' : 'ghost'}
<div className="setting-group">
<label htmlFor="rebroadcastToAllRelays" className="checkbox-label">
<input
id="rebroadcastToAllRelays"
type="checkbox"
checked={settings.rebroadcastToAllRelays ?? false}
onChange={(e) => onUpdate({ rebroadcastToAllRelays: e.target.checked })}
className="setting-checkbox"
/>
<IconButton
icon={faUserGroup}
onClick={() => onUpdate({ defaultHighlightVisibilityFriends: !(settings.defaultHighlightVisibilityFriends !== false) })}
title="Friends highlights"
ariaLabel="Toggle friends highlights by default"
variant={(settings.defaultHighlightVisibilityFriends !== false) ? 'primary' : 'ghost'}
/>
<IconButton
icon={faUser}
onClick={() => onUpdate({ defaultHighlightVisibilityMine: !(settings.defaultHighlightVisibilityMine !== false) })}
title="My highlights"
ariaLabel="Toggle my highlights by default"
variant={(settings.defaultHighlightVisibilityMine !== false) ? 'primary' : 'ghost'}
/>
</div>
<span>Rebroadcast events while browsing</span>
</label>
</div>
</div>
)

View File

@@ -0,0 +1,143 @@
import React from 'react'
import { UserSettings } from '../../services/settingsService'
interface ZapSettingsProps {
settings: UserSettings
onUpdate: (updates: Partial<UserSettings>) => void
}
const ZapSettings: React.FC<ZapSettingsProps> = ({ settings, onUpdate }) => {
const highlighterWeight = settings.zapSplitHighlighterWeight ?? 50
const borisWeight = settings.zapSplitBorisWeight ?? 2.1
const authorWeight = settings.zapSplitAuthorWeight ?? 50
// Calculate actual percentages from weights
const totalWeight = highlighterWeight + borisWeight + authorWeight
const highlighterPercentage = totalWeight > 0 ? (highlighterWeight / totalWeight) * 100 : 0
const borisPercentage = totalWeight > 0 ? (borisWeight / totalWeight) * 100 : 0
const authorPercentage = totalWeight > 0 ? (authorWeight / totalWeight) * 100 : 0
const presets = {
default: { highlighter: 50, boris: 2.1, author: 50 },
generous: { highlighter: 5, boris: 10, author: 75 },
selfless: { highlighter: 1, boris: 19, author: 80 },
boris: { highlighter: 10, boris: 80, author: 10 },
}
const isPresetActive = (preset: { highlighter: number; boris: number; author: number }) => {
return highlighterWeight === preset.highlighter &&
borisWeight === preset.boris &&
authorWeight === preset.author
}
const applyPreset = (preset: { highlighter: number; boris: number; author: number }) => {
onUpdate({
zapSplitHighlighterWeight: preset.highlighter,
zapSplitBorisWeight: preset.boris,
zapSplitAuthorWeight: preset.author,
})
}
return (
<div className="settings-section">
<h3 className="section-title">Zap Splits</h3>
<div className="setting-group">
<label className="setting-label">Presets</label>
<div className="zap-preset-buttons">
<button
onClick={() => applyPreset(presets.default)}
className={`zap-preset-btn ${isPresetActive(presets.default) ? 'active' : ''}`}
title="You: 49%, Author: 49%, Boris: 2%"
>
Default
</button>
<button
onClick={() => applyPreset(presets.generous)}
className={`zap-preset-btn ${isPresetActive(presets.generous) ? 'active' : ''}`}
title="You: 6%, Author: 83%, Boris: 11%"
>
Generous
</button>
<button
onClick={() => applyPreset(presets.selfless)}
className={`zap-preset-btn ${isPresetActive(presets.selfless) ? 'active' : ''}`}
title="You: 1%, Author: 80%, Boris: 19%"
>
Selfless
</button>
<button
onClick={() => applyPreset(presets.boris)}
className={`zap-preset-btn ${isPresetActive(presets.boris) ? 'active' : ''}`}
title="You: 10%, Author: 10%, Boris: 80%"
>
Boris 🧡
</button>
</div>
</div>
<div className="setting-group">
<label className="setting-label">Your Share</label>
<div className="zap-split-container">
<div className="zap-split-labels">
<span className="zap-split-label">Weight: {highlighterWeight}</span>
<span className="zap-split-label">({highlighterPercentage.toFixed(1)}%)</span>
</div>
<input
type="range"
min="0"
max="100"
value={highlighterWeight}
onChange={(e) => onUpdate({ zapSplitHighlighterWeight: parseInt(e.target.value) })}
className="zap-split-slider"
/>
</div>
</div>
<div className="setting-group">
<label className="setting-label">Author(s) Share</label>
<div className="zap-split-container">
<div className="zap-split-labels">
<span className="zap-split-label">Weight: {authorWeight}</span>
<span className="zap-split-label">({authorPercentage.toFixed(1)}%)</span>
</div>
<input
type="range"
min="0"
max="100"
value={authorWeight}
onChange={(e) => onUpdate({ zapSplitAuthorWeight: parseInt(e.target.value) })}
className="zap-split-slider"
/>
</div>
</div>
<div className="setting-group">
<label className="setting-label">Support Boris</label>
<div className="zap-split-container">
<div className="zap-split-labels">
<span className="zap-split-label">Weight: {borisWeight.toFixed(1)}</span>
<span className="zap-split-label">({borisPercentage.toFixed(1)}%)</span>
</div>
<input
type="range"
min="0"
max="10"
step="0.1"
value={borisWeight}
onChange={(e) => onUpdate({ zapSplitBorisWeight: parseFloat(e.target.value) })}
className="zap-split-slider"
/>
</div>
</div>
<div className="zap-split-description">
Weights determine zap splits when highlighting nostr-native content.
If the content has multiple authors, their share is divided proportionally.
</div>
</div>
)
}
export default ZapSettings

View File

@@ -1,22 +1,28 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faRotate } from '@fortawesome/free-solid-svg-icons'
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faHome, faPlus, faNewspaper } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import { Accounts } from 'applesauce-accounts'
import { RelayPool } from 'applesauce-relay'
import IconButton from './IconButton'
import AddBookmarkModal from './AddBookmarkModal'
import { createWebBookmark } from '../services/webBookmarkService'
import { RELAYS } from '../config/relays'
interface SidebarHeaderProps {
onToggleCollapse: () => void
onLogout: () => void
onOpenSettings: () => void
onRefresh?: () => void
isRefreshing?: boolean
relayPool: RelayPool | null
}
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, onRefresh, isRefreshing }) => {
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, relayPool }) => {
const [isConnecting, setIsConnecting] = useState(false)
const [showAddModal, setShowAddModal] = useState(false)
const navigate = useNavigate()
const activeAccount = Hooks.useActiveAccount()
const accountManager = Hooks.useAccountManager()
const profile = useEventModel(Models.ProfileModel, activeAccount ? [activeAccount.pubkey] : null)
@@ -47,6 +53,14 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
return `${activeAccount.pubkey.slice(0, 8)}...${activeAccount.pubkey.slice(-8)}`
}
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
if (!activeAccount || !relayPool) {
throw new Error('Please login to create bookmarks')
}
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
}
const profileImage = getProfileImage()
return (
@@ -61,24 +75,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
<FontAwesomeIcon icon={faChevronRight} />
</button>
<div className="sidebar-header-right">
{onRefresh && (
<IconButton
icon={faRotate}
onClick={onRefresh}
title="Refresh bookmarks"
ariaLabel="Refresh bookmarks"
variant="ghost"
disabled={isRefreshing}
spin={isRefreshing}
/>
)}
<IconButton
icon={faGear}
onClick={onOpenSettings}
title="Settings"
ariaLabel="Settings"
variant="ghost"
/>
<div
className="profile-avatar"
title={activeAccount ? getUserDisplayName() : "Login"}
@@ -91,6 +87,36 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
<FontAwesomeIcon icon={faUserCircle} />
)}
</div>
<IconButton
icon={faHome}
onClick={() => navigate('/')}
title="Home"
ariaLabel="Home"
variant="ghost"
/>
<IconButton
icon={faNewspaper}
onClick={() => navigate('/explore')}
title="Explore"
ariaLabel="Explore"
variant="ghost"
/>
<IconButton
icon={faGear}
onClick={onOpenSettings}
title="Settings"
ariaLabel="Settings"
variant="ghost"
/>
{activeAccount && (
<IconButton
icon={faPlus}
onClick={() => setShowAddModal(true)}
title="Add bookmark"
ariaLabel="Add bookmark"
variant="ghost"
/>
)}
{activeAccount ? (
<IconButton
icon={faRightFromBracket}
@@ -110,6 +136,12 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
)}
</div>
</div>
{showAddModal && (
<AddBookmarkModal
onClose={() => setShowAddModal(false)}
onSave={handleSaveBookmark}
/>
)}
</>
)
}

View File

@@ -1,10 +1,13 @@
import React from 'react'
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { BookmarkList } from './BookmarkList'
import ContentPanel from './ContentPanel'
import { HighlightsPanel } from './HighlightsPanel'
import Settings from './Settings'
import Toast from './Toast'
import { HighlightButton } from './HighlightButton'
import { RelayStatusIndicator } from './RelayStatusIndicator'
import { ViewMode } from './Bookmarks'
import { Bookmark } from '../types/bookmarks'
import { Highlight } from '../types/highlights'
@@ -19,17 +22,21 @@ interface ThreePaneLayoutProps {
isCollapsed: boolean
isHighlightsCollapsed: boolean
showSettings: boolean
showExplore?: boolean
// Bookmarks pane
bookmarks: Bookmark[]
bookmarksLoading: boolean
viewMode: ViewMode
isRefreshing: boolean
lastFetchTime?: number | null
onToggleSidebar: () => void
onLogout: () => void
onViewModeChange: (mode: ViewMode) => void
onOpenSettings: () => void
onRefresh: () => void
relayPool: RelayPool | null
eventStore: IEventStore | null
// Content pane
readerLoading: boolean
@@ -66,6 +73,9 @@ interface ThreePaneLayoutProps {
toastMessage?: string
toastType?: 'success' | 'error'
onClearToast: () => void
// Optional Explore content
explore?: React.ReactNode
}
const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
@@ -85,7 +95,10 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
onOpenSettings={props.onOpenSettings}
onRefresh={props.onRefresh}
isRefreshing={props.isRefreshing}
lastFetchTime={props.lastFetchTime}
loading={props.bookmarksLoading}
relayPool={props.relayPool}
settings={props.settings}
/>
</div>
<div className="pane main">
@@ -94,7 +107,13 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
settings={props.settings}
onSave={props.onSaveSettings}
onClose={props.onCloseSettings}
relayPool={props.relayPool}
/>
) : props.showExplore && props.explore ? (
// Render Explore inside the main pane to keep side panels
<>
{props.explore}
</>
) : (
<ContentPanel
loading={props.readerLoading}
@@ -102,6 +121,8 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
html={props.readerContent?.html}
markdown={props.readerContent?.markdown}
image={props.readerContent?.image}
summary={props.readerContent?.summary}
published={props.readerContent?.published}
selectedUrl={props.selectedUrl}
highlights={props.classifiedHighlights}
showHighlights={props.showHighlights}
@@ -114,6 +135,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
onClearSelection={props.onClearSelection}
currentUserPubkey={props.currentUserPubkey}
followedPubkeys={props.followedPubkeys}
settings={props.settings}
/>
)}
</div>
@@ -133,6 +155,8 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
highlightVisibility={props.highlightVisibility}
onHighlightVisibilityChange={props.onHighlightVisibilityChange}
followedPubkeys={props.followedPubkeys}
relayPool={props.relayPool}
eventStore={props.eventStore}
/>
</div>
</div>
@@ -143,6 +167,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
highlightColor={props.settings.highlightColor || '#ffff00'}
/>
)}
<RelayStatusIndicator relayPool={props.relayPool} />
{props.toastMessage && (
<Toast
message={props.toastMessage}

View File

@@ -3,9 +3,10 @@
* Single set of relays used throughout the application
*/
// All relays including local relay
// All relays including local relays
export const RELAYS = [
'ws://localhost:10547',
'ws://localhost:4869',
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.nostr.band',

View File

@@ -5,6 +5,7 @@ import { fetchHighlightsForArticle } from '../services/highlightService'
import { ReadableContent } from '../services/readerService'
import { Highlight } from '../types/highlights'
import { NostrEvent } from 'nostr-tools'
import { UserSettings } from '../services/settingsService'
interface UseArticleLoaderProps {
naddr: string | undefined
@@ -18,6 +19,7 @@ interface UseArticleLoaderProps {
setCurrentArticleCoordinate: (coord: string | undefined) => void
setCurrentArticleEventId: (id: string | undefined) => void
setCurrentArticle?: (article: NostrEvent) => void
settings?: UserSettings
}
export function useArticleLoader({
@@ -31,7 +33,8 @@ export function useArticleLoader({
setHighlightsLoading,
setCurrentArticleCoordinate,
setCurrentArticleEventId,
setCurrentArticle
setCurrentArticle,
settings
}: UseArticleLoaderProps) {
useEffect(() => {
if (!relayPool || !naddr) return
@@ -44,11 +47,13 @@ export function useArticleLoader({
// Keep highlights panel collapsed by default - only open on user interaction
try {
const article = await fetchArticleByNaddr(relayPool, naddr)
const article = await fetchArticleByNaddr(relayPool, naddr, false, settings)
setReaderContent({
title: article.title,
markdown: article.markdown,
image: article.image,
summary: article.summary,
published: article.published,
url: `nostr:${naddr}`
})
@@ -71,19 +76,23 @@ export function useArticleLoader({
try {
setHighlightsLoading(true)
setHighlights([]) // Clear old highlights
const highlightsList: Highlight[] = []
const highlightsMap = new Map<string, Highlight>()
await fetchHighlightsForArticle(
relayPool,
articleCoordinate,
article.event.id,
(highlight) => {
// Render each highlight immediately as it arrives
highlightsList.push(highlight)
setHighlights([...highlightsList].sort((a, b) => b.created_at - a.created_at))
}
// Deduplicate highlights by ID as they arrive
if (!highlightsMap.has(highlight.id)) {
highlightsMap.set(highlight.id, highlight)
const highlightsList = Array.from(highlightsMap.values())
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
}
},
settings
)
console.log(`📌 Found ${highlightsList.length} highlights`)
console.log(`📌 Found ${highlightsMap.size} highlights`)
} catch (err) {
console.error('Failed to fetch highlights:', err)
} finally {
@@ -101,5 +110,5 @@ export function useArticleLoader({
}
loadArticle()
}, [naddr, relayPool, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId, setCurrentArticle])
}, [naddr, relayPool, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId, setCurrentArticle, settings])
}

View File

@@ -5,6 +5,7 @@ import { Highlight } from '../types/highlights'
import { fetchBookmarks } from '../services/bookmarkService'
import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService'
import { fetchContacts } from '../services/contactService'
import { UserSettings } from '../services/settingsService'
interface UseBookmarksDataParams {
relayPool: RelayPool | null
@@ -15,6 +16,7 @@ interface UseBookmarksDataParams {
naddr?: string
currentArticleCoordinate?: string
currentArticleEventId?: string
settings?: UserSettings
}
export const useBookmarksData = ({
@@ -23,7 +25,8 @@ export const useBookmarksData = ({
accountManager,
naddr,
currentArticleCoordinate,
currentArticleEventId
currentArticleEventId,
settings
}: UseBookmarksDataParams) => {
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [bookmarksLoading, setBookmarksLoading] = useState(true)
@@ -31,6 +34,7 @@ export const useBookmarksData = ({
const [highlightsLoading, setHighlightsLoading] = useState(true)
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
const [isRefreshing, setIsRefreshing] = useState(false)
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null)
const handleFetchContacts = useCallback(async () => {
if (!relayPool || !activeAccount) return
@@ -43,11 +47,11 @@ export const useBookmarksData = ({
setBookmarksLoading(true)
try {
const fullAccount = accountManager.getActive()
await fetchBookmarks(relayPool, fullAccount || activeAccount, setBookmarks)
await fetchBookmarks(relayPool, fullAccount || activeAccount, setBookmarks, settings)
} finally {
setBookmarksLoading(false)
}
}, [relayPool, activeAccount, accountManager])
}, [relayPool, activeAccount, accountManager, settings])
const handleFetchHighlights = useCallback(async () => {
if (!relayPool) return
@@ -55,19 +59,24 @@ export const useBookmarksData = ({
setHighlightsLoading(true)
try {
if (currentArticleCoordinate) {
const highlightsList: Highlight[] = []
const highlightsMap = new Map<string, Highlight>()
await fetchHighlightsForArticle(
relayPool,
currentArticleCoordinate,
currentArticleEventId,
(highlight) => {
highlightsList.push(highlight)
setHighlights([...highlightsList].sort((a, b) => b.created_at - a.created_at))
}
// Deduplicate highlights by ID as they arrive
if (!highlightsMap.has(highlight.id)) {
highlightsMap.set(highlight.id, highlight)
const highlightsList = Array.from(highlightsMap.values())
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
}
},
settings
)
console.log(`🔄 Refreshed ${highlightsList.length} highlights for article`)
console.log(`🔄 Refreshed ${highlightsMap.size} highlights for article`)
} else if (activeAccount) {
const fetchedHighlights = await fetchHighlights(relayPool, activeAccount.pubkey)
const fetchedHighlights = await fetchHighlights(relayPool, activeAccount.pubkey, undefined, settings)
setHighlights(fetchedHighlights)
}
} catch (err) {
@@ -75,7 +84,7 @@ export const useBookmarksData = ({
} finally {
setHighlightsLoading(false)
}
}, [relayPool, activeAccount, currentArticleCoordinate, currentArticleEventId])
}, [relayPool, activeAccount, currentArticleCoordinate, currentArticleEventId, settings])
const handleRefreshAll = useCallback(async () => {
if (!relayPool || !activeAccount || isRefreshing) return
@@ -85,6 +94,7 @@ export const useBookmarksData = ({
await handleFetchBookmarks()
await handleFetchHighlights()
await handleFetchContacts()
setLastFetchTime(Date.now())
} catch (err) {
console.error('Failed to refresh data:', err)
} finally {
@@ -111,6 +121,7 @@ export const useBookmarksData = ({
setHighlightsLoading,
followedPubkeys,
isRefreshing,
lastFetchTime,
handleFetchBookmarks,
handleFetchHighlights,
handleRefreshAll

View File

@@ -14,7 +14,6 @@ export const useBookmarksUI = ({ settings }: UseBookmarksUIParams) => {
const [viewMode, setViewMode] = useState<ViewMode>('compact')
const [showHighlights, setShowHighlights] = useState(true)
const [selectedHighlightId, setSelectedHighlightId] = useState<string | undefined>(undefined)
const [showSettings, setShowSettings] = useState(false)
const [currentArticleCoordinate, setCurrentArticleCoordinate] = useState<string | undefined>(undefined)
const [currentArticleEventId, setCurrentArticleEventId] = useState<string | undefined>(undefined)
const [currentArticle, setCurrentArticle] = useState<NostrEvent | undefined>(undefined)
@@ -46,8 +45,6 @@ export const useBookmarksUI = ({ settings }: UseBookmarksUIParams) => {
setShowHighlights,
selectedHighlightId,
setSelectedHighlightId,
showSettings,
setShowSettings,
currentArticleCoordinate,
setCurrentArticleCoordinate,
currentArticleEventId,

View File

@@ -1,28 +1,34 @@
import { useCallback, useRef } from 'react'
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { IEventStore } from 'applesauce-core'
import { Highlight } from '../types/highlights'
import { ReadableContent } from '../services/readerService'
import { createHighlight, eventToHighlight } from '../services/highlightCreationService'
import { createHighlight } from '../services/highlightCreationService'
import { HighlightButtonRef } from '../components/HighlightButton'
import { UserSettings } from '../services/settingsService'
interface UseHighlightCreationParams {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
activeAccount: any
relayPool: RelayPool | null
eventStore: IEventStore | null
currentArticle: NostrEvent | undefined
selectedUrl: string | undefined
readerContent: ReadableContent | undefined
onHighlightCreated: (highlight: Highlight) => void
settings?: UserSettings
}
export const useHighlightCreation = ({
activeAccount,
relayPool,
eventStore,
currentArticle,
selectedUrl,
readerContent,
onHighlightCreated
onHighlightCreated,
settings
}: UseHighlightCreationParams) => {
const highlightButtonRef = useRef<HighlightButtonRef>(null)
@@ -35,7 +41,7 @@ export const useHighlightCreation = ({
}, [])
const handleCreateHighlight = useCallback(async (text: string) => {
if (!activeAccount || !relayPool) {
if (!activeAccount || !relayPool || !eventStore) {
console.error('Missing requirements for highlight creation')
return
}
@@ -51,23 +57,34 @@ export const useHighlightCreation = ({
? currentArticle.content
: readerContent?.markdown || readerContent?.html
const signedEvent = await createHighlight(
console.log('🎯 Creating highlight...', { text: text.substring(0, 50) + '...' })
const newHighlight = await createHighlight(
text,
source,
activeAccount,
relayPool,
contentForContext
eventStore,
contentForContext,
undefined,
settings
)
console.log('✅ Highlight created successfully!')
highlightButtonRef.current?.clearSelection()
console.log('✅ Highlight created successfully!', {
id: newHighlight.id,
isLocalOnly: newHighlight.isLocalOnly,
isOfflineCreated: newHighlight.isOfflineCreated,
publishedRelays: newHighlight.publishedRelays
})
const newHighlight = eventToHighlight(signedEvent)
highlightButtonRef.current?.clearSelection()
onHighlightCreated(newHighlight)
} catch (error) {
console.error('Failed to create highlight:', error)
console.error('Failed to create highlight:', error)
// Re-throw to allow parent to handle
throw error
}
}, [activeAccount, relayPool, currentArticle, selectedUrl, readerContent, onHighlightCreated])
}, [activeAccount, relayPool, eventStore, currentArticle, selectedUrl, readerContent, onHighlightCreated, settings])
return {
highlightButtonRef,

View File

@@ -0,0 +1,34 @@
import { UserSettings } from '../services/settingsService'
/**
* Hook to return image URL for display
* Service Worker handles all caching transparently
* Images are cached on first load and available offline automatically
*
* @param imageUrl - The URL of the image to display
* @returns The image URL (Service Worker handles caching)
*/
export function useImageCache(
imageUrl: string | undefined,
// eslint-disable-next-line no-unused-vars
_settings?: UserSettings
): string | undefined {
// Service Worker handles everything - just return the URL as-is
return imageUrl
}
/**
* Pre-load image to ensure it's cached by Service Worker
* Triggers a fetch so the SW can cache it even if not visible yet
*/
export function useCacheImageOnLoad(
imageUrl: string | undefined,
// eslint-disable-next-line no-unused-vars
_settings?: UserSettings
): void {
// Service Worker will cache on first fetch
// This hook is now a no-op, kept for API compatibility
// The browser will automatically fetch and cache images when they're used in <img> tags
void imageUrl
}

View File

@@ -0,0 +1,70 @@
import { useEffect, useRef } from 'react'
import { RelayPool } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
import { IEventStore } from 'applesauce-core'
import { syncLocalEventsToRemote } from '../services/offlineSyncService'
import { isLocalRelay } from '../utils/helpers'
import { RelayStatus } from '../services/relayStatusService'
interface UseOfflineSyncParams {
relayPool: RelayPool | null
account: IAccount | null
eventStore: IEventStore | null
relayStatuses: RelayStatus[]
enabled?: boolean
}
export function useOfflineSync({
relayPool,
account: _account,
eventStore,
relayStatuses,
enabled = true
}: UseOfflineSyncParams) {
const previousStateRef = useRef<{
hasRemoteRelays: boolean
initialized: boolean
}>({
hasRemoteRelays: false,
initialized: false
})
useEffect(() => {
if (!enabled || !relayPool || !_account || !eventStore) return
const connectedRelays = relayStatuses.filter(r => r.isInPool)
const hasRemoteRelays = connectedRelays.some(r => !isLocalRelay(r.url))
const hasLocalRelays = connectedRelays.some(r => isLocalRelay(r.url))
// Skip the first check to avoid syncing on initial load
if (!previousStateRef.current.initialized) {
previousStateRef.current = {
hasRemoteRelays,
initialized: true
}
return
}
// Detect transition: from local-only to having remote relays
const wasLocalOnly = !previousStateRef.current.hasRemoteRelays && hasLocalRelays
const isNowOnline = hasRemoteRelays
if (wasLocalOnly && isNowOnline) {
console.log('✈️ Detected transition: Flight Mode → Online')
console.log('📊 Relay state:', {
connectedRelays: connectedRelays.length,
remoteRelays: connectedRelays.filter(r => !isLocalRelay(r.url)).length,
localRelays: connectedRelays.filter(r => isLocalRelay(r.url)).length
})
// Wait a moment for relays to fully establish connections
setTimeout(() => {
console.log('🚀 Starting sync after delay...')
syncLocalEventsToRemote(relayPool, eventStore)
}, 2000)
}
previousStateRef.current.hasRemoteRelays = hasRemoteRelays
}, [relayPool, _account, eventStore, relayStatuses, enabled])
}

View File

@@ -0,0 +1,37 @@
import { useState, useEffect } from 'react'
import { RelayPool } from 'applesauce-relay'
import { RelayStatus, updateAndGetRelayStatuses } from '../services/relayStatusService'
interface UseRelayStatusParams {
relayPool: RelayPool | null
pollingInterval?: number // in milliseconds
}
export function useRelayStatus({
relayPool,
pollingInterval = 20000
}: UseRelayStatusParams) {
const [relayStatuses, setRelayStatuses] = useState<RelayStatus[]>([])
useEffect(() => {
if (!relayPool) return
const updateStatuses = () => {
const statuses = updateAndGetRelayStatuses(relayPool)
setRelayStatuses(statuses)
}
// Initial update
updateStatuses()
// Poll for updates
const interval = setInterval(updateStatuses, pollingInterval)
return () => {
clearInterval(interval)
}
}, [relayPool, pollingInterval])
return relayStatuses
}

View File

@@ -58,7 +58,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
// Apply font settings after font is loaded
root.setProperty('--reading-font', getFontFamily(fontKey))
root.setProperty('--reading-font-size', `${settings.fontSize || 18}px`)
root.setProperty('--reading-font-size', `${settings.fontSize || 21}px`)
// Set highlight colors for three levels
root.setProperty('--highlight-color-mine', settings.highlightColorMine || '#ffff00')

View File

@@ -71,15 +71,16 @@ body {
.bookmarks-container .view-mode-controls {
margin-top: auto;
padding: 0.75rem 1rem;
padding: 1rem;
border-top: 1px solid #333;
background: #1a1a1a;
border-radius: 0 0 12px 12px;
background: transparent;
border-radius: 0;
}
.bookmarks-container .bookmarks-list {
padding: 0.25rem;
padding: 0.5rem;
overflow-y: auto;
overflow-x: hidden;
flex: 1;
width: 100%;
max-width: 100%;
@@ -108,13 +109,8 @@ body {
.view-mode-controls {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
margin-bottom: 1rem;
justify-content: center;
gap: 0.5rem;
}
.profile-avatar {
@@ -501,17 +497,21 @@ body {
}
.reader-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 2rem;
position: relative;
}
.reader-title {
margin: 0;
flex: 1;
margin: 0 0 0.75rem 0;
font-family: var(--reading-font);
}
.reader-summary {
color: #aaa;
font-size: 1.1rem;
line-height: 1.5;
margin: 0 0 1rem 0;
font-family: var(--reading-font);
}
.reader-meta {
@@ -521,6 +521,31 @@ body {
flex-wrap: wrap;
}
.publish-date {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.813rem;
color: rgba(136, 136, 136, 0.7);
opacity: 0.85;
}
.publish-date svg {
font-size: 0.75rem;
opacity: 0.6;
}
.publish-date-topright {
position: absolute;
top: 1rem;
right: 1rem;
font-size: 0.813rem;
color: #fff;
padding: 0.4rem 0.75rem;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
z-index: 10;
}
.reading-time {
display: flex;
align-items: center;
@@ -718,11 +743,11 @@ body {
}
.individual-bookmark {
background: #2a2a2a;
background: transparent;
padding: 1rem;
border-radius: 8px;
transition: all 0.2s ease;
border: 1px solid #333;
border: 1px solid transparent;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
@@ -730,23 +755,26 @@ body {
}
.individual-bookmark:hover {
border-color: #444;
background: #2d2d2d;
border-color: transparent;
background: #2a2a2a;
}
/* Compact view styles */
.individual-bookmark.compact {
padding: 0.3rem 0.25rem;
padding: 0.5rem 0.5rem;
background: transparent;
border-bottom: 1px solid #333;
border: none;
border-bottom: 1px solid #2a2a2a;
border-radius: 0;
box-shadow: none;
width: 100%;
max-width: 100%;
overflow: hidden;
}
.individual-bookmark.compact:hover {
background: #2a2a2a;
background: #252525;
border-bottom-color: #333;
transform: none;
box-shadow: none;
}
@@ -754,11 +782,11 @@ body {
.compact-row {
display: flex;
align-items: center;
gap: 0.75rem;
gap: 0.5rem;
height: 28px;
justify-content: space-between;
width: 100%;
min-width: 0;
overflow: hidden;
}
.compact-row.clickable {
@@ -779,7 +807,7 @@ body {
}
.compact-text {
flex: 1 1 0;
flex: 1;
min-width: 0;
color: #ccc;
font-size: 0.85rem;
@@ -787,7 +815,6 @@ body {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.bookmark-date-compact {
@@ -808,10 +835,9 @@ body {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
width: 24px;
height: 22px;
flex-shrink: 0;
margin-left: auto;
transition: color 0.2s ease;
}
@@ -1066,10 +1092,12 @@ body {
/* Hero image in reader view */
.reader-hero-image {
width: 100%;
margin: 0 0 2rem 0;
border-radius: 8px;
width: calc(100% + 1.5rem);
margin: -0.75rem -0.75rem 2rem -0.75rem;
border-radius: 0;
overflow: hidden;
position: relative;
min-height: 300px;
}
.reader-hero-image img {
@@ -1080,6 +1108,61 @@ body {
display: block;
}
.reader-header-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 2rem 2rem 1.5rem;
background: linear-gradient(to top, rgba(0, 0, 0, 0.85) 0%, rgba(0, 0, 0, 0.6) 60%, rgba(0, 0, 0, 0) 100%);
}
.reader-header-overlay .reader-title {
color: #fff;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
margin-bottom: 0.75rem;
}
.reader-header-overlay .reader-summary {
color: rgba(255, 255, 255, 0.9);
font-size: 1.1rem;
line-height: 1.5;
margin: 0 0 1rem 0;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
font-family: var(--reading-font);
}
.reader-header-overlay .reader-meta {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.reader-header-overlay .publish-date {
color: rgba(255, 255, 255, 0.65);
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
opacity: 1;
}
.reader-header-overlay .publish-date svg {
opacity: 0.7;
}
.reader-header-overlay .reading-time,
.reader-header-overlay .highlight-indicator {
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.25);
color: #fff;
}
.reader-header-overlay .highlight-indicator {
background: rgba(100, 108, 255, 0.25);
border: 1px solid rgba(100, 108, 255, 0.4);
}
/* Private Bookmark Styles */
.private-bookmark {
background: #2a2a2a;
@@ -1122,12 +1205,22 @@ body {
}
.individual-bookmark {
background: #f5f5f5;
border-color: #ddd;
background: transparent;
border-color: transparent;
}
.individual-bookmark:hover {
border-color: #646cff;
background: #f5f5f5;
border-color: transparent;
}
.individual-bookmark.compact {
border-bottom-color: #e5e5e5;
}
.individual-bookmark.compact:hover {
background: #fafafa;
border-bottom-color: #ddd;
}
.individual-bookmarks h4 {
@@ -1171,6 +1264,14 @@ body {
color: #646cff;
}
.highlight-relay-indicator {
color: #666;
}
.highlight-relay-indicator:hover {
color: #333;
}
.highlight-text {
color: #213547;
}
@@ -1185,7 +1286,6 @@ body {
flex-direction: column;
height: 100%;
overflow: hidden;
padding-right: 1rem;
}
.highlights-container.collapsed {
@@ -1471,6 +1571,28 @@ body {
font-size: 1.2rem;
flex-shrink: 0;
margin-top: 0.25rem;
position: relative;
}
.highlight-relay-indicator {
position: absolute;
bottom: -2px;
left: 0;
font-size: 0.7rem;
color: #888;
opacity: 0.7;
transition: all 0.2s ease;
cursor: pointer;
}
.highlight-relay-indicator:hover {
opacity: 1;
color: #aaa;
transform: scale(1.1);
}
.highlight-relay-indicator:active {
transform: scale(0.95);
}
/* Level-colored quote icon */
@@ -1519,22 +1641,33 @@ body {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-size: 0.8rem;
color: #888;
flex-wrap: wrap;
flex-wrap: nowrap;
min-height: 20px;
}
.highlight-author {
color: #aaa;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 150px;
line-height: 1;
}
.highlight-meta-separator {
color: #666;
flex-shrink: 0;
line-height: 1;
}
.highlight-time {
color: #888;
white-space: nowrap;
flex-shrink: 0;
line-height: 1;
}
.highlight-source {
@@ -1544,6 +1677,9 @@ body {
color: #646cff;
text-decoration: none;
transition: color 0.2s ease;
flex-shrink: 0;
margin-left: auto;
line-height: 1;
}
.highlight-source:hover {
@@ -2020,6 +2156,104 @@ body {
font-weight: 500;
}
.zap-split-container {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.zap-split-labels {
display: flex;
justify-content: space-between;
align-items: center;
}
.zap-split-label {
font-size: 0.9rem;
color: #ccc;
font-weight: 500;
}
.zap-split-slider {
width: 100%;
height: 8px;
background: linear-gradient(to right, #646cff 0%, #646cff 50%, #f97316 50%, #f97316 100%);
border-radius: 4px;
outline: none;
cursor: pointer;
-webkit-appearance: none;
appearance: none;
}
.zap-split-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
background: white;
border: 2px solid #646cff;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s;
}
.zap-split-slider::-moz-range-thumb {
width: 20px;
height: 20px;
background: white;
border: 2px solid #646cff;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s;
}
.zap-split-slider::-webkit-slider-thumb:hover {
transform: scale(1.1);
box-shadow: 0 0 8px rgba(100, 108, 255, 0.5);
}
.zap-split-slider::-moz-range-thumb:hover {
transform: scale(1.1);
box-shadow: 0 0 8px rgba(100, 108, 255, 0.5);
}
.zap-split-description {
font-size: 0.8rem;
color: #999;
line-height: 1.4;
text-align: left;
}
.zap-preset-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.zap-preset-btn {
padding: 0.5rem 1rem;
background: #2a2a2a;
border: 1px solid #444;
border-radius: 6px;
color: #ccc;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
flex: 1;
min-width: 80px;
}
.zap-preset-btn:hover {
background: #333;
border-color: #646cff;
color: white;
transform: translateY(-1px);
}
.zap-preset-btn:active {
transform: translateY(0);
}
.settings-footer {
display: flex;
justify-content: flex-start;
@@ -2095,3 +2329,426 @@ body {
opacity: 1;
}
}
/* Add Bookmark Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
padding: 1rem;
}
.modal-content {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
max-width: 500px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
box-sizing: border-box;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.5rem;
border-bottom: 1px solid #333;
}
.modal-header h2 {
margin: 0;
font-size: 1.5rem;
color: #fff;
}
.modal-form {
padding: 1.5rem;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
color: #ccc;
font-size: 0.9rem;
font-weight: 500;
}
.fetching-indicator {
font-size: 0.8rem;
color: #999;
font-weight: normal;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 0.75rem;
background: #2a2a2a;
border: 1px solid #444;
border-radius: 6px;
color: #fff;
font-size: 1rem;
font-family: inherit;
transition: border-color 0.2s;
box-sizing: border-box;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: #646cff;
}
.form-group input:disabled,
.form-group textarea:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.form-group textarea {
resize: vertical;
min-height: 80px;
}
.form-helper-text {
margin-top: 0.25rem;
font-size: 0.8rem;
color: #999;
line-height: 1.4;
}
.modal-error {
padding: 0.75rem;
background: rgba(220, 53, 69, 0.1);
border: 1px solid #dc3545;
border-radius: 6px;
color: #dc3545;
font-size: 0.9rem;
margin-bottom: 1rem;
}
.modal-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 1.5rem;
}
.btn-secondary {
padding: 0.75rem 1.5rem;
background: #2a2a2a;
border: 1px solid #444;
border-radius: 6px;
color: #ccc;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary:hover:not(:disabled) {
background: #333;
border-color: #646cff;
color: white;
}
.btn-secondary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
padding: 0.75rem 1.5rem;
background: #646cff;
border: none;
border-radius: 6px;
color: white;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-primary:hover:not(:disabled) {
background: #535bf2;
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Relay Status Indicator */
.relay-status-indicator {
position: fixed;
bottom: 1.5rem;
left: 1.5rem;
z-index: 999;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: rgba(245, 158, 11, 0.95);
border: 1px solid rgba(245, 158, 11, 0.4);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(10px);
transition: all 0.3s ease;
cursor: default;
}
.relay-status-indicator.connecting {
background: rgba(100, 108, 255, 0.15);
border: 1px solid rgba(100, 108, 255, 0.25);
}
.relay-status-indicator.connecting:hover {
background: rgba(100, 108, 255, 0.25);
}
.relay-status-indicator.connecting .relay-status-icon {
color: rgba(100, 108, 255, 0.9);
}
.relay-status-indicator.connecting .relay-status-title {
color: rgba(100, 108, 255, 1);
}
.relay-status-indicator:hover {
background: rgba(245, 158, 11, 1);
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
}
.relay-status-icon {
font-size: 1.25rem;
color: #1a1a1a;
display: flex;
align-items: center;
justify-content: center;
}
.relay-status-text {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.relay-status-title {
font-size: 0.875rem;
font-weight: 600;
color: #1a1a1a;
line-height: 1.2;
}
.relay-status-subtitle {
font-size: 0.75rem;
color: rgba(26, 26, 26, 0.8);
line-height: 1.2;
}
.relay-status-pulse {
display: flex;
align-items: center;
justify-content: center;
margin-left: 0.25rem;
}
.pulse-icon {
font-size: 0.875rem;
color: rgba(26, 26, 26, 0.6);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 0.4;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.1);
}
}
/* Adjust for collapsed sidebar */
.three-pane.sidebar-collapsed .relay-status-indicator {
left: calc(var(--sidebar-collapsed-width) + 1.5rem);
}
/* Explore Page Styles */
.explore-container {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
min-height: 100vh;
}
.explore-header {
text-align: center;
margin-bottom: 3rem;
}
.explore-header h1 {
font-size: 2.5rem;
margin: 0 0 1rem 0;
color: #646cff;
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
}
.explore-subtitle {
font-size: 1.125rem;
color: rgba(255, 255, 255, 0.7);
margin: 0;
}
.explore-loading,
.explore-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
min-height: 50vh;
color: rgba(255, 255, 255, 0.7);
}
.explore-error {
color: #ff6b6b;
}
.explore-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 2rem;
margin-top: 2rem;
}
.blog-post-card {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
overflow: hidden;
transition: all 0.3s ease;
cursor: pointer;
display: flex;
flex-direction: column;
height: 100%;
}
.blog-post-card:hover {
border-color: #646cff;
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(100, 108, 255, 0.15);
}
.blog-post-card-image {
width: 100%;
height: 200px;
overflow: hidden;
background: #0f0f0f;
}
.blog-post-card-image img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.blog-post-card:hover .blog-post-card-image img {
transform: scale(1.05);
}
.blog-post-card-content {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
flex: 1;
}
.blog-post-card-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: rgba(255, 255, 255, 0.95);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.blog-post-card-summary {
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.6);
margin: 0;
line-height: 1.6;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
flex: 1;
}
.blog-post-card-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding-top: 0.75rem;
border-top: 1px solid #333;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
flex-wrap: wrap;
}
.blog-post-card-author,
.blog-post-card-date {
display: flex;
align-items: center;
gap: 0.5rem;
}
.blog-post-card-author svg,
.blog-post-card-date svg {
opacity: 0.7;
}
@media (max-width: 768px) {
.explore-container {
padding: 1rem;
}
.explore-header h1 {
font-size: 2rem;
}
.explore-grid {
grid-template-columns: 1fr;
gap: 1.5rem;
}
}

View File

@@ -3,6 +3,32 @@ import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
// Register Service Worker for offline image caching
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/sw.js')
.then(registration => {
console.log('✅ Service Worker registered:', registration.scope)
// Update service worker when a new version is available
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'activated') {
console.log('🔄 Service Worker updated, page may need reload')
}
})
}
})
})
.catch(error => {
console.error('❌ Service Worker registration failed:', error)
})
})
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />

View File

@@ -5,6 +5,8 @@ import { AddressPointer } from 'nostr-tools/nip19'
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { RELAYS } from '../config/relays'
import { UserSettings } from './settingsService'
import { rebroadcastEvents } from './rebroadcastService'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
@@ -71,11 +73,13 @@ function saveToCache(naddr: string, content: ArticleContent): void {
* @param relayPool - The relay pool to query
* @param naddr - The article's naddr
* @param bypassCache - If true, skip cache and fetch fresh from relays
* @param settings - User settings for rebroadcast options
*/
export async function fetchArticleByNaddr(
relayPool: RelayPool,
naddr: string,
bypassCache = false
bypassCache = false,
settings?: UserSettings
): Promise<ArticleContent> {
try {
// Check cache first unless bypassed
@@ -120,6 +124,9 @@ export async function fetchArticleByNaddr(
events.sort((a, b) => b.created_at - a.created_at)
const article = events[0]
// Rebroadcast article to local/all relays based on settings
await rebroadcastEvents([article], relayPool, settings)
const title = getArticleTitle(article) || 'Untitled Article'
const image = getArticleImage(article)
const published = getArticlePublished(article)
@@ -138,6 +145,8 @@ export async function fetchArticleByNaddr(
// Save to cache before returning
saveToCache(naddr, content)
// Image caching is handled automatically by Service Worker
return content
} catch (err) {
console.error('Failed to fetch article:', err)

View File

@@ -14,13 +14,16 @@ import {
} from './bookmarkHelpers'
import { Bookmark } from '../types/bookmarks'
import { collectBookmarksFromEvents } from './bookmarkProcessing.ts'
import { UserSettings } from './settingsService'
import { rebroadcastEvents } from './rebroadcastService'
export const fetchBookmarks = async (
relayPool: RelayPool,
activeAccount: unknown, // Full account object with extension capabilities
setBookmarks: (bookmarks: Bookmark[]) => void
setBookmarks: (bookmarks: Bookmark[]) => void,
settings?: UserSettings
) => {
try {
@@ -37,6 +40,9 @@ export const fetchBookmarks = async (
.pipe(completeOnEose(), takeUntil(timer(20000)), toArray())
)
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)

View File

@@ -0,0 +1,87 @@
import { RelayPool, completeOnEose } from 'applesauce-relay'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
export interface BlogPostPreview {
event: NostrEvent
title: string
summary?: string
image?: string
published?: number
author: string
}
/**
* Fetches blog posts (kind:30023) from a list of pubkeys (friends)
* @param relayPool - The relay pool to query
* @param pubkeys - Array of pubkeys to fetch posts from
* @param relayUrls - Array of relay URLs to query
* @returns Array of blog post previews
*/
export const fetchBlogPostsFromAuthors = async (
relayPool: RelayPool,
pubkeys: string[],
relayUrls: string[]
): Promise<BlogPostPreview[]> => {
try {
if (pubkeys.length === 0) {
console.log('⚠️ No pubkeys to fetch blog posts from')
return []
}
console.log('📚 Fetching blog posts (kind 30023) from', pubkeys.length, 'authors')
const events = await lastValueFrom(
relayPool
.req(relayUrls, {
kinds: [30023],
authors: pubkeys,
limit: 100 // Fetch up to 100 recent posts
})
.pipe(completeOnEose(), takeUntil(timer(15000)), toArray())
)
console.log('📊 Blog post events fetched:', events.length)
// Deduplicate replaceable events by keeping the most recent version
// Group by author + d-tag identifier
const uniqueEvents = new Map<string, NostrEvent>()
for (const event of events) {
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${event.pubkey}:${dTag}`
const existing = uniqueEvents.get(key)
if (!existing || event.created_at > existing.created_at) {
uniqueEvents.set(key, event)
}
}
// Convert to blog post previews and sort by published date (most recent first)
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
.map(event => ({
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
}))
.sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA // Most recent first
})
console.log('📰 Processed', blogPosts.length, 'unique blog posts')
return blogPosts
} catch (error) {
console.error('Failed to fetch blog posts:', error)
return []
}
}

View File

@@ -3,9 +3,16 @@ import { RelayPool } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
import { AddressPointer } from 'nostr-tools/nip19'
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { Helpers, IEventStore } from 'applesauce-core'
import { RELAYS } from '../config/relays'
import { Highlight } from '../types/highlights'
import { UserSettings } from './settingsService'
import { areAllRelaysLocal } from '../utils/helpers'
import { markEventAsOfflineCreated } from './offlineSyncService'
// Boris pubkey for zap splits
// npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x
const BORIS_PUBKEY = '29dea8672f44ed164bfc83db3da5bd472001af70307f42277674cbc64d33013e'
const {
getHighlightText,
@@ -22,16 +29,18 @@ const { HighlightBlueprint } = Blueprints
/**
* Creates and publishes a highlight event (NIP-84)
* Supports both nostr-native articles and external URLs
* Returns the signed event for immediate UI updates
* Returns a Highlight object with relay tracking info for immediate UI updates
*/
export async function createHighlight(
selectedText: string,
source: NostrEvent | string,
account: IAccount,
relayPool: RelayPool,
eventStore: IEventStore,
contentForContext?: string,
comment?: string
): Promise<NostrEvent> {
comment?: string,
settings?: UserSettings
): Promise<Highlight> {
if (!selectedText || !source) {
throw new Error('Missing required data to create highlight')
}
@@ -67,21 +76,102 @@ export async function createHighlight(
// Update the alt tag to identify Boris as the creator
const altTagIndex = highlightEvent.tags.findIndex(tag => tag[0] === 'alt')
if (altTagIndex !== -1) {
highlightEvent.tags[altTagIndex] = ['alt', 'Highlight created by Boris. readwithboris.com']
highlightEvent.tags[altTagIndex] = ['alt', 'Highlight created by Boris. read.withboris.com']
} else {
highlightEvent.tags.push(['alt', 'Highlight created by Boris. readwithboris.com'])
highlightEvent.tags.push(['alt', 'Highlight created by Boris. read.withboris.com'])
}
// Add p tag (author tag) for nostr-native content
// This tags the original author so they can see highlights of their work
if (typeof source === 'object' && 'kind' in source) {
// Only add p tag if it doesn't already exist
const hasPTag = highlightEvent.tags.some(tag => tag[0] === 'p' && tag[1] === source.pubkey)
if (!hasPTag) {
highlightEvent.tags.push(['p', source.pubkey])
}
}
// Add zap tags for nostr-native content (NIP-57 Appendix G)
if (typeof source === 'object' && 'kind' in source) {
// Migrate old settings format to new weight-based format if needed
let highlighterWeight = settings?.zapSplitHighlighterWeight
let borisWeight = settings?.zapSplitBorisWeight
let authorWeight = settings?.zapSplitAuthorWeight
const anySettings = settings as Record<string, unknown> | undefined
if (!highlighterWeight && anySettings && 'zapSplitPercentage' in anySettings) {
highlighterWeight = anySettings.zapSplitPercentage as number
authorWeight = 100 - (anySettings.zapSplitPercentage as number)
}
if (!borisWeight && anySettings && 'borisSupportPercentage' in anySettings) {
borisWeight = anySettings.borisSupportPercentage as number
}
// Use defaults if still undefined
highlighterWeight = highlighterWeight ?? 50
borisWeight = borisWeight ?? 2.1
authorWeight = authorWeight ?? 50
addZapTags(highlightEvent, account.pubkey, source, highlighterWeight, borisWeight, authorWeight)
}
// Sign the event
const signedEvent = await factory.sign(highlightEvent)
// Publish to relays (including local relay)
await relayPool.publish(RELAYS, signedEvent)
// Publish to all configured relays - let the relay pool handle connection state
const targetRelays = RELAYS
console.log('✅ Highlight published to', RELAYS.length, 'relays (including local):', signedEvent)
// Store the event in the local EventStore FIRST for immediate UI display
eventStore.add(signedEvent)
console.log('💾 Stored highlight in EventStore:', signedEvent.id.slice(0, 8))
// Return the signed event for immediate UI updates
return signedEvent
// Check current connection status - are we online or in flight mode?
const connectedRelays = Array.from(relayPool.relays.values())
.filter(relay => relay.connected)
.map(relay => relay.url)
const hasRemoteConnection = connectedRelays.some(url =>
!url.includes('localhost') && !url.includes('127.0.0.1')
)
// Determine which relays we expect to succeed
const expectedSuccessRelays = hasRemoteConnection
? RELAYS
: RELAYS.filter(r => r.includes('localhost') || r.includes('127.0.0.1'))
const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays)
console.log('📍 Highlight relay status:', {
targetRelays: targetRelays.length,
expectedSuccessRelays,
isLocalOnly,
hasRemoteConnection,
eventId: signedEvent.id
})
// If we're in local-only mode, mark this event for later sync
if (isLocalOnly) {
markEventAsOfflineCreated(signedEvent.id)
}
// Convert to Highlight with relay tracking info and return IMMEDIATELY
const highlight = eventToHighlight(signedEvent)
highlight.publishedRelays = expectedSuccessRelays // Show only relays we expect to succeed
highlight.isLocalOnly = isLocalOnly
highlight.isOfflineCreated = isLocalOnly // Mark as created offline if local-only
// Publish to relays in the background (non-blocking)
// This allows instant UI updates while publishing happens asynchronously
relayPool.publish(targetRelays, signedEvent)
.then(() => {
console.log('✅ Highlight published to', targetRelays.length, 'relay(s):', targetRelays)
})
.catch((error) => {
console.warn('⚠️ Failed to publish highlight to relays (event still saved locally):', error)
})
// Return the highlight immediately for instant UI updates
return highlight
}
/**
@@ -176,6 +266,76 @@ function extractContext(selectedText: string, articleContent: string): string |
return contextParts.length > 1 ? contextParts.join(' ') : undefined
}
/**
* Adds zap tags to a highlight event for split payments (NIP-57 Appendix G)
* Respects existing zap tags in the source event (author group)
* @param event The highlight event to add zap tags to (can be EventTemplate or NostrEvent)
* @param highlighterPubkey The pubkey of the user creating the highlight
* @param sourceEvent The source event (may contain existing zap tags)
* @param highlighterWeight Weight to give to the highlighter (default 50)
* @param borisWeight Weight to give to Boris (default 2.1)
* @param authorWeight Weight to give to author(s) (default 50)
*/
function addZapTags(
event: { tags: string[][] },
highlighterPubkey: string,
sourceEvent: NostrEvent,
highlighterWeight: number = 50,
borisWeight: number = 2.1,
authorWeight: number = 50
): void {
// Use a reliable relay for zap metadata lookup (first non-local relay)
const zapRelay = RELAYS.find(r => !r.includes('localhost')) || RELAYS[0]
// Extract existing zap tags from source event (the "author group")
const existingZapTags = sourceEvent.tags.filter(tag => tag[0] === 'zap')
// Add zap tag for the highlighter
if (highlighterWeight > 0) {
event.tags.push(['zap', highlighterPubkey, zapRelay, highlighterWeight.toString()])
}
// Add zap tag for Boris (if weight > 0 and Boris is not the highlighter)
if (borisWeight > 0 && BORIS_PUBKEY !== highlighterPubkey) {
event.tags.push(['zap', BORIS_PUBKEY, zapRelay, borisWeight.toFixed(1)])
}
if (existingZapTags.length > 0 && authorWeight > 0) {
// Calculate total weight from existing zap tags
const totalExistingWeight = existingZapTags.reduce((sum, tag) => {
const weight = parseFloat(tag[3] || '1')
return sum + weight
}, 0)
// Add proportionally adjusted zap tags for each existing author
// Don't add the highlighter or Boris again if they're already in the author group
for (const zapTag of existingZapTags) {
const authorPubkey = zapTag[1]
// Skip if this is the highlighter or Boris (they already have their shares)
if (authorPubkey === highlighterPubkey || authorPubkey === BORIS_PUBKEY) continue
const originalWeight = parseFloat(zapTag[3] || '1')
const originalRelay = zapTag[2] || zapRelay
// Calculate proportional weight: (original weight / total weight) * author group weight
const adjustedWeight = (originalWeight / totalExistingWeight) * authorWeight
// Only add if weight is greater than 0
if (adjustedWeight > 0) {
event.tags.push(['zap', authorPubkey, originalRelay, adjustedWeight.toFixed(1)])
}
}
} else if (authorWeight > 0) {
// No existing zap tags, give full author weight to source author
// Add zap tag for the original author (only if different from highlighter and Boris)
if (sourceEvent.pubkey !== highlighterPubkey && sourceEvent.pubkey !== BORIS_PUBKEY) {
event.tags.push(['zap', sourceEvent.pubkey, zapRelay, authorWeight.toFixed(1)])
}
}
}
/**
* Converts a NostrEvent to a Highlight object for immediate UI display
*/

View File

@@ -4,18 +4,23 @@ import { NostrEvent } from 'nostr-tools'
import { Highlight } from '../types/highlights'
import { RELAYS } from '../config/relays'
import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor'
import { UserSettings } from './settingsService'
import { rebroadcastEvents } from './rebroadcastService'
/**
* Fetches highlights for a specific article by its address coordinate and/or event ID
* @param relayPool - The relay pool to query
* @param articleCoordinate - The article's address in format "kind:pubkey:identifier" (e.g., "30023:abc...def:my-article")
* @param eventId - Optional event ID to also query by 'e' tag
* @param onHighlight - Optional callback to receive highlights as they arrive
* @param settings - User settings for rebroadcast options
*/
export const fetchHighlightsForArticle = async (
relayPool: RelayPool,
articleCoordinate: string,
eventId?: string,
onHighlight?: (highlight: Highlight) => void
onHighlight?: (highlight: Highlight) => void,
settings?: UserSettings
): Promise<Highlight[]> => {
try {
console.log('🔍 Fetching highlights (kind 9802) for article:', articleCoordinate)
@@ -75,6 +80,9 @@ export const fetchHighlightsForArticle = async (
const rawEvents = [...aTagEvents, ...eTagEvents]
console.log('📊 Total raw highlight events fetched:', rawEvents.length)
// Rebroadcast highlight events to local/all relays based on settings
await rebroadcastEvents(rawEvents, relayPool, settings)
if (rawEvents.length > 0) {
console.log('📄 Sample highlight tags:', JSON.stringify(rawEvents[0].tags, null, 2))
} else {
@@ -99,10 +107,12 @@ export const fetchHighlightsForArticle = async (
* Fetches highlights for a specific URL
* @param relayPool - The relay pool to query
* @param url - The external URL to find highlights for
* @param settings - User settings for rebroadcast options
*/
export const fetchHighlightsForUrl = async (
relayPool: RelayPool,
url: string
url: string,
settings?: UserSettings
): Promise<Highlight[]> => {
try {
console.log('🔍 Fetching highlights (kind 9802) for URL:', url)
@@ -124,6 +134,9 @@ export const fetchHighlightsForUrl = async (
console.log('📊 Highlights for URL:', rawEvents.length)
// Rebroadcast highlight events to local/all relays based on settings
await rebroadcastEvents(rawEvents, relayPool, settings)
const uniqueEvents = dedupeHighlights(rawEvents)
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
return sortHighlights(highlights)
@@ -138,11 +151,13 @@ export const fetchHighlightsForUrl = async (
* @param relayPool - The relay pool to query
* @param pubkey - The user's public key
* @param onHighlight - Optional callback to receive highlights as they arrive
* @param settings - User settings for rebroadcast options
*/
export const fetchHighlights = async (
relayPool: RelayPool,
pubkey: string,
onHighlight?: (highlight: Highlight) => void
onHighlight?: (highlight: Highlight) => void,
settings?: UserSettings
): Promise<Highlight[]> => {
try {
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
@@ -172,6 +187,9 @@ export const fetchHighlights = async (
console.log('📊 Raw highlight events fetched:', rawEvents.length)
// Rebroadcast highlight events to local/all relays based on settings
await rebroadcastEvents(rawEvents, relayPool, settings)
// Deduplicate and process events
const uniqueEvents = dedupeHighlights(rawEvents)
console.log('📊 Unique highlight events after deduplication:', uniqueEvents.length)

View File

@@ -0,0 +1,74 @@
/**
* Image Cache Service
*
* Utility functions for managing the Service Worker's image cache
* Service Worker automatically caches images on fetch
*/
const CACHE_NAME = 'boris-image-cache-v1'
/**
* Clear all cached images
*/
export async function clearImageCache(): Promise<void> {
try {
await caches.delete(CACHE_NAME)
console.log('🗑️ Cleared all cached images')
} catch (err) {
console.error('Failed to clear image cache:', err)
}
}
/**
* Get cache statistics by inspecting Cache API directly
*/
export async function getImageCacheStatsAsync(): Promise<{
totalSizeMB: number
itemCount: number
items: Array<{ url: string, sizeMB: number }>
}> {
try {
const cache = await caches.open(CACHE_NAME)
const requests = await cache.keys()
let totalSize = 0
const items: Array<{ url: string, sizeMB: number }> = []
for (const request of requests) {
const response = await cache.match(request)
if (response) {
const blob = await response.blob()
const sizeMB = blob.size / (1024 * 1024)
totalSize += blob.size
items.push({ url: request.url, sizeMB })
}
}
return {
totalSizeMB: totalSize / (1024 * 1024),
itemCount: requests.length,
items
}
} catch (err) {
console.error('Failed to get cache stats:', err)
return { totalSizeMB: 0, itemCount: 0, items: [] }
}
}
/**
* Synchronous wrapper for cache stats (returns approximate values)
* For real-time stats, use getImageCacheStatsAsync
*/
export function getImageCacheStats(): {
totalSizeMB: number
itemCount: number
items: Array<{ url: string, sizeMB: number, lastAccessed: Date }>
} {
// Return placeholder - actual stats require async Cache API access
// Component should use getImageCacheStatsAsync for real values
return {
totalSizeMB: 0,
itemCount: 0,
items: []
}
}

View File

@@ -0,0 +1,158 @@
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { IEventStore } from 'applesauce-core'
import { RELAYS } from '../config/relays'
import { isLocalRelay } from '../utils/helpers'
let isSyncing = false
// Track events created during offline period
const offlineCreatedEvents = new Set<string>()
// Track events currently being synced
const syncingEvents = new Set<string>()
// Callbacks to notify when sync state changes
const syncStateListeners: Array<(eventId: string, isSyncing: boolean) => void> = []
/**
* Marks an event as created during offline period
*/
export function markEventAsOfflineCreated(eventId: string): void {
offlineCreatedEvents.add(eventId)
console.log(`📝 Marked event ${eventId.slice(0, 8)} as offline-created. Total: ${offlineCreatedEvents.size}`)
}
/**
* Check if an event is currently being synced
*/
export function isEventSyncing(eventId: string): boolean {
return syncingEvents.has(eventId)
}
/**
* Subscribe to sync state changes
*/
export function onSyncStateChange(callback: (eventId: string, isSyncing: boolean) => void): () => void {
syncStateListeners.push(callback)
return () => {
const index = syncStateListeners.indexOf(callback)
if (index > -1) syncStateListeners.splice(index, 1)
}
}
/**
* Notify listeners of sync state change
*/
function notifySyncStateChange(eventId: string, isSyncing: boolean): void {
syncStateListeners.forEach(listener => listener(eventId, isSyncing))
}
/**
* Syncs local-only events to remote relays when coming back online
* Now uses applesauce EventStore instead of querying relays
*/
export async function syncLocalEventsToRemote(
relayPool: RelayPool,
eventStore: IEventStore
): Promise<void> {
if (isSyncing) {
console.log('⏳ Sync already in progress, skipping...')
return
}
console.log('🔄 Coming back online - syncing local events to remote relays...')
console.log(`📦 Offline events tracked: ${offlineCreatedEvents.size}`)
isSyncing = true
try {
const remoteRelays = RELAYS.filter(url => !isLocalRelay(url))
console.log(`📡 Remote relays: ${remoteRelays.length}`)
if (remoteRelays.length === 0) {
console.log('⚠️ No remote relays available for sync')
isSyncing = false
return
}
if (offlineCreatedEvents.size === 0) {
console.log('✅ No offline events to sync')
isSyncing = false
return
}
// Get events from EventStore using the tracked IDs
const eventsToSync: NostrEvent[] = []
console.log(`🔍 Querying EventStore for ${offlineCreatedEvents.size} offline events...`)
for (const eventId of offlineCreatedEvents) {
const event = eventStore.getEvent(eventId)
if (event) {
console.log(`📥 Found event ${eventId.slice(0, 8)} (kind ${event.kind}) in EventStore`)
eventsToSync.push(event)
} else {
console.warn(`⚠️ Event ${eventId.slice(0, 8)} not found in EventStore`)
}
}
console.log(`📊 Total events to sync: ${eventsToSync.length}`)
if (eventsToSync.length === 0) {
console.log('✅ No events found in EventStore to sync')
isSyncing = false
offlineCreatedEvents.clear()
return
}
// Deduplicate events by id
const uniqueEvents = Array.from(
new Map(eventsToSync.map(e => [e.id, e])).values()
)
console.log(`📤 Syncing ${uniqueEvents.length} event(s) to remote relays...`)
// Mark all events as syncing
uniqueEvents.forEach(event => {
syncingEvents.add(event.id)
notifySyncStateChange(event.id, true)
})
// Publish to remote relays
let successCount = 0
const successfulIds: string[] = []
for (const event of uniqueEvents) {
try {
await relayPool.publish(remoteRelays, event)
successCount++
successfulIds.push(event.id)
console.log(`✅ Synced event ${event.id.slice(0, 8)}`)
} catch (error) {
console.warn(`⚠️ Failed to sync event ${event.id.slice(0, 8)}:`, error)
}
}
console.log(`✅ Synced ${successCount}/${uniqueEvents.length} events to remote relays`)
// Clear syncing state and offline tracking for successful events
successfulIds.forEach(eventId => {
syncingEvents.delete(eventId)
offlineCreatedEvents.delete(eventId)
notifySyncStateChange(eventId, false)
})
// Clear syncing state for failed events
uniqueEvents.forEach(event => {
if (!successfulIds.includes(event.id)) {
syncingEvents.delete(event.id)
notifySyncStateChange(event.id, false)
}
})
} catch (error) {
console.error('❌ Error during offline sync:', error)
} finally {
isSyncing = false
}
}

View File

@@ -7,6 +7,8 @@ export interface ReadableContent {
html?: string
markdown?: string
image?: string
summary?: string
published?: number
}
interface CachedContent {
@@ -57,7 +59,7 @@ function saveToCache(url: string, content: ReadableContent): void {
function toProxyUrl(url: string): string {
// Ensure the target URL has a protocol and build the proxy URL
const normalized = /^https?:\/\//i.test(url) ? url : `https://${url}`
return `https://r.jina.ai/http://${normalized.replace(/^https?:\/\//, '')}`
return `https://r.jina.ai/${normalized}`
}
export async function fetchReadableContent(

View File

@@ -0,0 +1,78 @@
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { UserSettings } from './settingsService'
import { RELAYS } from '../config/relays'
import { isLocalRelay } from '../utils/helpers'
/**
* Rebroadcasts events to relays based on user settings
* @param events Events to rebroadcast
* @param relayPool The relay pool to use for publishing
* @param settings User settings to determine which relays to broadcast to
*/
export async function rebroadcastEvents(
events: NostrEvent[],
relayPool: RelayPool,
settings?: UserSettings
): Promise<void> {
if (!events || events.length === 0) {
return
}
// Check if any rebroadcast is enabled
const useLocalCache = settings?.useLocalRelayAsCache ?? true
const broadcastToAll = settings?.rebroadcastToAllRelays ?? false
if (!useLocalCache && !broadcastToAll) {
return // No rebroadcast enabled
}
// Check current relay connectivity - don't rebroadcast in flight mode
const connectedRelays = Array.from(relayPool.relays.values())
const connectedRemoteRelays = connectedRelays.filter(relay => relay.connected && !isLocalRelay(relay.url))
const hasRemoteConnection = connectedRemoteRelays.length > 0
// If we're in flight mode (only local relays connected) and user wants to broadcast to all relays, skip
if (broadcastToAll && !hasRemoteConnection) {
console.log('✈️ Flight mode: skipping rebroadcast to remote relays')
return
}
// Determine target relays based on settings
let targetRelays: string[] = []
if (broadcastToAll) {
// Broadcast to all relays (only if we have remote connection)
targetRelays = RELAYS
} else if (useLocalCache) {
// Only broadcast to local relays
targetRelays = RELAYS.filter(isLocalRelay)
}
if (targetRelays.length === 0) {
console.log('📡 No target relays for rebroadcast')
return
}
// Rebroadcast each event
const rebroadcastPromises = events.map(async (event) => {
try {
await relayPool.publish(targetRelays, event)
console.log('📡 Rebroadcast event', event.id?.slice(0, 8), 'to', targetRelays.length, 'relay(s)')
} catch (error) {
console.warn('⚠️ Failed to rebroadcast event', event.id?.slice(0, 8), error)
}
})
// Execute all rebroadcasts (don't block on completion)
Promise.all(rebroadcastPromises).catch((err) => {
console.warn('⚠️ Some rebroadcasts failed:', err)
})
console.log(`📡 Rebroadcasting ${events.length} event(s) to ${targetRelays.length} relay(s)`, {
broadcastToAll,
useLocalCache,
targetRelays
})
}

View File

@@ -0,0 +1,85 @@
import { RelayPool } from 'applesauce-relay'
export interface RelayStatus {
url: string
isInPool: boolean
lastSeen: number // timestamp
}
// How long to show disconnected relays as "recently seen" before hiding them
const RECENT_CONNECTION_WINDOW = 10 * 1000 // 10 seconds
// In-memory tracking of relay last seen times
const relayLastSeen = new Map<string, number>()
/**
* Updates and gets the current status of all relays
*/
export function updateAndGetRelayStatuses(relayPool: RelayPool): RelayStatus[] {
const statuses: RelayStatus[] = []
const now = Date.now()
const currentlyConnectedUrls = new Set<string>()
// Check all relays in the pool for their actual connection status
for (const relay of relayPool.relays.values()) {
const isConnected = relay.connected
if (isConnected) {
currentlyConnectedUrls.add(relay.url)
relayLastSeen.set(relay.url, now)
}
statuses.push({
url: relay.url,
isInPool: isConnected,
lastSeen: isConnected ? now : (relayLastSeen.get(relay.url) || now)
})
}
// Debug logging
const connectedCount = statuses.filter(s => s.isInPool).length
const disconnectedCount = statuses.filter(s => !s.isInPool).length
if (connectedCount === 0 || disconnectedCount > 0) {
console.log(`🔌 Relay status: ${connectedCount} connected, ${disconnectedCount} disconnected`)
const connected = statuses.filter(s => s.isInPool).map(s => s.url.replace(/^wss?:\/\//, ''))
const disconnected = statuses.filter(s => !s.isInPool).map(s => s.url.replace(/^wss?:\/\//, ''))
if (connected.length > 0) console.log('✅ Connected:', connected.join(', '))
if (disconnected.length > 0) console.log('❌ Disconnected:', disconnected.join(', '))
}
// Add recently seen relays that are no longer connected
const cutoffTime = now - RECENT_CONNECTION_WINDOW
for (const [url, lastSeen] of relayLastSeen.entries()) {
if (!currentlyConnectedUrls.has(url) && lastSeen >= cutoffTime) {
// Check if this relay is already in statuses (might be in pool but not connected)
const existingStatus = statuses.find(s => s.url === url)
if (!existingStatus) {
statuses.push({
url,
isInPool: false,
lastSeen
})
}
}
}
// Clean up old entries
for (const [url, lastSeen] of relayLastSeen.entries()) {
if (lastSeen < cutoffTime) {
relayLastSeen.delete(url)
}
}
return statuses.sort((a, b) => {
if (a.isInPool !== b.isInPool) return a.isInPool ? -1 : 1
return b.lastSeen - a.lastSeen
})
}
/**
* Gets count of currently active relays
*/
export function getActiveCount(statuses: RelayStatus[]): number {
return statuses.filter(r => r.isInPool).length
}

View File

@@ -35,6 +35,16 @@ export interface UserSettings {
defaultHighlightVisibilityNostrverse?: boolean
defaultHighlightVisibilityFriends?: boolean
defaultHighlightVisibilityMine?: boolean
// Zap split weights (treated as relative weights, not strict percentages)
zapSplitHighlighterWeight?: number // default 50
zapSplitBorisWeight?: number // default 2.1
zapSplitAuthorWeight?: number // default 50
// Relay rebroadcast settings
useLocalRelayAsCache?: boolean // Rebroadcast events to local relays
rebroadcastToAllRelays?: boolean // Rebroadcast events to all relays
// Image cache settings
enableImageCache?: boolean // Enable caching images in localStorage
imageCacheSizeMB?: number // Maximum cache size in megabytes (default: 210MB)
}
export async function loadSettings(

View File

@@ -0,0 +1,90 @@
import { EventFactory } from 'applesauce-factory'
import { RelayPool } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
/**
* Creates a web bookmark event (NIP-B0, kind:39701)
* @param url The URL to bookmark
* @param title Optional title for the bookmark
* @param description Optional description (goes in content field)
* @param bookmarkTags Optional array of tags/hashtags
* @param account The user's account for signing
* @param relayPool The relay pool for publishing
* @param relays The relays to publish to
* @returns The signed event
*/
export async function createWebBookmark(
url: string,
title: string | undefined,
description: string | undefined,
bookmarkTags: string[] | undefined,
account: IAccount,
relayPool: RelayPool,
relays: string[]
): Promise<NostrEvent> {
if (!url || !url.trim()) {
throw new Error('URL is required for web bookmark')
}
// Validate URL format and extract the URL without scheme for d tag
let parsedUrl: URL
try {
parsedUrl = new URL(url)
} catch {
throw new Error('Invalid URL format')
}
// d tag should be URL without scheme (as per NIP-B0)
const dTagValue = parsedUrl.host + parsedUrl.pathname + parsedUrl.search + parsedUrl.hash
const factory = new EventFactory({ signer: account })
const now = Math.floor(Date.now() / 1000)
// Build tags according to NIP-B0
const tags: string[][] = [
['d', dTagValue], // URL without scheme as identifier
]
// Add published_at tag (current timestamp)
tags.push(['published_at', now.toString()])
// Add title tag if provided
if (title && title.trim()) {
tags.push(['title', title.trim()])
}
// Add t tags for each bookmark tag/hashtag
if (bookmarkTags && bookmarkTags.length > 0) {
bookmarkTags.forEach(tag => {
const trimmedTag = tag.trim()
if (trimmedTag) {
tags.push(['t', trimmedTag])
}
})
}
// Create the event with description in content field (as per NIP-B0)
const draft = await factory.create(async () => ({
kind: 39701, // NIP-B0 web bookmark
content: description?.trim() || '',
tags,
created_at: now
}))
// Sign the event
const signedEvent = await factory.sign(draft)
// Publish to relays in the background (don't block UI)
relayPool.publish(relays, signedEvent)
.then(() => {
console.log('✅ Web bookmark published to', relays.length, 'relays:', signedEvent)
})
.catch((err) => {
console.warn('⚠️ Some relays failed to publish bookmark:', err)
})
// Return immediately so UI doesn't block
return signedEvent
}

View File

@@ -15,5 +15,11 @@ export interface Highlight {
comment?: string // optional comment about the highlight
// Level classification (computed based on user's context)
level?: HighlightLevel
// Relay tracking for offline/local-only highlights
publishedRelays?: string[] // URLs of relays where this was published (for user-created highlights)
seenOnRelays?: string[] // URLs of relays where this event was fetched from
isLocalOnly?: boolean // true if only published to local relays
isOfflineCreated?: boolean // true if created while in flight mode (offline)
isSyncing?: boolean // true if currently being synced to remote relays
}

View File

@@ -1,5 +1,5 @@
import React from 'react'
import { formatDistanceToNow } from 'date-fns'
import { formatDistanceToNow, differenceInSeconds, differenceInMinutes, differenceInHours, differenceInDays, differenceInMonths, differenceInYears } from 'date-fns'
import { ParsedContent, ParsedNode } from '../types/bookmarks'
import ResolvedMention from '../components/ResolvedMention'
// Note: ContentWithResolvedProfiles is imported by components directly to keep this file component-only for fast refresh
@@ -9,6 +9,26 @@ export const formatDate = (timestamp: number) => {
return formatDistanceToNow(date, { addSuffix: true })
}
// Ultra-compact date format for tight spaces (e.g., compact view)
export const formatDateCompact = (timestamp: number) => {
const date = new Date(timestamp * 1000)
const now = new Date()
const seconds = differenceInSeconds(now, date)
const minutes = differenceInMinutes(now, date)
const hours = differenceInHours(now, date)
const days = differenceInDays(now, date)
const months = differenceInMonths(now, date)
const years = differenceInYears(now, date)
if (seconds < 60) return 'now'
if (minutes < 60) return `${minutes}m`
if (hours < 24) return `${hours}h`
if (days < 30) return `${days}d`
if (months < 12) return `${months}mo`
return `${years}y`
}
// Component to render content with resolved nprofile names
// Intentionally no exports except components and render helpers

View File

@@ -32,6 +32,7 @@ export async function loadContent(
title: article.title,
markdown: article.markdown,
image: article.image,
summary: article.summary,
url: `nostr:${naddr}`
}
} else {

View File

@@ -40,3 +40,26 @@ export const classifyUrl = (url: string | undefined): UrlClassification => {
return { type: 'article', buttonText: 'READ NOW' }
}
/**
* Checks if a relay URL is a local relay (localhost or 127.0.0.1)
*/
export const isLocalRelay = (relayUrl: string): boolean => {
return relayUrl.includes('localhost') || relayUrl.includes('127.0.0.1')
}
/**
* Checks if all relays in the list are local relays
*/
export const areAllRelaysLocal = (relayUrls: string[]): boolean => {
if (!relayUrls || relayUrls.length === 0) return false
return relayUrls.every(isLocalRelay)
}
/**
* Checks if at least one relay is a remote (non-local) relay
*/
export const hasRemoteRelay = (relayUrls: string[]): boolean => {
if (!relayUrls || relayUrls.length === 0) return false
return relayUrls.some(url => !isLocalRelay(url))
}