Compare commits

..

108 Commits

Author SHA1 Message Date
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
45 changed files with 2947 additions and 213 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%

455
CHANGELOG.md Normal file
View File

@@ -0,0 +1,455 @@
# 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.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.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

@@ -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

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.6",
"version": "0.3.0",
"description": "A minimal nostr client for bookmark management",
"homepage": "https://xn--bris-v0b.com/",
"homepage": "https://read.withboris.com/",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -52,6 +52,15 @@ function AppRoutes({
/>
}
/>
<Route
path="/settings"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
</Routes>
)
@@ -109,6 +118,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 +147,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

@@ -1,6 +1,7 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
import { RelayPool } from 'applesauce-relay'
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
import { BookmarkItem } from './BookmarkItem'
import SidebarHeader from './SidebarHeader'
@@ -21,6 +22,7 @@ interface BookmarkListProps {
onRefresh?: () => void
isRefreshing?: boolean
loading?: boolean
relayPool: RelayPool | null
}
export const BookmarkList: React.FC<BookmarkListProps> = ({
@@ -35,7 +37,8 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
onOpenSettings,
onRefresh,
isRefreshing,
loading = false
loading = false,
relayPool
}) => {
// Helper to check if a bookmark has either content or a URL
const hasContentOrUrl = (ib: IndividualBookmark) => {
@@ -98,6 +101,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
onOpenSettings={onOpenSettings}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
relayPool={relayPool}
/>
{loading ? (

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,6 +10,8 @@ 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 { classifyHighlights } from '../utils/highlightClassification'
@@ -23,10 +25,21 @@ 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'
// 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 +52,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 +75,6 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
setShowHighlights,
selectedHighlightId,
setSelectedHighlightId,
showSettings,
setShowSettings,
currentArticleCoordinate,
setCurrentArticleCoordinate,
currentArticleEventId,
@@ -79,7 +102,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
accountManager,
naddr,
currentArticleCoordinate,
currentArticleEventId
currentArticleEventId,
settings
})
const {
@@ -94,7 +118,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
relayPool,
settings,
setIsCollapsed,
setShowSettings,
setShowSettings: () => {}, // No-op since we use route-based settings now
setCurrentArticle
})
@@ -106,6 +130,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
} = useHighlightCreation({
activeAccount,
relayPool,
eventStore,
currentArticle,
selectedUrl,
readerContent,
@@ -125,7 +150,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
setHighlightsLoading,
setCurrentArticleCoordinate,
setCurrentArticleEventId,
setCurrentArticle
setCurrentArticle,
settings
})
// Load external URL if /r/* route is used
@@ -160,17 +186,23 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
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}

View File

@@ -19,6 +19,8 @@ interface ContentPanelProps {
markdown?: string
selectedUrl?: string
image?: string
summary?: string
published?: number
highlights?: Highlight[]
showHighlights?: boolean
highlightStyle?: 'marker' | 'underline'
@@ -40,6 +42,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
markdown,
selectedUrl,
image,
summary,
published,
highlights = [],
showHighlights = true,
highlightStyle = 'marker',
@@ -117,6 +121,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
<ReaderHeader
title={title}
image={image}
summary={summary}
published={published}
readingTimeText={readingStats ? readingStats.text : null}
hasHighlights={hasHighlights}
highlightCount={relevantHighlights.length}

View File

@@ -1,10 +1,14 @@
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'
interface HighlightWithLevel extends Highlight {
level?: 'mine' | 'friends' | 'nostrverse'
@@ -15,10 +19,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 +48,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' })
@@ -58,6 +109,105 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelec
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 +218,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">

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,10 +1,13 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faClock } from '@fortawesome/free-solid-svg-icons'
import { format } from 'date-fns'
interface ReaderHeaderProps {
title?: string
image?: string
summary?: string
published?: number
readingTimeText?: string | null
hasHighlights: boolean
highlightCount: number
@@ -13,20 +16,57 @@ interface ReaderHeaderProps {
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
title,
image,
summary,
published,
readingTimeText,
hasHighlights,
highlightCount
}) => {
const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null
if (image) {
return (
<div className="reader-hero-image">
<img src={image} 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,73 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faPlane, faGlobe, faCircle } 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 for relay status updates
const relayStatuses = useRelayStatus({ relayPool })
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
// Debug logging
React.useEffect(() => {
if (localOnlyMode || offlineMode) {
console.log('✈️ Relay Status Indicator:', {
mode: offlineMode ? 'OFFLINE' : 'LOCAL_ONLY',
connectedUrls,
hasLocalRelay,
hasRemoteRelay
})
}
}, [localOnlyMode, offlineMode, connectedUrls.length])
// Don't show indicator when fully connected
if (!localOnlyMode && !offlineMode) return null
return (
<div className="relay-status-indicator" title={
offlineMode
? 'Offline - No relays connected'
: 'Local Relays Only - Highlights will be marked as local'
}>
<div className="relay-status-icon">
<FontAwesomeIcon icon={offlineMode ? faCircle : faPlane} />
</div>
<div className="relay-status-text">
{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 && (
<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,21 +28,57 @@ const DEFAULT_SETTINGS: UserSettings = {
defaultHighlightVisibilityNostrverse: true,
defaultHighlightVisibilityFriends: true,
defaultHighlightVisibilityMine: true,
zapSplitPercentage: 50,
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(() => {
@@ -58,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 = () => {
@@ -97,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,104 @@
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { UserSettings } from '../../services/settingsService'
interface OfflineModeSettingsProps {
settings: UserSettings
onUpdate: (updates: Partial<UserSettings>) => void
onClose?: () => void
}
const OfflineModeSettings: React.FC<OfflineModeSettingsProps> = ({ settings, onUpdate, onClose }) => {
const navigate = useNavigate()
const handleLinkClick = (url: string) => {
if (onClose) onClose()
navigate(`/r/${encodeURIComponent(url)}`)
}
return (
<div className="settings-section">
<h3 className="section-title">Flight Mode</h3>
<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 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"
/>
<span>Rebroadcast events while browsing</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,41 +99,66 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
</div>
</div>
<div className="setting-group">
<label className="setting-label">Zap Split for Highlights</label>
<div className="zap-split-container">
<div className="zap-split-labels">
<span className="zap-split-label">You: {settings.zapSplitPercentage ?? 50}%</span>
<span className="zap-split-label">Author: {100 - (settings.zapSplitPercentage ?? 50)}%</span>
</div>
<input
type="range"
min="0"
max="100"
value={settings.zapSplitPercentage ?? 50}
onChange={(e) => onUpdate({ zapSplitPercentage: parseInt(e.target.value) })}
className="zap-split-slider"
/>
<div className="zap-split-description">
When highlighting nostr-native content, zaps will be split between you and the author.
</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
@@ -38,33 +36,6 @@ const StartupPreferencesSettings: React.FC<StartupPreferencesSettingsProps> = ({
<span>Start with highlights panel collapsed</span>
</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'}
/>
<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>
</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,12 +1,16 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faRotate, faHome } from '@fortawesome/free-solid-svg-icons'
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faRotate, faHome, faPlus } 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
@@ -14,10 +18,12 @@ interface SidebarHeaderProps {
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, onRefresh, isRefreshing, relayPool }) => {
const [isConnecting, setIsConnecting] = useState(false)
const [showAddModal, setShowAddModal] = useState(false)
const navigate = useNavigate()
const activeAccount = Hooks.useActiveAccount()
const accountManager = Hooks.useAccountManager()
@@ -49,6 +55,19 @@ 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)
// Refresh bookmarks after creating
if (onRefresh) {
onRefresh()
}
}
const profileImage = getProfileImage()
return (
@@ -63,6 +82,18 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
<FontAwesomeIcon icon={faChevronRight} />
</button>
<div className="sidebar-header-right">
<div
className="profile-avatar"
title={activeAccount ? getUserDisplayName() : "Login"}
onClick={!activeAccount ? (isConnecting ? () => {} : handleLogin) : undefined}
style={{ cursor: !activeAccount ? 'pointer' : 'default' }}
>
{profileImage ? (
<img src={profileImage} alt={getUserDisplayName()} />
) : (
<FontAwesomeIcon icon={faUserCircle} />
)}
</div>
<IconButton
icon={faHome}
onClick={() => navigate('/')}
@@ -70,6 +101,13 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
ariaLabel="Home"
variant="ghost"
/>
<IconButton
icon={faGear}
onClick={onOpenSettings}
title="Settings"
ariaLabel="Settings"
variant="ghost"
/>
{onRefresh && (
<IconButton
icon={faRotate}
@@ -81,25 +119,15 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
spin={isRefreshing}
/>
)}
<IconButton
icon={faGear}
onClick={onOpenSettings}
title="Settings"
ariaLabel="Settings"
variant="ghost"
/>
<div
className="profile-avatar"
title={activeAccount ? getUserDisplayName() : "Login"}
onClick={!activeAccount ? (isConnecting ? () => {} : handleLogin) : undefined}
style={{ cursor: !activeAccount ? 'pointer' : 'default' }}
>
{profileImage ? (
<img src={profileImage} alt={getUserDisplayName()} />
) : (
<FontAwesomeIcon icon={faUserCircle} />
)}
</div>
{activeAccount && (
<IconButton
icon={faPlus}
onClick={() => setShowAddModal(true)}
title="Add bookmark"
ariaLabel="Add bookmark"
variant="ghost"
/>
)}
{activeAccount ? (
<IconButton
icon={faRightFromBracket}
@@ -119,6 +147,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'
@@ -30,6 +33,8 @@ interface ThreePaneLayoutProps {
onViewModeChange: (mode: ViewMode) => void
onOpenSettings: () => void
onRefresh: () => void
relayPool: RelayPool | null
eventStore: IEventStore | null
// Content pane
readerLoading: boolean
@@ -86,6 +91,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
onRefresh={props.onRefresh}
isRefreshing={props.isRefreshing}
loading={props.bookmarksLoading}
relayPool={props.relayPool}
/>
</div>
<div className="pane main">
@@ -94,6 +100,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
settings={props.settings}
onSave={props.onSaveSettings}
onClose={props.onCloseSettings}
relayPool={props.relayPool}
/>
) : (
<ContentPanel
@@ -102,6 +109,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}
@@ -133,6 +142,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 +154,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)
@@ -43,11 +46,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 +58,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 +83,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

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,9 +1,10 @@
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'
@@ -11,6 +12,7 @@ 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
@@ -21,6 +23,7 @@ interface UseHighlightCreationParams {
export const useHighlightCreation = ({
activeAccount,
relayPool,
eventStore,
currentArticle,
selectedUrl,
readerContent,
@@ -38,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
}
@@ -54,25 +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,
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, settings])
}, [activeAccount, relayPool, eventStore, currentArticle, selectedUrl, readerContent, onHighlightCreated, settings])
return {
highlightButtonRef,

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

@@ -501,17 +501,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 +525,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;
@@ -1070,6 +1099,8 @@ body {
margin: 0 0 2rem 0;
border-radius: 8px;
overflow: hidden;
position: relative;
min-height: 300px;
}
.reader-hero-image img {
@@ -1080,6 +1111,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;
@@ -1171,6 +1257,14 @@ body {
color: #646cff;
}
.highlight-relay-indicator {
color: #666;
}
.highlight-relay-indicator:hover {
color: #333;
}
.highlight-text {
color: #213547;
}
@@ -1471,6 +1565,28 @@ body {
font-size: 1.2rem;
flex-shrink: 0;
margin-top: 0.25rem;
position: relative;
}
.highlight-relay-indicator {
position: absolute;
bottom: -4px;
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 */
@@ -2088,6 +2204,36 @@ body {
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;
@@ -2163,3 +2309,249 @@ 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: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);
}

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)

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

@@ -3,10 +3,15 @@ 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
const BORIS_PUBKEY = '6e468422dfb74a5738702a8823b9b28168fc6cfb119d613e49ca0ec5a0bbd0c3'
const {
getHighlightText,
@@ -23,17 +28,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,
settings?: UserSettings
): Promise<NostrEvent> {
): Promise<Highlight> {
if (!selectedText || !source) {
throw new Error('Missing required data to create highlight')
}
@@ -76,20 +82,85 @@ export async function createHighlight(
// Add zap tags for nostr-native content (NIP-57 Appendix G)
if (typeof source === 'object' && 'kind' in source) {
const zapSplitPercentage = settings?.zapSplitPercentage ?? 50
addZapTags(highlightEvent, account.pubkey, source.pubkey, zapSplitPercentage)
// 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
}
/**
@@ -186,31 +257,71 @@ function extractContext(selectedText: string, articleContent: string): string |
/**
* Adds zap tags to a highlight event for split payments (NIP-57 Appendix G)
* @param event The highlight event to add zap tags to
* 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 authorPubkey The pubkey of the original article author
* @param highlighterPercentage Percentage (0-100) to give to the highlighter (default 50)
* @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: NostrEvent,
event: { tags: string[][] },
highlighterPubkey: string,
authorPubkey: string,
highlighterPercentage: number = 50
sourceEvent: NostrEvent,
highlighterWeight: number = 50,
borisWeight: number = 2.1,
authorWeight: number = 50
): void {
// Calculate weights based on percentage
// Using simple integer weights where highlighterPercentage:authorPercentage ratio is maintained
const highlighterWeight = Math.round(highlighterPercentage)
const authorWeight = Math.round(100 - highlighterPercentage)
// Use a reliable relay for zap metadata lookup (first non-local relay)
const zapRelay = RELAYS.find(r => !r.includes('localhost')) || RELAYS[0]
// Add zap tag for the highlighter
event.tags.push(['zap', highlighterPubkey, zapRelay, highlighterWeight.toString()])
// Extract existing zap tags from source event (the "author group")
const existingZapTags = sourceEvent.tags.filter(tag => tag[0] === 'zap')
// Add zap tag for the original author (only if different from highlighter)
if (authorPubkey !== highlighterPubkey) {
event.tags.push(['zap', authorPubkey, zapRelay, authorWeight.toString()])
// 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)])
}
}
}

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,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,8 +35,13 @@ export interface UserSettings {
defaultHighlightVisibilityNostrverse?: boolean
defaultHighlightVisibilityFriends?: boolean
defaultHighlightVisibilityMine?: boolean
// Zap split percentage for highlights (0-100, default 50)
zapSplitPercentage?: number
// 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
}
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

@@ -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))
}