Compare commits

...

54 Commits

Author SHA1 Message Date
Gigi
5d379a280b chore: bump version to 0.2.9 2025-10-08 13:14:28 +01:00
Gigi
22a02d228d fix: deduplicate highlights in streaming callbacks
- Replace array accumulation with Map keyed by highlight ID
- Prevents duplicate highlights when same event arrives from multiple relays
- Fix applied in useArticleLoader and useBookmarksData hooks
- Only updates UI when new unique highlight arrives
- Resolves issue where highlights appeared twice in sidebar
2025-10-08 12:55:27 +01:00
Gigi
61fd5bbadc chore: bump version to 0.2.8 2025-10-08 12:42:24 +01:00
Gigi
d642c87527 fix: pass article summary through to ReadableContent
- Add summary field when converting ArticleContent to ReadableContent
- Fix contentLoader.ts to include article.summary
- Fix useArticleLoader.ts to include article.summary
- Article summaries now properly display in reader header
- Resolves missing summary display for kind:30023 articles
2025-10-08 12:41:03 +01:00
Gigi
fea425b5d0 feat: display article summary in header
- Add summary field to ReadableContent interface
- Pass summary through ContentPanel to ReaderHeader
- Display summary below title in both overlay and standard layouts
- Style summary with reading font for consistency
- Summary appears in white with shadow in image overlays
- Summary appears in gray (#aaa) in standard headers
- Enhances article preview and reading experience
2025-10-08 12:35:05 +01:00
Gigi
1609c6e580 feat: overlay title and metadata on hero images
- Position title and metadata absolutely over hero images
- Add gradient background for text readability (dark at bottom)
- Use backdrop-filter blur for metadata badges
- White text with shadow for better contrast
- Maintain original layout when no image present
- Creates more immersive reading experience
2025-10-08 12:30:00 +01:00
Gigi
270ea94c70 feat: apply reading font to article titles
- Add font-family: var(--reading-font) to .reader-title class
- Ensures consistent typography between titles and body text
- Titles now respect user's reading font preference from settings
2025-10-08 12:09:36 +01:00
Gigi
83e2f23357 chore: update homepage URL to read.withboris.com 2025-10-08 12:08:11 +01:00
Gigi
9df0261071 fix: correct Jina AI Reader proxy URL format
- Remove hardcoded http:// prefix in proxy URL
- Preserve original protocol (http/https) when constructing proxy URL
- Fix: https://r.jina.ai/https://example.com instead of /http://example.com
- Resolves metadata fetching issues for HTTPS URLs
2025-10-08 12:00:03 +01:00
Gigi
1dfe66651a refactor: reorder toolbar buttons
- New order: Profile, Home, Settings, Refresh, Plus, Logout
- Navigation first (Home, Settings)
- Actions in middle (Refresh, Plus)
- Logout at end
2025-10-08 11:58:28 +01:00
Gigi
dcb7933ede refactor: reorder toolbar buttons
- New order: Profile, Home, Refresh, Add, Settings, Logout
- Groups navigation (Profile, Home) at start
- Action buttons (Refresh, Add) in middle
- Settings and Logout at end
2025-10-08 11:48:19 +01:00
Gigi
aa72ac44c8 chore: bump version to 0.2.7 2025-10-08 11:45:52 +01:00
Gigi
44fb07033b refactor: reorder toolbar buttons for better UX
- New order: User, Home, Settings, Add, Refresh, Logout
- Profile/user first for identity prominence
- Core navigation (Home, Settings) before actions
- Actions (Add, Refresh) grouped together
- Logout at the end as final action
2025-10-08 11:45:07 +01:00
Gigi
7e2d412869 refactor: swap refresh and profile button positions in toolbar
- Move profile avatar before refresh button
- New order: Home, Add, Profile, Refresh, Settings, Logout/Login
- Improves toolbar organization and button flow
2025-10-08 11:39:50 +01:00
Gigi
19021af49a fix: revert to fetchReadableContent to avoid CORS issues
- url-metadata package doesn't work in browser due to CORS
- Restore use of fetchReadableContent with Jina AI proxy
- Extract helper functions for cleaner metadata parsing
- Maintain same functionality with proper browser compatibility
- Remove url-metadata dependency (25 packages)
2025-10-08 11:16:59 +01:00
Gigi
bdbb89c50e feat: only add boris tag when metadata is auto-extracted
- Start with empty tags field instead of pre-filling 'boris'
- Track if any metadata was successfully extracted (title, description, or tags)
- Only add 'boris' tag if we extracted something automatically
- Makes the boris tag more meaningful and intentional
- User can still manually add tags for URLs without metadata
2025-10-08 11:15:34 +01:00
Gigi
687f60db3f refactor: DRY tag extraction with normalizeTags helper
- Create normalizeTags helper to eliminate duplication
- Handle both string and array tag formats uniformly
- Reduce tag extraction code from 25 lines to 10 lines
- Cleaner, more maintainable code
2025-10-08 11:12:34 +01:00
Gigi
8ee7d347be refactor: use url-metadata package for robust metadata extraction
- Replace manual regex-based HTML parsing with url-metadata package
- Cleaner code with proper handling of OpenGraph, Twitter Cards, and standard meta tags
- Better handling of keywords (supports both string and array formats)
- More reliable extraction across different website structures
- Removes dependency on fetchReadableContent for metadata
- Significantly reduces code complexity (60+ lines to ~20 lines)
2025-10-08 11:10:10 +01:00
Gigi
8e9242e6f2 feat: auto-extract tags from metadata and add boris as default tag
- Set 'boris' as default tag in bookmark creation modal
- Extract tags from keywords meta tag (comma/semicolon separated)
- Extract tags from OpenGraph article:tag properties
- Deduplicate and limit to 5 extracted tags
- Prepend 'boris' to any extracted tags
- Only extract tags if user hasn't modified the tags field
- Use ref to prevent re-fetching same URL
- Improves bookmark organization and discoverability
2025-10-08 11:06:14 +01:00
Gigi
1df3962064 fix: improve modal spacing with proper box-sizing
- Add box-sizing: border-box to modal-content
- Add box-sizing: border-box to form inputs and textareas
- Ensures padding is included in width calculation
- Fixes right margin and prevents content from touching edges
2025-10-08 11:04:44 +01:00
Gigi
4edc22cec2 feat: prioritize OpenGraph tags for metadata extraction
- Extract title with priority: og:title > twitter:title > <title>
- Extract description with priority: og:description > twitter:description > meta description > first <p>
- OpenGraph tags provide better, curated metadata for sharing
- Twitter Card tags as fallback for social media compatibility
- Improved metadata quality for most modern websites
2025-10-08 11:01:51 +01:00
Gigi
82977fa5d4 feat: auto-fetch title and description when URL is pasted
- Automatically fetch page metadata using r.jina.ai proxy
- Debounced (800ms) to avoid API spam while typing
- Only auto-fills if fields are empty (won't overwrite user input)
- Extracts title from page
- Extracts description from meta tag or first paragraph
- Shows spinner indicator while fetching
- Gracefully handles fetch errors (just skips auto-fill)
- Uses existing fetchReadableContent service
2025-10-08 11:00:51 +01:00
Gigi
1a84817453 perf: publish bookmarks to relays in background
- Remove await on relayPool.publish() to not block UI
- Bookmark modal now closes immediately after signing
- Publishing happens asynchronously in the background
- Added error handling for failed relay publishes
- Fixes slow save issue caused by waiting for all 12 relays
2025-10-08 10:02:04 +01:00
Gigi
a0b98231b7 feat: add tags support to web bookmarks per NIP-B0
- Added tags input field to bookmark modal (comma-separated)
- Updated createWebBookmark to accept tags array
- Tags are added as 't' tags per NIP-B0 spec
- Added published_at tag with current timestamp
- Moved description to content field (per spec, not summary tag)
- d tag now uses URL without scheme (host + path + search + hash)
- Added helper text to explain tag formatting
- Styled form-helper-text for better UX
2025-10-08 09:51:33 +01:00
Gigi
d452f96f79 fix: pass relayPool as prop instead of using non-existent hook
- Fixed crash when opening bookmark bar
- Removed non-existent Hooks.useRelayPool() call
- Added relayPool prop to SidebarHeader, BookmarkList, and ThreePaneLayout
- Threaded relayPool through component hierarchy from Bookmarks down
- Web bookmark creation now works correctly
2025-10-08 09:49:02 +01:00
Gigi
dcf43cfce1 feat: add web bookmark creation (NIP-B0, kind:39701)
- Created webBookmarkService for creating web bookmarks
- Added AddBookmarkModal component with URL, title, and description fields
- Added plus button to sidebar header (visible when logged in)
- Modal validates URL format and publishes to relays
- Auto-refreshes bookmarks after creation
- Styled modal with dark theme matching app design
- Follows NIP-B0 spec: URL in 'd' tag, title and summary tags
2025-10-08 09:44:45 +01:00
Gigi
815b3cc57d fix: correct type signature for addZapTags function
- Changed event parameter type from NostrEvent to { tags: string[][] }
- Allows function to accept both EventTemplate and NostrEvent
- Fixes TypeScript error where EventTemplate was passed before signing
- No functional changes, just type safety improvement
2025-10-08 09:27:36 +01:00
Gigi
7e54a01237 feat: add zap split preset buttons
- Added 4 preset buttons: Default, Generous, Selfless, and Boris 🧡
- Default: 50/2.1/50 (You: 49%, Author: 49%, Boris: 2%)
- Generous: 5/10/75 (You: 6%, Author: 83%, Boris: 11%)
- Selfless: 1/19/80 (You: 1%, Author: 80%, Boris: 19%)
- Boris 🧡: 10/80/10 (You: 10%, Author: 10%, Boris: 80%)
- One-click setup for common zap split configurations
- Hover tooltips show actual percentage splits
2025-10-08 09:25:37 +01:00
Gigi
ec4692da15 fix: prevent sliders from jumping when resetting settings
- Added debouncing (300ms) to settings auto-save
- Added flag to prevent external settings updates during local editing
- External updates are blocked for 500ms after save completes
- Fixes issue where rapid save/subscription cycle caused sliders to jump
- Settings now update smoothly when resetting to defaults
2025-10-08 07:14:06 +01:00
Gigi
f6d2f98eae refactor: make zap split sliders independent using weights
- Changed from percentage-based to weight-based zap splits
- All three sliders (highlighter, author, Boris) are now independent
- Weights are normalized to calculate actual percentages
- UI shows both weight value and calculated percentage
- Added migration logic for users with old percentage-based settings
- Each slider can be adjusted without affecting the others
- Prevents interdependent slider behavior that was confusing

Breaking change: Settings now use zapSplitHighlighterWeight,
zapSplitAuthorWeight, and zapSplitBorisWeight instead of
zapSplitPercentage and borisSupportPercentage
2025-10-08 07:06:42 +01:00
Gigi
9b97715274 feat: add Boris support percentage to zap splits
- Added borisSupportPercentage setting (default 2.1%)
- Added separate slider in ZapSettings for Boris support
- Updated zap split calculation to include three-way split:
  - Highlighter gets their configured percentage
  - Boris gets their support percentage (0-10%)
  - Author(s) get remaining percentage, split proportionally
- Display all three percentages in the UI
- Updated addZapTags to include Boris as zap recipient
- Boris support is optional and adjustable (0-10% range)
2025-10-08 07:03:47 +01:00
Gigi
fa1e536a26 refactor: move zap splits to dedicated settings section
- Created new ZapSettings component as separate section
- Moved zap split slider from ReadingDisplaySettings
- Placed at end of settings page as requested
- Updated description to mention multiple authors support
2025-10-08 07:02:02 +01:00
Gigi
238aac1921 docs: add zap splits feature to README 2025-10-08 06:55:32 +01:00
Gigi
29edd159e7 feat: respect existing zap tags in source content when creating highlights
- Updated addZapTags function to check for existing zap tags in source event
- When source has zap tags (author group), split proportionally:
  - Highlighter gets their configured percentage
  - Remaining percentage distributed among existing authors proportionally
- Example: 50/50 split with 2 source authors = 50% highlighter, 25% each author
- Falls back to simple two-way split if no existing zap tags
- Prevents duplicate entries if highlighter is already in author group
2025-10-08 06:53:53 +01:00
Gigi
a3edb64e4c docs: add CHANGELOG.md based on git history 2025-10-08 06:43:08 +01:00
Gigi
1609416e1e chore: bump version to 0.2.6 2025-10-08 06:40:39 +01:00
Gigi
00d9fbdbab feat: add home button to bookmark bar
- Add home icon button to sidebar header
- Clicking home button navigates to root path '/'
- Position home button before refresh and settings buttons
2025-10-08 06:34:22 +01:00
Gigi
239ab5763d feat: add configurable zap split for highlights on nostr-native content
- Add zapSplitPercentage setting (default 50%) to UserSettings
- Implement NIP-57 Appendix G zap tags for highlight events
- Add zap tags when creating highlights of nostr-native content
- Split zaps between highlighter and article author based on setting
- Add UI slider in settings to configure split percentage
- Include relay URL in zap tags for metadata lookup
- Only add author zap tag if different from highlighter
2025-10-08 06:32:02 +01:00
Gigi
f4f0de3508 chore(release): v0.2.5 2025-10-07 22:19:49 +01:00
Gigi
0c3e697df6 fix(reader): wire preview ref to markdown conversion hook
- Update useMarkdownToHTML to return {renderedHtml, previewRef}
- Use returned previewRef in ContentPanel hidden markdown preview
- Resolves article markdown not rendering instantly
2025-10-07 22:16:31 +01:00
Gigi
a9847a8848 fix: add missing useEffect dependencies for article loading
- Fix useBookmarksData hook to include all callback dependencies
- Fix useArticleLoader hook to include all setter function dependencies
- Fix useExternalUrlLoader hook to include all setter function dependencies
- Ensures articles load properly when navigating
2025-10-07 22:04:12 +01:00
Gigi
5ef7d2c41c refactor: DRY up highlight classification and URL normalization
- Extract classifyHighlight and classifyHighlights utilities
- Remove duplicate highlight classification logic from 3 locations
- Use existing normalizeUrl from urlHelpers instead of duplicating
- Reduce code duplication while maintaining functionality
2025-10-07 21:58:17 +01:00
Gigi
e1f704a690 fix: resolve linter errors
- Remove unused imports in ReadingDisplaySettings and useBookmarksData
- Add eslint-disable comments for unavoidable any types from applesauce library
- All lint and type-check validations now pass
2025-10-07 21:57:04 +01:00
Gigi
59ecc29b9c refactor(highlights): split highlighting utilities into modules
- Create textMatching module for text search utilities
- Create domUtils module for DOM manipulation helpers
- Create htmlMatching module for HTML highlight application
- Reduce highlightMatching.tsx from 217 lines to 59 lines
- All files now under 210 lines
2025-10-07 21:54:41 +01:00
Gigi
9ae918f744 refactor(highlights): extract highlights panel components
- Create useFilteredHighlights hook for highlight filtering
- Extract HighlightsPanelCollapsed component
- Extract HighlightsPanelHeader component
- Reduce HighlightsPanel.tsx from 232 lines to 118 lines
2025-10-07 21:53:24 +01:00
Gigi
ac71d0b5a4 refactor(content): extract content rendering hooks
- Create useMarkdownToHTML hook for markdown conversion
- Create useHighlightedContent hook for highlight processing
- Create useHighlightInteractions hook for highlight interactions
- Reduce ContentPanel.tsx from 294 lines to 159 lines
2025-10-07 21:52:05 +01:00
Gigi
7c0d3b909b refactor(settings): split Settings into section components
- Extract ReadingDisplaySettings component
- Extract LayoutNavigationSettings component
- Extract StartupPreferencesSettings component
- Reduce Settings.tsx from 295 lines to 104 lines
2025-10-07 21:50:35 +01:00
Gigi
7b390aae66 refactor(highlights): extract event processing utilities
- Create highlightEventProcessor module with eventToHighlight utility
- Extract dedupeHighlights and sortHighlights functions
- Reduce highlightService.ts from 346 lines to 186 lines
- Eliminate repetitive event-to-highlight conversion code
2025-10-07 21:49:01 +01:00
Gigi
70a830fb66 refactor(bookmarks): split Bookmarks.tsx into smaller hooks and components
- Extract useBookmarksData hook for data fetching
- Extract useContentSelection hook for content selection logic
- Extract useHighlightCreation hook for highlight creation
- Extract useBookmarksUI hook for UI state management
- Create ThreePaneLayout component to reduce JSX complexity
- Reduce Bookmarks.tsx from 392 lines to 209 lines
2025-10-07 21:47:59 +01:00
Gigi
eee7628096 fix: encode/decode URLs in /r/ route to preserve special characters
URLs with special characters like // in https:// were being collapsed by browsers when passed directly in the path. Now using encodeURIComponent when navigating and decodeURIComponent when extracting the URL from the path.
2025-10-07 21:40:43 +01:00
Gigi
298e80cd49 chore: add public assets and deployment configuration
- Add _headers for security headers and asset caching
- Add robots.txt for SEO with sitemap reference
- Add _redirects for SPA client-side routing support
2025-10-07 21:34:46 +01:00
Gigi
5b7ad1d697 chore: add domain configuration for https://xn--bris-v0b.com/
- Add homepage field to package.json
- Add meta tags for SEO and social sharing (Open Graph, Twitter Card)
- Add canonical URL
- Add description meta tag
2025-10-07 21:34:17 +01:00
Gigi
0b5bf59e98 feat: hide bookmarks without content or URL 2025-10-07 18:37:45 +01:00
Gigi
a72a3e3f77 docs: add live app domain to README 2025-10-07 07:19:52 +01:00
47 changed files with 3245 additions and 1210 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%

384
CHANGELOG.md Normal file
View File

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

@@ -4,16 +4,22 @@ Your reading list for the Nostr world.
Boris turns your Nostr bookmarks into a calm, fast, and focused reading experience. Connect your Nostr account and you'll get a clean threepane reader: bookmarks on the left, the article in the middle, and highlights on the right.
## Live
- App: [https://xn--bris-v0b.com/](https://xn--bris-v0b.com/)
## The Vision
When I wrote "Purple Text, Orange Highlights" 2.5 years ago, I had a certain interface in mind that would allow the reader to curate, discover, highlight, and provide value to writers and other readers alike. Boris is my attempt to build this interface.
Boris has three "levels" of highlights for each article:
- user = yellow
- friends = orange
- nostrverse = purple
In case it's not self-explanatory:
- **your highlights** = highlights that the logged-in npub made
- **friends** = highlights that your friends made, i.e. highlights of the npubs that the logged-in user follows
- **nostrverse** = all the highlights we can find on all the relays we're connected to
@@ -29,6 +35,7 @@ If you bookmark something on nostr, Boris will show it in the bookmarks bar. If
- Collects your saved links from Nostr and shows them as a tidy reading list
- Opens articles in a distractionfree reader with clear typography
- Shows community highlights layered on the article (yours, friends, everyone)
- Splits zaps between you and the author(s) when you highlight
- Lets you collapse sidebars anytime for fullfocus reading
- Remembers simple preferences like view mode, fonts, and highlight style

View File

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

4
package-lock.json generated
View File

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

View File

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

9
public/_headers Normal file
View File

@@ -0,0 +1,9 @@
/*
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
/assets/*
Cache-Control: public, max-age=31536000, immutable

3
public/_redirects Normal file
View File

@@ -0,0 +1,3 @@
# SPA redirect for client-side routing
/* /index.html 200

5
public/robots.txt Normal file
View File

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

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,11 +1,13 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
import { Bookmark } from '../types/bookmarks'
import { RelayPool } from 'applesauce-relay'
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
import { BookmarkItem } from './BookmarkItem'
import SidebarHeader from './SidebarHeader'
import IconButton from './IconButton'
import { ViewMode } from './Bookmarks'
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
interface BookmarkListProps {
bookmarks: Bookmark[]
@@ -20,6 +22,7 @@ interface BookmarkListProps {
onRefresh?: () => void
isRefreshing?: boolean
loading?: boolean
relayPool: RelayPool | null
}
export const BookmarkList: React.FC<BookmarkListProps> = ({
@@ -34,12 +37,38 @@ 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) => {
// Check if has content (text)
const hasContent = ib.content && ib.content.trim().length > 0
// Check if has URL
let hasUrl = false
// For web bookmarks (kind:39701), URL is in the 'd' tag
if (ib.kind === 39701) {
const dTag = ib.tags?.find((t: string[]) => t[0] === 'd')?.[1]
hasUrl = !!dTag && dTag.trim().length > 0
} else {
// For other bookmarks, extract URLs from content
const urls = extractUrlsFromContent(ib.content || '')
hasUrl = urls.length > 0
}
// Always show articles (kind:30023) as they have special handling
if (ib.kind === 30023) return true
// Otherwise, must have either content or URL
return hasContent || hasUrl
}
// Merge and flatten all individual bookmarks from all lists
// Re-sort after flattening to ensure newest first across all lists
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
.filter(ib => ib.content || ib.kind === 30023 || ib.kind === 39701)
.filter(hasContentOrUrl)
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
if (isCollapsed) {
@@ -72,6 +101,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
onOpenSettings={onOpenSettings}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
relayPool={relayPool}
/>
{loading ? (

View File

@@ -1,28 +1,18 @@
import React, { useState, useEffect, useMemo } from 'react'
import { useParams, useLocation, useNavigate } from 'react-router-dom'
import React, { useMemo } from 'react'
import { useParams, useLocation } from 'react-router-dom'
import { Hooks } from 'applesauce-react'
import { useEventStore } from 'applesauce-react/hooks'
import { RelayPool } from 'applesauce-relay'
import { Bookmark } from '../types/bookmarks'
import { Highlight } from '../types/highlights'
import { BookmarkList } from './BookmarkList'
import { fetchBookmarks } from '../services/bookmarkService'
import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService'
import { fetchContacts } from '../services/contactService'
import ContentPanel from './ContentPanel'
import { HighlightsPanel } from './HighlightsPanel'
import { ReadableContent } from '../services/readerService'
import Settings from './Settings'
import Toast from './Toast'
import { useSettings } from '../hooks/useSettings'
import { useArticleLoader } from '../hooks/useArticleLoader'
import { useExternalUrlLoader } from '../hooks/useExternalUrlLoader'
import { loadContent, BookmarkReference } from '../utils/contentLoader'
import { HighlightVisibility } from './HighlightsPanel'
import { HighlightButton, HighlightButtonRef } from './HighlightButton'
import { createHighlight, eventToHighlight } from '../services/highlightCreationService'
import { useRef, useCallback } from 'react'
import { NostrEvent, nip19 } from 'nostr-tools'
import { useBookmarksData } from '../hooks/useBookmarksData'
import { useContentSelection } from '../hooks/useContentSelection'
import { useHighlightCreation } from '../hooks/useHighlightCreation'
import { useBookmarksUI } from '../hooks/useBookmarksUI'
import ThreePaneLayout from './ThreePaneLayout'
import { classifyHighlights } from '../utils/highlightClassification'
export type ViewMode = 'compact' | 'cards' | 'large'
interface BookmarksProps {
@@ -33,40 +23,14 @@ interface BookmarksProps {
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
const { naddr } = useParams<{ naddr?: string }>()
const location = useLocation()
const navigate = useNavigate()
// Extract external URL from /r/* route
const externalUrl = location.pathname.startsWith('/r/')
? location.pathname.slice(3) // Remove '/r/' prefix
? decodeURIComponent(location.pathname.slice(3))
: undefined
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [bookmarksLoading, setBookmarksLoading] = useState(true)
const [highlights, setHighlights] = useState<Highlight[]>([])
const [highlightsLoading, setHighlightsLoading] = useState(true)
const [selectedUrl, setSelectedUrl] = useState<string | undefined>(undefined)
const [readerLoading, setReaderLoading] = useState(false)
const [readerContent, setReaderContent] = useState<ReadableContent | undefined>(undefined)
const [isCollapsed, setIsCollapsed] = useState(true) // Start collapsed
const [isHighlightsCollapsed, setIsHighlightsCollapsed] = useState(true) // Start collapsed
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) // Store the current article event
const [highlightVisibility, setHighlightVisibility] = useState<HighlightVisibility>({
nostrverse: true,
friends: true,
mine: true
})
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
const [isRefreshing, setIsRefreshing] = useState(false)
const activeAccount = Hooks.useActiveAccount()
const accountManager = Hooks.useAccountManager()
const eventStore = useEventStore()
const highlightButtonRef = useRef<HighlightButtonRef>(null)
const { settings, saveSettings, toastMessage, toastType, clearToast } = useSettings({
relayPool,
@@ -75,6 +39,80 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
accountManager
})
const {
isCollapsed,
setIsCollapsed,
isHighlightsCollapsed,
setIsHighlightsCollapsed,
viewMode,
setViewMode,
showHighlights,
setShowHighlights,
selectedHighlightId,
setSelectedHighlightId,
showSettings,
setShowSettings,
currentArticleCoordinate,
setCurrentArticleCoordinate,
currentArticleEventId,
setCurrentArticleEventId,
currentArticle,
setCurrentArticle,
highlightVisibility,
setHighlightVisibility
} = useBookmarksUI({ settings })
const {
bookmarks,
bookmarksLoading,
highlights,
setHighlights,
highlightsLoading,
setHighlightsLoading,
followedPubkeys,
isRefreshing,
handleFetchHighlights,
handleRefreshAll
} = useBookmarksData({
relayPool,
activeAccount,
accountManager,
naddr,
currentArticleCoordinate,
currentArticleEventId
})
const {
selectedUrl,
setSelectedUrl,
readerLoading,
setReaderLoading,
readerContent,
setReaderContent,
handleSelectUrl
} = useContentSelection({
relayPool,
settings,
setIsCollapsed,
setShowSettings,
setCurrentArticle
})
const {
highlightButtonRef,
handleTextSelection,
handleClearSelection,
handleCreateHighlight
} = useHighlightCreation({
activeAccount,
relayPool,
currentArticle,
selectedUrl,
readerContent,
onHighlightCreated: (highlight) => setHighlights(prev => [highlight, ...prev]),
settings
})
// Load nostr-native article if naddr is in URL
useArticleLoader({
naddr,
@@ -104,288 +142,62 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
setCurrentArticleEventId
})
// Load initial data on login
useEffect(() => {
if (!relayPool || !activeAccount) return
handleFetchBookmarks()
// Avoid overwriting article-specific highlights during initial article load
// If an article is being viewed (naddr present), let useArticleLoader own the first highlights set
if (!naddr) {
handleFetchHighlights()
}
handleFetchContacts()
}, [relayPool, activeAccount?.pubkey])
const handleFetchContacts = async () => {
if (!relayPool || !activeAccount) return
const contacts = await fetchContacts(relayPool, activeAccount.pubkey)
setFollowedPubkeys(contacts)
}
// Apply UI settings
useEffect(() => {
if (settings.defaultViewMode) setViewMode(settings.defaultViewMode)
if (settings.showHighlights !== undefined) setShowHighlights(settings.showHighlights)
// Apply default highlight visibility settings
setHighlightVisibility({
nostrverse: settings.defaultHighlightVisibilityNostrverse !== false,
friends: settings.defaultHighlightVisibilityFriends !== false,
mine: settings.defaultHighlightVisibilityMine !== false
})
// Always start with both panels collapsed on initial load
// Don't apply saved collapse settings on initial load - let user control them
}, [settings])
const handleFetchBookmarks = async () => {
if (!relayPool || !activeAccount) return
setBookmarksLoading(true)
try {
const fullAccount = accountManager.getActive()
await fetchBookmarks(relayPool, fullAccount || activeAccount, setBookmarks)
} finally {
setBookmarksLoading(false)
}
}
const handleFetchHighlights = async () => {
if (!relayPool) return
setHighlightsLoading(true)
try {
// If we're viewing an article, fetch highlights for that article
if (currentArticleCoordinate) {
const highlightsList: Highlight[] = []
await fetchHighlightsForArticle(
relayPool,
currentArticleCoordinate,
currentArticleEventId,
(highlight) => {
// Render each highlight immediately as it arrives
highlightsList.push(highlight)
setHighlights([...highlightsList].sort((a, b) => b.created_at - a.created_at))
}
)
console.log(`🔄 Refreshed ${highlightsList.length} highlights for article`)
}
// Otherwise, if logged in, fetch user's own highlights
else if (activeAccount) {
const fetchedHighlights = await fetchHighlights(relayPool, activeAccount.pubkey)
setHighlights(fetchedHighlights)
}
} catch (err) {
console.error('Failed to fetch highlights:', err)
} finally {
setHighlightsLoading(false)
}
}
const handleRefreshBookmarks = async () => {
if (!relayPool || !activeAccount || isRefreshing) return
setIsRefreshing(true)
try {
await handleFetchBookmarks()
await handleFetchHighlights()
await handleFetchContacts()
} catch (err) {
console.error('Failed to refresh bookmarks:', err)
} finally {
setIsRefreshing(false)
}
}
// Classify highlights with levels based on user context
const classifiedHighlights = useMemo(() => {
return highlights.map(h => {
let level: 'mine' | 'friends' | 'nostrverse' = 'nostrverse'
if (h.pubkey === activeAccount?.pubkey) {
level = 'mine'
} else if (followedPubkeys.has(h.pubkey)) {
level = 'friends'
}
return { ...h, level }
})
return classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys)
}, [highlights, activeAccount?.pubkey, followedPubkeys])
const handleSelectUrl = async (url: string, bookmark?: BookmarkReference) => {
if (!relayPool) return
// Update the URL path based on content type
if (bookmark && bookmark.kind === 30023) {
// For nostr articles, navigate to /a/:naddr
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1] || ''
if (dTag && bookmark.pubkey) {
const pointer = {
identifier: dTag,
kind: 30023,
pubkey: bookmark.pubkey,
}
const naddr = nip19.naddrEncode(pointer)
navigate(`/a/${naddr}`)
}
} else if (url) {
// For external URLs, navigate to /r/:url
navigate(`/r/${url}`)
}
setSelectedUrl(url)
setReaderLoading(true)
setReaderContent(undefined)
setCurrentArticle(undefined) // Clear previous article
setShowSettings(false)
if (settings.collapseOnArticleOpen !== false) setIsCollapsed(true)
try {
const content = await loadContent(url, relayPool, bookmark)
setReaderContent(content)
// Note: currentArticle is set by useArticleLoader when loading Nostr articles
// For web bookmarks, there's no article event to set
} catch (err) {
console.warn('Failed to fetch content:', err)
} finally {
setReaderLoading(false)
}
}
const handleTextSelection = useCallback((text: string) => {
highlightButtonRef.current?.updateSelection(text)
}, [])
const handleClearSelection = useCallback(() => {
highlightButtonRef.current?.clearSelection()
}, [])
const handleCreateHighlight = useCallback(async (text: string) => {
if (!activeAccount || !relayPool) {
console.error('Missing requirements for highlight creation')
return
}
// Need either a nostr article or an external URL
if (!currentArticle && !selectedUrl) {
console.error('No source available for highlight creation')
return
}
try {
// Determine the source: prefer currentArticle (for nostr content), fallback to selectedUrl (for external URLs)
const source = currentArticle || selectedUrl!
// For context extraction, use article content or reader content
const contentForContext = currentArticle
? currentArticle.content
: readerContent?.markdown || readerContent?.html
// Create and publish the highlight
const signedEvent = await createHighlight(
text,
source,
activeAccount,
relayPool,
contentForContext
)
console.log('✅ Highlight created successfully!')
highlightButtonRef.current?.clearSelection()
// Immediately add the highlight to the UI (optimistic update)
const newHighlight = eventToHighlight(signedEvent)
setHighlights(prev => [newHighlight, ...prev])
} catch (error) {
console.error('Failed to create highlight:', error)
}
}, [activeAccount, relayPool, currentArticle, selectedUrl, readerContent])
return (
<>
<div className={`three-pane ${isCollapsed ? 'sidebar-collapsed' : ''} ${isHighlightsCollapsed ? 'highlights-collapsed' : ''}`}>
<div className="pane sidebar">
<BookmarkList
bookmarks={bookmarks}
onSelectUrl={handleSelectUrl}
isCollapsed={isCollapsed}
onToggleCollapse={() => setIsCollapsed(!isCollapsed)}
onLogout={onLogout}
viewMode={viewMode}
onViewModeChange={setViewMode}
selectedUrl={selectedUrl}
onOpenSettings={() => {
setShowSettings(true)
setIsCollapsed(true)
setIsHighlightsCollapsed(true)
}}
onRefresh={handleRefreshBookmarks}
isRefreshing={isRefreshing}
loading={bookmarksLoading}
/>
</div>
<div className="pane main">
{showSettings ? (
<Settings
settings={settings}
onSave={saveSettings}
onClose={() => setShowSettings(false)}
/>
) : (
<ContentPanel
loading={readerLoading}
title={readerContent?.title}
html={readerContent?.html}
markdown={readerContent?.markdown}
image={readerContent?.image}
selectedUrl={selectedUrl}
highlights={classifiedHighlights}
showHighlights={showHighlights}
highlightStyle={settings.highlightStyle || 'marker'}
highlightColor={settings.highlightColor || '#ffff00'}
onHighlightClick={(id) => {
setSelectedHighlightId(id)
if (isHighlightsCollapsed) setIsHighlightsCollapsed(false)
}}
selectedHighlightId={selectedHighlightId}
highlightVisibility={highlightVisibility}
onTextSelection={handleTextSelection}
onClearSelection={handleClearSelection}
currentUserPubkey={activeAccount?.pubkey}
followedPubkeys={followedPubkeys}
/>
)}
</div>
<div className="pane highlights">
<HighlightsPanel
highlights={highlights}
loading={highlightsLoading}
isCollapsed={isHighlightsCollapsed}
onToggleCollapse={() => setIsHighlightsCollapsed(!isHighlightsCollapsed)}
onSelectUrl={handleSelectUrl}
selectedUrl={selectedUrl}
onToggleHighlights={setShowHighlights}
selectedHighlightId={selectedHighlightId}
onRefresh={handleFetchHighlights}
onHighlightClick={setSelectedHighlightId}
currentUserPubkey={activeAccount?.pubkey}
highlightVisibility={highlightVisibility}
onHighlightVisibilityChange={setHighlightVisibility}
followedPubkeys={followedPubkeys}
/>
</div>
</div>
{activeAccount && relayPool && (
<HighlightButton
ref={highlightButtonRef}
onHighlight={handleCreateHighlight}
highlightColor={settings.highlightColor || '#ffff00'}
/>
)}
{toastMessage && (
<Toast
message={toastMessage}
type={toastType}
onClose={clearToast}
/>
)}
</>
<ThreePaneLayout
isCollapsed={isCollapsed}
isHighlightsCollapsed={isHighlightsCollapsed}
showSettings={showSettings}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
viewMode={viewMode}
isRefreshing={isRefreshing}
onToggleSidebar={() => setIsCollapsed(!isCollapsed)}
onLogout={onLogout}
onViewModeChange={setViewMode}
onOpenSettings={() => {
setShowSettings(true)
setIsCollapsed(true)
setIsHighlightsCollapsed(true)
}}
onRefresh={handleRefreshAll}
relayPool={relayPool}
readerLoading={readerLoading}
readerContent={readerContent}
selectedUrl={selectedUrl}
settings={settings}
onSaveSettings={saveSettings}
onCloseSettings={() => setShowSettings(false)}
classifiedHighlights={classifiedHighlights}
showHighlights={showHighlights}
selectedHighlightId={selectedHighlightId}
highlightVisibility={highlightVisibility}
onHighlightClick={(id) => {
setSelectedHighlightId(id)
if (isHighlightsCollapsed) setIsHighlightsCollapsed(false)
}}
onTextSelection={handleTextSelection}
onClearSelection={handleClearSelection}
currentUserPubkey={activeAccount?.pubkey}
followedPubkeys={followedPubkeys}
highlights={highlights}
highlightsLoading={highlightsLoading}
onToggleHighlightsPanel={() => setIsHighlightsCollapsed(!isHighlightsCollapsed)}
onSelectUrl={handleSelectUrl}
onToggleHighlights={setShowHighlights}
onRefreshHighlights={handleFetchHighlights}
onHighlightVisibilityChange={setHighlightVisibility}
highlightButtonRef={highlightButtonRef}
onCreateHighlight={handleCreateHighlight}
hasActiveAccount={!!(activeAccount && relayPool)}
toastMessage={toastMessage ?? undefined}
toastType={toastType}
onClearToast={clearToast}
/>
)
}

View File

@@ -1,15 +1,16 @@
import React, { useMemo, useEffect, useRef, useState, useCallback } from 'react'
import React, { useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
import { Highlight } from '../types/highlights'
import { applyHighlightsToHTML } from '../utils/highlightMatching'
import { readingTime } from 'reading-time-estimator'
import { filterHighlightsByUrl } from '../utils/urlHelpers'
import { hexToRgb } from '../utils/colorHelpers'
import ReaderHeader from './ReaderHeader'
import { HighlightVisibility } from './HighlightsPanel'
import { useMarkdownToHTML } from '../hooks/useMarkdownToHTML'
import { useHighlightedContent } from '../hooks/useHighlightedContent'
import { useHighlightInteractions } from '../hooks/useHighlightInteractions'
interface ContentPanelProps {
loading: boolean
@@ -18,6 +19,7 @@ interface ContentPanelProps {
markdown?: string
selectedUrl?: string
image?: string
summary?: string
highlights?: Highlight[]
showHighlights?: boolean
highlightStyle?: 'marker' | 'underline'
@@ -39,6 +41,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
markdown,
selectedUrl,
image,
summary,
highlights = [],
showHighlights = true,
highlightStyle = 'marker',
@@ -48,175 +51,40 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
highlightVisibility = { nostrverse: true, friends: true, mine: true },
currentUserPubkey,
followedPubkeys = new Set(),
// For highlight creation
onTextSelection,
onClearSelection
}) => {
const contentRef = useRef<HTMLDivElement>(null)
const markdownPreviewRef = useRef<HTMLDivElement>(null)
const [renderedHtml, setRenderedHtml] = useState<string>('')
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef } = useMarkdownToHTML(markdown)
// Filter highlights by URL and visibility settings
const relevantHighlights = useMemo(() => {
console.log('🔍 ContentPanel: Processing highlights', {
totalHighlights: highlights.length,
selectedUrl,
showHighlights
})
const urlFiltered = filterHighlightsByUrl(highlights, selectedUrl)
console.log('📌 URL filtered highlights:', urlFiltered.length)
// Apply visibility filtering
const filtered = urlFiltered
.map(h => {
// Classify highlight level
let level: 'mine' | 'friends' | 'nostrverse' = 'nostrverse'
if (h.pubkey === currentUserPubkey) {
level = 'mine'
} else if (followedPubkeys.has(h.pubkey)) {
level = 'friends'
}
return { ...h, level }
})
.filter(h => {
// Filter by visibility settings
if (h.level === 'mine') return highlightVisibility.mine
if (h.level === 'friends') return highlightVisibility.friends
return highlightVisibility.nostrverse
})
console.log('✅ Relevant highlights after filtering:', filtered.length, filtered.map(h => h.content.substring(0, 30)))
return filtered
}, [selectedUrl, highlights, highlightVisibility, currentUserPubkey, followedPubkeys, showHighlights])
const { finalHtml, relevantHighlights } = useHighlightedContent({
html,
markdown,
renderedMarkdownHtml,
highlights,
showHighlights,
highlightStyle,
selectedUrl,
highlightVisibility,
currentUserPubkey,
followedPubkeys
})
// Convert markdown to HTML when markdown content changes
useEffect(() => {
if (!markdown) {
setRenderedHtml('')
return
}
const { contentRef, handleMouseUp } = useHighlightInteractions({
onHighlightClick,
selectedHighlightId,
onTextSelection,
onClearSelection
})
console.log('📝 Converting markdown to HTML...')
// Use requestAnimationFrame to ensure ReactMarkdown has rendered
const rafId = requestAnimationFrame(() => {
if (markdownPreviewRef.current) {
const html = markdownPreviewRef.current.innerHTML
console.log('✅ Markdown converted to HTML:', html.length, 'chars')
setRenderedHtml(html)
} else {
console.warn('⚠️ markdownPreviewRef.current is null')
}
})
return () => cancelAnimationFrame(rafId)
}, [markdown])
// Prepare the final HTML with highlights applied
const finalHtml = useMemo(() => {
const sourceHtml = markdown ? renderedHtml : html
console.log('🎨 Preparing final HTML:', {
hasMarkdown: !!markdown,
hasHtml: !!html,
renderedHtmlLength: renderedHtml.length,
sourceHtmlLength: sourceHtml?.length || 0,
showHighlights,
relevantHighlightsCount: relevantHighlights.length
})
if (!sourceHtml) {
console.warn('⚠️ No source HTML available')
return ''
}
// Apply highlights if we have them and highlights are enabled
if (showHighlights && relevantHighlights.length > 0) {
console.log('✨ Applying', relevantHighlights.length, 'highlights to HTML')
const highlightedHtml = applyHighlightsToHTML(sourceHtml, relevantHighlights, highlightStyle)
console.log('✅ Highlights applied, result length:', highlightedHtml.length)
return highlightedHtml
}
console.log('📄 Returning source HTML without highlights')
return sourceHtml
}, [html, renderedHtml, markdown, relevantHighlights, showHighlights, highlightStyle])
// Attach click handlers to highlight marks
useEffect(() => {
if (!onHighlightClick || !contentRef.current) return
const marks = contentRef.current.querySelectorAll('mark[data-highlight-id]')
const handlers = new Map<Element, () => void>()
marks.forEach(mark => {
const highlightId = mark.getAttribute('data-highlight-id')
if (highlightId) {
const handler = () => onHighlightClick(highlightId)
mark.addEventListener('click', handler)
;(mark as HTMLElement).style.cursor = 'pointer'
handlers.set(mark, handler)
}
})
return () => {
handlers.forEach((handler, mark) => {
mark.removeEventListener('click', handler)
})
}
}, [onHighlightClick, finalHtml])
// Scroll to selected highlight in article when clicked from sidebar
useEffect(() => {
if (!selectedHighlightId || !contentRef.current) return
const markElement = contentRef.current.querySelector(`mark[data-highlight-id="${selectedHighlightId}"]`)
if (markElement) {
markElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
// Add pulsing animation after scroll completes
const htmlElement = markElement as HTMLElement
setTimeout(() => {
htmlElement.classList.add('highlight-pulse')
setTimeout(() => htmlElement.classList.remove('highlight-pulse'), 1500)
}, 500)
}
}, [selectedHighlightId, finalHtml])
// Calculate reading time from content (must be before early returns)
const readingStats = useMemo(() => {
const content = markdown || html || ''
if (!content) return null
// Strip HTML tags for more accurate word count
const textContent = content.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ')
return readingTime(textContent)
}, [html, markdown])
const hasHighlights = relevantHighlights.length > 0
// Handle text selection for highlight creation
const handleMouseUp = useCallback(() => {
setTimeout(() => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) {
onClearSelection?.()
return
}
const range = selection.getRangeAt(0)
const text = selection.toString().trim()
if (text.length > 0 && contentRef.current?.contains(range.commonAncestorContainer)) {
onTextSelection?.(text)
} else {
onClearSelection?.()
}
}, 10)
}, [onTextSelection, onClearSelection])
if (!selectedUrl) {
return (
<div className="reader empty">
@@ -251,14 +119,14 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
<ReaderHeader
title={title}
image={image}
summary={summary}
readingTimeText={readingStats ? readingStats.text : null}
hasHighlights={hasHighlights}
highlightCount={relevantHighlights.length}
/>
{markdown || html ? (
markdown ? (
// For markdown, always use finalHtml once it's ready to ensure highlights are applied
renderedHtml && finalHtml ? (
renderedMarkdownHtml && finalHtml ? (
<div
ref={contentRef}
className="reader-markdown"
@@ -266,7 +134,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
onMouseUp={handleMouseUp}
/>
) : (
// Show loading state while markdown is being converted to HTML
<div className="reader-markdown">
<div className="loading-spinner">
<FontAwesomeIcon icon={faSpinner} spin size="sm" />
@@ -274,7 +141,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
</div>
)
) : (
// For HTML, use finalHtml directly
<div
ref={contentRef}
className="reader-html"

View File

@@ -1,8 +1,11 @@
import React, { useMemo, useState } from 'react'
import React, { useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight, faHighlighter, faEye, faEyeSlash, faRotate, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons'
import { faHighlighter } from '@fortawesome/free-solid-svg-icons'
import { Highlight } from '../types/highlights'
import { HighlightItem } from './HighlightItem'
import { useFilteredHighlights } from '../hooks/useFilteredHighlights'
import HighlightsPanelCollapsed from './HighlightsPanel/HighlightsPanelCollapsed'
import HighlightsPanelHeader from './HighlightsPanel/HighlightsPanelHeader'
export interface HighlightVisibility {
nostrverse: boolean
@@ -51,153 +54,36 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
onToggleHighlights?.(newValue)
}
// Filter highlights based on visibility levels and URL
const filteredHighlights = useMemo(() => {
if (!selectedUrl) return highlights
let urlFiltered = highlights
// For Nostr articles (URL starts with "nostr:"), we don't need to filter by URL
// because we already fetched highlights specifically for this article
if (!selectedUrl.startsWith('nostr:')) {
// For web URLs, filter by URL matching
const normalizeUrl = (url: string) => {
try {
const urlObj = new URL(url.startsWith('http') ? url : `https://${url}`)
return `${urlObj.hostname.replace(/^www\./, '')}${urlObj.pathname}`.replace(/\/$/, '').toLowerCase()
} catch {
return url.replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/$/, '').toLowerCase()
}
}
const normalizedSelected = normalizeUrl(selectedUrl)
urlFiltered = highlights.filter(h => {
if (!h.urlReference) return false
const normalizedRef = normalizeUrl(h.urlReference)
return normalizedSelected === normalizedRef ||
normalizedSelected.includes(normalizedRef) ||
normalizedRef.includes(normalizedSelected)
})
}
// Classify and filter by visibility levels
return urlFiltered
.map(h => {
// Classify highlight level
let level: 'mine' | 'friends' | 'nostrverse' = 'nostrverse'
if (h.pubkey === currentUserPubkey) {
level = 'mine'
} else if (followedPubkeys.has(h.pubkey)) {
level = 'friends'
}
return { ...h, level }
})
.filter(h => {
// Filter by visibility settings
if (h.level === 'mine') return highlightVisibility.mine
if (h.level === 'friends') return highlightVisibility.friends
return highlightVisibility.nostrverse
})
}, [highlights, selectedUrl, highlightVisibility, currentUserPubkey, followedPubkeys])
const filteredHighlights = useFilteredHighlights({
highlights,
selectedUrl,
highlightVisibility,
currentUserPubkey,
followedPubkeys
})
if (isCollapsed) {
const hasHighlights = filteredHighlights.length > 0
return (
<div className="highlights-container collapsed">
<button
onClick={onToggleCollapse}
className={`toggle-highlights-btn with-icon ${hasHighlights ? 'has-highlights' : ''}`}
title="Expand highlights panel"
aria-label="Expand highlights panel"
>
<FontAwesomeIcon icon={faHighlighter} className={hasHighlights ? 'glow' : ''} />
<FontAwesomeIcon icon={faChevronRight} />
</button>
</div>
<HighlightsPanelCollapsed
hasHighlights={filteredHighlights.length > 0}
onToggleCollapse={onToggleCollapse}
/>
)
}
return (
<div className="highlights-container">
<div className="highlights-header">
<div className="highlights-actions">
<div className="highlights-actions-left">
{onHighlightVisibilityChange && (
<div className="highlight-level-toggles">
<button
onClick={() => onHighlightVisibilityChange({
...highlightVisibility,
nostrverse: !highlightVisibility.nostrverse
})}
className={`level-toggle-btn ${highlightVisibility.nostrverse ? 'active' : ''}`}
title="Toggle nostrverse highlights"
aria-label="Toggle nostrverse highlights"
style={{ color: highlightVisibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }}
>
<FontAwesomeIcon icon={faNetworkWired} />
</button>
<button
onClick={() => onHighlightVisibilityChange({
...highlightVisibility,
friends: !highlightVisibility.friends
})}
className={`level-toggle-btn ${highlightVisibility.friends ? 'active' : ''}`}
title={currentUserPubkey ? "Toggle friends highlights" : "Login to see friends highlights"}
aria-label="Toggle friends highlights"
style={{ color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined }}
disabled={!currentUserPubkey}
>
<FontAwesomeIcon icon={faUserGroup} />
</button>
<button
onClick={() => onHighlightVisibilityChange({
...highlightVisibility,
mine: !highlightVisibility.mine
})}
className={`level-toggle-btn ${highlightVisibility.mine ? 'active' : ''}`}
title={currentUserPubkey ? "Toggle my highlights" : "Login to see your highlights"}
aria-label="Toggle my highlights"
style={{ color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined }}
disabled={!currentUserPubkey}
>
<FontAwesomeIcon icon={faUser} />
</button>
</div>
)}
{onRefresh && (
<button
onClick={onRefresh}
className="refresh-highlights-btn"
title="Refresh highlights"
aria-label="Refresh highlights"
disabled={loading}
>
<FontAwesomeIcon icon={faRotate} spin={loading} />
</button>
)}
{filteredHighlights.length > 0 && (
<button
onClick={handleToggleHighlights}
className="toggle-highlight-display-btn"
title={showHighlights ? 'Hide highlights' : 'Show highlights'}
aria-label={showHighlights ? 'Hide highlights' : 'Show highlights'}
>
<FontAwesomeIcon icon={showHighlights ? faEye : faEyeSlash} />
</button>
)}
</div>
<button
onClick={onToggleCollapse}
className="toggle-highlights-btn"
title="Collapse highlights panel"
aria-label="Collapse highlights panel"
>
<FontAwesomeIcon icon={faChevronRight} rotation={180} />
</button>
</div>
</div>
<HighlightsPanelHeader
loading={loading}
hasHighlights={filteredHighlights.length > 0}
showHighlights={showHighlights}
highlightVisibility={highlightVisibility}
currentUserPubkey={currentUserPubkey}
onToggleHighlights={handleToggleHighlights}
onRefresh={onRefresh}
onToggleCollapse={onToggleCollapse}
onHighlightVisibilityChange={onHighlightVisibilityChange}
/>
{loading && filteredHighlights.length === 0 ? (
<div className="highlights-loading">

View File

@@ -0,0 +1,30 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faChevronRight } from '@fortawesome/free-solid-svg-icons'
interface HighlightsPanelCollapsedProps {
hasHighlights: boolean
onToggleCollapse: () => void
}
const HighlightsPanelCollapsed: React.FC<HighlightsPanelCollapsedProps> = ({
hasHighlights,
onToggleCollapse
}) => {
return (
<div className="highlights-container collapsed">
<button
onClick={onToggleCollapse}
className={`toggle-highlights-btn with-icon ${hasHighlights ? 'has-highlights' : ''}`}
title="Expand highlights panel"
aria-label="Expand highlights panel"
>
<FontAwesomeIcon icon={faHighlighter} className={hasHighlights ? 'glow' : ''} />
<FontAwesomeIcon icon={faChevronRight} />
</button>
</div>
)
}
export default HighlightsPanelCollapsed

View File

@@ -0,0 +1,111 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight, faEye, faEyeSlash, faRotate, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons'
import { HighlightVisibility } from '../HighlightsPanel'
interface HighlightsPanelHeaderProps {
loading: boolean
hasHighlights: boolean
showHighlights: boolean
highlightVisibility: HighlightVisibility
currentUserPubkey?: string
onToggleHighlights: () => void
onRefresh?: () => void
onToggleCollapse: () => void
onHighlightVisibilityChange?: (visibility: HighlightVisibility) => void
}
const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
loading,
hasHighlights,
showHighlights,
highlightVisibility,
currentUserPubkey,
onToggleHighlights,
onRefresh,
onToggleCollapse,
onHighlightVisibilityChange
}) => {
return (
<div className="highlights-header">
<div className="highlights-actions">
<div className="highlights-actions-left">
{onHighlightVisibilityChange && (
<div className="highlight-level-toggles">
<button
onClick={() => onHighlightVisibilityChange({
...highlightVisibility,
nostrverse: !highlightVisibility.nostrverse
})}
className={`level-toggle-btn ${highlightVisibility.nostrverse ? 'active' : ''}`}
title="Toggle nostrverse highlights"
aria-label="Toggle nostrverse highlights"
style={{ color: highlightVisibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }}
>
<FontAwesomeIcon icon={faNetworkWired} />
</button>
<button
onClick={() => onHighlightVisibilityChange({
...highlightVisibility,
friends: !highlightVisibility.friends
})}
className={`level-toggle-btn ${highlightVisibility.friends ? 'active' : ''}`}
title={currentUserPubkey ? "Toggle friends highlights" : "Login to see friends highlights"}
aria-label="Toggle friends highlights"
style={{ color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined }}
disabled={!currentUserPubkey}
>
<FontAwesomeIcon icon={faUserGroup} />
</button>
<button
onClick={() => onHighlightVisibilityChange({
...highlightVisibility,
mine: !highlightVisibility.mine
})}
className={`level-toggle-btn ${highlightVisibility.mine ? 'active' : ''}`}
title={currentUserPubkey ? "Toggle my highlights" : "Login to see your highlights"}
aria-label="Toggle my highlights"
style={{ color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined }}
disabled={!currentUserPubkey}
>
<FontAwesomeIcon icon={faUser} />
</button>
</div>
)}
{onRefresh && (
<button
onClick={onRefresh}
className="refresh-highlights-btn"
title="Refresh highlights"
aria-label="Refresh highlights"
disabled={loading}
>
<FontAwesomeIcon icon={faRotate} spin={loading} />
</button>
)}
{hasHighlights && (
<button
onClick={onToggleHighlights}
className="toggle-highlight-display-btn"
title={showHighlights ? 'Hide highlights' : 'Show highlights'}
aria-label={showHighlights ? 'Hide highlights' : 'Show highlights'}
>
<FontAwesomeIcon icon={showHighlights ? faEye : faEyeSlash} />
</button>
)}
</div>
<button
onClick={onToggleCollapse}
className="toggle-highlights-btn"
title="Collapse highlights panel"
aria-label="Collapse highlights panel"
>
<FontAwesomeIcon icon={faChevronRight} rotation={180} />
</button>
</div>
</div>
)
}
export default HighlightsPanelHeader

View File

@@ -5,6 +5,7 @@ import { faHighlighter, faClock } from '@fortawesome/free-solid-svg-icons'
interface ReaderHeaderProps {
title?: string
image?: string
summary?: string
readingTimeText?: string | null
hasHighlights: boolean
highlightCount: number
@@ -13,20 +14,45 @@ interface ReaderHeaderProps {
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
title,
image,
summary,
readingTimeText,
hasHighlights,
highlightCount
}) => {
if (image) {
return (
<div className="reader-hero-image">
<img src={image} alt={title || 'Article image'} />
{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">
<h2 className="reader-title">{title}</h2>
{summary && <p className="reader-summary">{summary}</p>}
<div className="reader-meta">
{readingTimeText && (
<div className="reading-time">

View File

@@ -1,11 +1,12 @@
import React, { useState, useEffect, useRef } from 'react'
import { faTimes, faList, faThLarge, faImage, faUnderline, faHighlighter, faUndo, faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons'
import { faTimes, faUndo } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../services/settingsService'
import IconButton from './IconButton'
import ColorPicker from './ColorPicker'
import FontSelector from './FontSelector'
import { loadFont, getFontFamily } from '../utils/fontLoader'
import { hexToRgb } from '../utils/colorHelpers'
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'
const DEFAULT_SETTINGS: UserSettings = {
collapseOnArticleOpen: true,
@@ -23,6 +24,9 @@ const DEFAULT_SETTINGS: UserSettings = {
defaultHighlightVisibilityNostrverse: true,
defaultHighlightVisibilityFriends: true,
defaultHighlightVisibilityMine: true,
zapSplitHighlighterWeight: 50,
zapSplitBorisWeight: 2.1,
zapSplitAuthorWeight: 50,
}
interface SettingsProps {
@@ -32,11 +36,39 @@ interface SettingsProps {
}
const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
const [localSettings, setLocalSettings] = useState<UserSettings>(settings)
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)
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(() => {
@@ -48,29 +80,51 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
}, [])
useEffect(() => {
// Load font for preview when it changes
const fontToLoad = localSettings.readingFont || 'source-serif-4'
loadFont(fontToLoad).catch(err => console.warn('Failed to load preview font:', fontToLoad, err))
}, [localSettings.readingFont])
// Auto-save settings whenever they change (except on initial mount)
useEffect(() => {
if (isInitialMount.current) {
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 previewFontFamily = getFontFamily(localSettings.readingFont || 'source-serif-4')
const handleResetToDefaults = () => {
if (confirm('Reset all settings to defaults?')) {
setLocalSettings(DEFAULT_SETTINGS)
}
}
const handleUpdate = (updates: Partial<UserSettings>) => {
setLocalSettings({ ...localSettings, ...updates })
}
return (
<div className="settings-view">
<div className="settings-header">
@@ -94,199 +148,10 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
</div>
<div className="settings-content">
<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={localSettings.readingFont || 'source-serif-4'}
onChange={(font) => setLocalSettings({ ...localSettings, 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={() => setLocalSettings({ ...localSettings, fontSize: size })}
className={`font-size-btn ${(localSettings.fontSize || 18) === size ? 'active' : ''}`}
title={`${size}px`}
style={{ fontSize: `${size - 2}px` }}
>
A
</button>
))}
</div>
</div>
<div className="setting-group">
<label htmlFor="showHighlights" className="checkbox-label">
<input
id="showHighlights"
type="checkbox"
checked={localSettings.showHighlights !== false}
onChange={(e) => setLocalSettings({ ...localSettings, showHighlights: e.target.checked })}
className="setting-checkbox"
/>
<span>Show highlights</span>
</label>
</div>
<div className="setting-group setting-inline">
<label>Highlight Style</label>
<div className="setting-buttons">
<IconButton
icon={faHighlighter}
onClick={() => setLocalSettings({ ...localSettings, highlightStyle: 'marker' })}
title="Text marker style"
ariaLabel="Text marker style"
variant={(localSettings.highlightStyle || 'marker') === 'marker' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faUnderline}
onClick={() => setLocalSettings({ ...localSettings, highlightStyle: 'underline' })}
title="Underline style"
ariaLabel="Underline style"
variant={localSettings.highlightStyle === 'underline' ? 'primary' : 'ghost'}
/>
</div>
</div>
<div className="setting-group setting-inline">
<label className="setting-label">My Highlights</label>
<div className="setting-control">
<ColorPicker
selectedColor={localSettings.highlightColorMine || '#ffff00'}
onColorChange={(color) => setLocalSettings({ ...localSettings, highlightColorMine: color })}
/>
</div>
</div>
<div className="setting-group setting-inline">
<label className="setting-label">Friends Highlights</label>
<div className="setting-control">
<ColorPicker
selectedColor={localSettings.highlightColorFriends || '#f97316'}
onColorChange={(color) => setLocalSettings({ ...localSettings, highlightColorFriends: color })}
/>
</div>
</div>
<div className="setting-group setting-inline">
<label className="setting-label">Nostrverse Highlights</label>
<div className="setting-control">
<ColorPicker
selectedColor={localSettings.highlightColorNostrverse || '#9333ea'}
onColorChange={(color) => setLocalSettings({ ...localSettings, highlightColorNostrverse: color })}
/>
</div>
</div>
<div className="setting-preview">
<div className="preview-label">Preview</div>
<div
className="preview-content"
style={{
fontFamily: previewFontFamily,
fontSize: `${localSettings.fontSize || 18}px`,
'--highlight-rgb': hexToRgb(localSettings.highlightColor || '#ffff00')
} as React.CSSProperties}
>
<h3>The Quick Brown Fox</h3>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <span className={localSettings.showHighlights !== false ? `content-highlight-${localSettings.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={localSettings.showHighlights !== false ? `content-highlight-${localSettings.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={localSettings.showHighlights !== false ? `content-highlight-${localSettings.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>
</div>
<div className="settings-section">
<h3 className="section-title">Layout & Navigation</h3>
<div className="setting-group setting-inline">
<label>Default View Mode</label>
<div className="setting-buttons">
<IconButton icon={faList} onClick={() => setLocalSettings({ ...localSettings, defaultViewMode: 'compact' })} title="Compact list view" ariaLabel="Compact list view" variant={(localSettings.defaultViewMode || 'compact') === 'compact' ? 'primary' : 'ghost'} />
<IconButton icon={faThLarge} onClick={() => setLocalSettings({ ...localSettings, defaultViewMode: 'cards' })} title="Cards view" ariaLabel="Cards view" variant={localSettings.defaultViewMode === 'cards' ? 'primary' : 'ghost'} />
<IconButton icon={faImage} onClick={() => setLocalSettings({ ...localSettings, defaultViewMode: 'large' })} title="Large preview view" ariaLabel="Large preview view" variant={localSettings.defaultViewMode === 'large' ? 'primary' : 'ghost'} />
</div>
</div>
<div className="setting-group">
<label htmlFor="collapseOnArticleOpen" className="checkbox-label">
<input
id="collapseOnArticleOpen"
type="checkbox"
checked={localSettings.collapseOnArticleOpen !== false}
onChange={(e) => setLocalSettings({ ...localSettings, collapseOnArticleOpen: e.target.checked })}
className="setting-checkbox"
/>
<span>Collapse bookmark bar when opening an article</span>
</label>
</div>
</div>
<div className="settings-section">
<h3 className="section-title">Startup Preferences</h3>
<div className="setting-group">
<label htmlFor="sidebarCollapsed" className="checkbox-label">
<input
id="sidebarCollapsed"
type="checkbox"
checked={localSettings.sidebarCollapsed !== false}
onChange={(e) => setLocalSettings({ ...localSettings, sidebarCollapsed: e.target.checked })}
className="setting-checkbox"
/>
<span>Start with bookmarks sidebar collapsed</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="highlightsCollapsed" className="checkbox-label">
<input
id="highlightsCollapsed"
type="checkbox"
checked={localSettings.highlightsCollapsed !== false}
onChange={(e) => setLocalSettings({ ...localSettings, highlightsCollapsed: e.target.checked })}
className="setting-checkbox"
/>
<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={() => setLocalSettings({ ...localSettings, defaultHighlightVisibilityNostrverse: !(localSettings.defaultHighlightVisibilityNostrverse !== false) })}
title="Nostrverse highlights"
ariaLabel="Toggle nostrverse highlights by default"
variant={(localSettings.defaultHighlightVisibilityNostrverse !== false) ? 'primary' : 'ghost'}
/>
<IconButton
icon={faUserGroup}
onClick={() => setLocalSettings({ ...localSettings, defaultHighlightVisibilityFriends: !(localSettings.defaultHighlightVisibilityFriends !== false) })}
title="Friends highlights"
ariaLabel="Toggle friends highlights by default"
variant={(localSettings.defaultHighlightVisibilityFriends !== false) ? 'primary' : 'ghost'}
/>
<IconButton
icon={faUser}
onClick={() => setLocalSettings({ ...localSettings, defaultHighlightVisibilityMine: !(localSettings.defaultHighlightVisibilityMine !== false) })}
title="My highlights"
ariaLabel="Toggle my highlights by default"
variant={(localSettings.defaultHighlightVisibilityMine !== false) ? 'primary' : 'ghost'}
/>
</div>
</div>
</div>
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
<LayoutNavigationSettings settings={localSettings} onUpdate={handleUpdate} />
<StartupPreferencesSettings settings={localSettings} onUpdate={handleUpdate} />
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
</div>
</div>
)

View File

@@ -0,0 +1,60 @@
import React from 'react'
import { faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../../services/settingsService'
import IconButton from '../IconButton'
interface LayoutNavigationSettingsProps {
settings: UserSettings
onUpdate: (updates: Partial<UserSettings>) => void
}
const LayoutNavigationSettings: React.FC<LayoutNavigationSettingsProps> = ({ settings, onUpdate }) => {
return (
<div className="settings-section">
<h3 className="section-title">Layout & Navigation</h3>
<div className="setting-group setting-inline">
<label>Default View Mode</label>
<div className="setting-buttons">
<IconButton
icon={faList}
onClick={() => onUpdate({ defaultViewMode: 'compact' })}
title="Compact list view"
ariaLabel="Compact list view"
variant={(settings.defaultViewMode || 'compact') === 'compact' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faThLarge}
onClick={() => onUpdate({ defaultViewMode: 'cards' })}
title="Cards view"
ariaLabel="Cards view"
variant={settings.defaultViewMode === 'cards' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faImage}
onClick={() => onUpdate({ defaultViewMode: 'large' })}
title="Large preview view"
ariaLabel="Large preview view"
variant={settings.defaultViewMode === 'large' ? 'primary' : 'ghost'}
/>
</div>
</div>
<div className="setting-group">
<label htmlFor="collapseOnArticleOpen" className="checkbox-label">
<input
id="collapseOnArticleOpen"
type="checkbox"
checked={settings.collapseOnArticleOpen !== false}
onChange={(e) => onUpdate({ collapseOnArticleOpen: e.target.checked })}
className="setting-checkbox"
/>
<span>Collapse bookmark bar when opening an article</span>
</label>
</div>
</div>
)
}
export default LayoutNavigationSettings

View File

@@ -0,0 +1,132 @@
import React from 'react'
import { faHighlighter, faUnderline } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../../services/settingsService'
import IconButton from '../IconButton'
import ColorPicker from '../ColorPicker'
import FontSelector from '../FontSelector'
import { getFontFamily } from '../../utils/fontLoader'
import { hexToRgb } from '../../utils/colorHelpers'
interface ReadingDisplaySettingsProps {
settings: UserSettings
onUpdate: (updates: Partial<UserSettings>) => void
}
const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ settings, onUpdate }) => {
const previewFontFamily = getFontFamily(settings.readingFont || 'source-serif-4')
return (
<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>
</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-group setting-inline">
<label>Highlight Style</label>
<div className="setting-buttons">
<IconButton
icon={faHighlighter}
onClick={() => onUpdate({ highlightStyle: 'marker' })}
title="Text marker style"
ariaLabel="Text marker style"
variant={(settings.highlightStyle || 'marker') === 'marker' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faUnderline}
onClick={() => onUpdate({ highlightStyle: 'underline' })}
title="Underline style"
ariaLabel="Underline style"
variant={settings.highlightStyle === 'underline' ? 'primary' : 'ghost'}
/>
</div>
</div>
<div className="setting-group setting-inline">
<label className="setting-label">My Highlights</label>
<div className="setting-control">
<ColorPicker
selectedColor={settings.highlightColorMine || '#ffff00'}
onColorChange={(color) => onUpdate({ highlightColorMine: color })}
/>
</div>
</div>
<div className="setting-group setting-inline">
<label className="setting-label">Friends Highlights</label>
<div className="setting-control">
<ColorPicker
selectedColor={settings.highlightColorFriends || '#f97316'}
onColorChange={(color) => onUpdate({ highlightColorFriends: color })}
/>
</div>
</div>
<div className="setting-group setting-inline">
<label className="setting-label">Nostrverse Highlights</label>
<div className="setting-control">
<ColorPicker
selectedColor={settings.highlightColorNostrverse || '#9333ea'}
onColorChange={(color) => onUpdate({ highlightColorNostrverse: color })}
/>
</div>
</div>
<div className="setting-preview">
<div className="preview-label">Preview</div>
<div
className="preview-content"
style={{
fontFamily: previewFontFamily,
fontSize: `${settings.fontSize || 18}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>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>
</div>
)
}
export default ReadingDisplaySettings

View File

@@ -0,0 +1,73 @@
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
onUpdate: (updates: Partial<UserSettings>) => void
}
const StartupPreferencesSettings: React.FC<StartupPreferencesSettingsProps> = ({ settings, onUpdate }) => {
return (
<div className="settings-section">
<h3 className="section-title">Startup Preferences</h3>
<div className="setting-group">
<label htmlFor="sidebarCollapsed" className="checkbox-label">
<input
id="sidebarCollapsed"
type="checkbox"
checked={settings.sidebarCollapsed !== false}
onChange={(e) => onUpdate({ sidebarCollapsed: e.target.checked })}
className="setting-checkbox"
/>
<span>Start with bookmarks sidebar collapsed</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="highlightsCollapsed" className="checkbox-label">
<input
id="highlightsCollapsed"
type="checkbox"
checked={settings.highlightsCollapsed !== false}
onChange={(e) => onUpdate({ highlightsCollapsed: e.target.checked })}
className="setting-checkbox"
/>
<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>
)
}
export default StartupPreferencesSettings

View File

@@ -0,0 +1,130 @@
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 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({ highlighter: 50, boris: 2.1, author: 50 })}
className="zap-preset-btn"
title="You: 49%, Author: 49%, Boris: 2%"
>
Default
</button>
<button
onClick={() => applyPreset({ highlighter: 5, boris: 10, author: 75 })}
className="zap-preset-btn"
title="You: 6%, Author: 83%, Boris: 11%"
>
Generous
</button>
<button
onClick={() => applyPreset({ highlighter: 1, boris: 19, author: 80 })}
className="zap-preset-btn"
title="You: 1%, Author: 80%, Boris: 19%"
>
Selfless
</button>
<button
onClick={() => applyPreset({ highlighter: 10, boris: 80, author: 10 })}
className="zap-preset-btn"
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,11 +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 } 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
@@ -13,10 +18,13 @@ 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()
const profile = useEventModel(Models.ProfileModel, activeAccount ? [activeAccount.pubkey] : null)
@@ -47,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 (
@@ -61,24 +82,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
<FontAwesomeIcon icon={faChevronRight} />
</button>
<div className="sidebar-header-right">
{onRefresh && (
<IconButton
icon={faRotate}
onClick={onRefresh}
title="Refresh bookmarks"
ariaLabel="Refresh bookmarks"
variant="ghost"
disabled={isRefreshing}
spin={isRefreshing}
/>
)}
<IconButton
icon={faGear}
onClick={onOpenSettings}
title="Settings"
ariaLabel="Settings"
variant="ghost"
/>
<div
className="profile-avatar"
title={activeAccount ? getUserDisplayName() : "Login"}
@@ -91,6 +94,40 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
<FontAwesomeIcon icon={faUserCircle} />
)}
</div>
<IconButton
icon={faHome}
onClick={() => navigate('/')}
title="Home"
ariaLabel="Home"
variant="ghost"
/>
<IconButton
icon={faGear}
onClick={onOpenSettings}
title="Settings"
ariaLabel="Settings"
variant="ghost"
/>
{onRefresh && (
<IconButton
icon={faRotate}
onClick={onRefresh}
title="Refresh bookmarks"
ariaLabel="Refresh bookmarks"
variant="ghost"
disabled={isRefreshing}
spin={isRefreshing}
/>
)}
{activeAccount && (
<IconButton
icon={faPlus}
onClick={() => setShowAddModal(true)}
title="Add bookmark"
ariaLabel="Add bookmark"
variant="ghost"
/>
)}
{activeAccount ? (
<IconButton
icon={faRightFromBracket}
@@ -110,6 +147,12 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
)}
</div>
</div>
{showAddModal && (
<AddBookmarkModal
onClose={() => setShowAddModal(false)}
onSave={handleSaveBookmark}
/>
)}
</>
)
}

View File

@@ -0,0 +1,162 @@
import React from 'react'
import { RelayPool } from 'applesauce-relay'
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 { ViewMode } from './Bookmarks'
import { Bookmark } from '../types/bookmarks'
import { Highlight } from '../types/highlights'
import { ReadableContent } from '../services/readerService'
import { UserSettings } from '../services/settingsService'
import { HighlightVisibility } from './HighlightsPanel'
import { HighlightButtonRef } from './HighlightButton'
import { BookmarkReference } from '../utils/contentLoader'
interface ThreePaneLayoutProps {
// Layout state
isCollapsed: boolean
isHighlightsCollapsed: boolean
showSettings: boolean
// Bookmarks pane
bookmarks: Bookmark[]
bookmarksLoading: boolean
viewMode: ViewMode
isRefreshing: boolean
onToggleSidebar: () => void
onLogout: () => void
onViewModeChange: (mode: ViewMode) => void
onOpenSettings: () => void
onRefresh: () => void
relayPool: RelayPool | null
// Content pane
readerLoading: boolean
readerContent?: ReadableContent
selectedUrl?: string
settings: UserSettings
onSaveSettings: (settings: UserSettings) => Promise<void>
onCloseSettings: () => void
classifiedHighlights: Highlight[]
showHighlights: boolean
selectedHighlightId?: string
highlightVisibility: HighlightVisibility
onHighlightClick: (id: string) => void
onTextSelection: (text: string) => void
onClearSelection: () => void
currentUserPubkey?: string
followedPubkeys: Set<string>
// Highlights pane
highlights: Highlight[]
highlightsLoading: boolean
onToggleHighlightsPanel: () => void
onSelectUrl: (url: string, bookmark?: BookmarkReference) => void
onToggleHighlights: (show: boolean) => void
onRefreshHighlights: () => void
onHighlightVisibilityChange: (visibility: HighlightVisibility) => void
// Highlight button
highlightButtonRef: React.RefObject<HighlightButtonRef>
onCreateHighlight: (text: string) => void
hasActiveAccount: boolean
// Toast
toastMessage?: string
toastType?: 'success' | 'error'
onClearToast: () => void
}
const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
return (
<>
<div className={`three-pane ${props.isCollapsed ? 'sidebar-collapsed' : ''} ${props.isHighlightsCollapsed ? 'highlights-collapsed' : ''}`}>
<div className="pane sidebar">
<BookmarkList
bookmarks={props.bookmarks}
onSelectUrl={props.onSelectUrl}
isCollapsed={props.isCollapsed}
onToggleCollapse={props.onToggleSidebar}
onLogout={props.onLogout}
viewMode={props.viewMode}
onViewModeChange={props.onViewModeChange}
selectedUrl={props.selectedUrl}
onOpenSettings={props.onOpenSettings}
onRefresh={props.onRefresh}
isRefreshing={props.isRefreshing}
loading={props.bookmarksLoading}
relayPool={props.relayPool}
/>
</div>
<div className="pane main">
{props.showSettings ? (
<Settings
settings={props.settings}
onSave={props.onSaveSettings}
onClose={props.onCloseSettings}
/>
) : (
<ContentPanel
loading={props.readerLoading}
title={props.readerContent?.title}
html={props.readerContent?.html}
markdown={props.readerContent?.markdown}
image={props.readerContent?.image}
summary={props.readerContent?.summary}
selectedUrl={props.selectedUrl}
highlights={props.classifiedHighlights}
showHighlights={props.showHighlights}
highlightStyle={props.settings.highlightStyle || 'marker'}
highlightColor={props.settings.highlightColor || '#ffff00'}
onHighlightClick={props.onHighlightClick}
selectedHighlightId={props.selectedHighlightId}
highlightVisibility={props.highlightVisibility}
onTextSelection={props.onTextSelection}
onClearSelection={props.onClearSelection}
currentUserPubkey={props.currentUserPubkey}
followedPubkeys={props.followedPubkeys}
/>
)}
</div>
<div className="pane highlights">
<HighlightsPanel
highlights={props.highlights}
loading={props.highlightsLoading}
isCollapsed={props.isHighlightsCollapsed}
onToggleCollapse={props.onToggleHighlightsPanel}
onSelectUrl={props.onSelectUrl}
selectedUrl={props.selectedUrl}
onToggleHighlights={props.onToggleHighlights}
selectedHighlightId={props.selectedHighlightId}
onRefresh={props.onRefreshHighlights}
onHighlightClick={props.onHighlightClick}
currentUserPubkey={props.currentUserPubkey}
highlightVisibility={props.highlightVisibility}
onHighlightVisibilityChange={props.onHighlightVisibilityChange}
followedPubkeys={props.followedPubkeys}
/>
</div>
</div>
{props.hasActiveAccount && (
<HighlightButton
ref={props.highlightButtonRef}
onHighlight={props.onCreateHighlight}
highlightColor={props.settings.highlightColor || '#ffff00'}
/>
)}
{props.toastMessage && (
<Toast
message={props.toastMessage}
type={props.toastType}
onClose={props.onClearToast}
/>
)}
</>
)
}
export default ThreePaneLayout

View File

@@ -49,6 +49,7 @@ export function useArticleLoader({
title: article.title,
markdown: article.markdown,
image: article.image,
summary: article.summary,
url: `nostr:${naddr}`
})
@@ -71,19 +72,22 @@ 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))
}
}
)
console.log(`📌 Found ${highlightsList.length} highlights`)
console.log(`📌 Found ${highlightsMap.size} highlights`)
} catch (err) {
console.error('Failed to fetch highlights:', err)
} finally {
@@ -101,5 +105,5 @@ export function useArticleLoader({
}
loadArticle()
}, [naddr, relayPool])
}, [naddr, relayPool, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId, setCurrentArticle])
}

View File

@@ -0,0 +1,123 @@
import { useState, useEffect, useCallback } from 'react'
import { RelayPool } from 'applesauce-relay'
import { Bookmark } from '../types/bookmarks'
import { Highlight } from '../types/highlights'
import { fetchBookmarks } from '../services/bookmarkService'
import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService'
import { fetchContacts } from '../services/contactService'
interface UseBookmarksDataParams {
relayPool: RelayPool | null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
activeAccount: any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
accountManager: any
naddr?: string
currentArticleCoordinate?: string
currentArticleEventId?: string
}
export const useBookmarksData = ({
relayPool,
activeAccount,
accountManager,
naddr,
currentArticleCoordinate,
currentArticleEventId
}: UseBookmarksDataParams) => {
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [bookmarksLoading, setBookmarksLoading] = useState(true)
const [highlights, setHighlights] = useState<Highlight[]>([])
const [highlightsLoading, setHighlightsLoading] = useState(true)
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
const [isRefreshing, setIsRefreshing] = useState(false)
const handleFetchContacts = useCallback(async () => {
if (!relayPool || !activeAccount) return
const contacts = await fetchContacts(relayPool, activeAccount.pubkey)
setFollowedPubkeys(contacts)
}, [relayPool, activeAccount])
const handleFetchBookmarks = useCallback(async () => {
if (!relayPool || !activeAccount) return
setBookmarksLoading(true)
try {
const fullAccount = accountManager.getActive()
await fetchBookmarks(relayPool, fullAccount || activeAccount, setBookmarks)
} finally {
setBookmarksLoading(false)
}
}, [relayPool, activeAccount, accountManager])
const handleFetchHighlights = useCallback(async () => {
if (!relayPool) return
setHighlightsLoading(true)
try {
if (currentArticleCoordinate) {
const highlightsMap = new Map<string, Highlight>()
await fetchHighlightsForArticle(
relayPool,
currentArticleCoordinate,
currentArticleEventId,
(highlight) => {
// Deduplicate highlights by ID as they arrive
if (!highlightsMap.has(highlight.id)) {
highlightsMap.set(highlight.id, highlight)
const highlightsList = Array.from(highlightsMap.values())
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
}
}
)
console.log(`🔄 Refreshed ${highlightsMap.size} highlights for article`)
} else if (activeAccount) {
const fetchedHighlights = await fetchHighlights(relayPool, activeAccount.pubkey)
setHighlights(fetchedHighlights)
}
} catch (err) {
console.error('Failed to fetch highlights:', err)
} finally {
setHighlightsLoading(false)
}
}, [relayPool, activeAccount, currentArticleCoordinate, currentArticleEventId])
const handleRefreshAll = useCallback(async () => {
if (!relayPool || !activeAccount || isRefreshing) return
setIsRefreshing(true)
try {
await handleFetchBookmarks()
await handleFetchHighlights()
await handleFetchContacts()
} catch (err) {
console.error('Failed to refresh data:', err)
} finally {
setIsRefreshing(false)
}
}, [relayPool, activeAccount, isRefreshing, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
// Load initial data
useEffect(() => {
if (!relayPool || !activeAccount) return
handleFetchBookmarks()
if (!naddr) {
handleFetchHighlights()
}
handleFetchContacts()
}, [relayPool, activeAccount?.pubkey, naddr, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
return {
bookmarks,
bookmarksLoading,
highlights,
setHighlights,
highlightsLoading,
setHighlightsLoading,
followedPubkeys,
isRefreshing,
handleFetchBookmarks,
handleFetchHighlights,
handleRefreshAll
}
}

View File

@@ -0,0 +1,61 @@
import { useState, useEffect } from 'react'
import { NostrEvent } from 'nostr-tools'
import { HighlightVisibility } from '../components/HighlightsPanel'
import { UserSettings } from '../services/settingsService'
import { ViewMode } from '../components/Bookmarks'
interface UseBookmarksUIParams {
settings: UserSettings
}
export const useBookmarksUI = ({ settings }: UseBookmarksUIParams) => {
const [isCollapsed, setIsCollapsed] = useState(true)
const [isHighlightsCollapsed, setIsHighlightsCollapsed] = useState(true)
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)
const [highlightVisibility, setHighlightVisibility] = useState<HighlightVisibility>({
nostrverse: true,
friends: true,
mine: true
})
// Apply UI settings
useEffect(() => {
if (settings.defaultViewMode) setViewMode(settings.defaultViewMode)
if (settings.showHighlights !== undefined) setShowHighlights(settings.showHighlights)
setHighlightVisibility({
nostrverse: settings.defaultHighlightVisibilityNostrverse !== false,
friends: settings.defaultHighlightVisibilityFriends !== false,
mine: settings.defaultHighlightVisibilityMine !== false
})
}, [settings])
return {
isCollapsed,
setIsCollapsed,
isHighlightsCollapsed,
setIsHighlightsCollapsed,
viewMode,
setViewMode,
showHighlights,
setShowHighlights,
selectedHighlightId,
setSelectedHighlightId,
showSettings,
setShowSettings,
currentArticleCoordinate,
setCurrentArticleCoordinate,
currentArticleEventId,
setCurrentArticleEventId,
currentArticle,
setCurrentArticle,
highlightVisibility,
setHighlightVisibility
}
}

View File

@@ -0,0 +1,75 @@
import { useState, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { RelayPool } from 'applesauce-relay'
import { NostrEvent, nip19 } from 'nostr-tools'
import { loadContent, BookmarkReference } from '../utils/contentLoader'
import { ReadableContent } from '../services/readerService'
import { UserSettings } from '../services/settingsService'
interface UseContentSelectionParams {
relayPool: RelayPool | null
settings: UserSettings
setIsCollapsed: (collapsed: boolean) => void
setShowSettings: (show: boolean) => void
setCurrentArticle: (article: NostrEvent | undefined) => void
}
export const useContentSelection = ({
relayPool,
settings,
setIsCollapsed,
setShowSettings,
setCurrentArticle
}: UseContentSelectionParams) => {
const navigate = useNavigate()
const [selectedUrl, setSelectedUrl] = useState<string | undefined>(undefined)
const [readerLoading, setReaderLoading] = useState(false)
const [readerContent, setReaderContent] = useState<ReadableContent | undefined>(undefined)
const handleSelectUrl = useCallback(async (url: string, bookmark?: BookmarkReference) => {
if (!relayPool) return
// Update the URL path based on content type
if (bookmark && bookmark.kind === 30023) {
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1] || ''
if (dTag && bookmark.pubkey) {
const pointer = {
identifier: dTag,
kind: 30023,
pubkey: bookmark.pubkey,
}
const naddr = nip19.naddrEncode(pointer)
navigate(`/a/${naddr}`)
}
} else if (url) {
navigate(`/r/${encodeURIComponent(url)}`)
}
setSelectedUrl(url)
setReaderLoading(true)
setReaderContent(undefined)
setCurrentArticle(undefined)
setShowSettings(false)
if (settings.collapseOnArticleOpen !== false) setIsCollapsed(true)
try {
const content = await loadContent(url, relayPool, bookmark)
setReaderContent(content)
} catch (err) {
console.warn('Failed to fetch content:', err)
} finally {
setReaderLoading(false)
}
}, [relayPool, settings, navigate, setIsCollapsed, setShowSettings, setCurrentArticle])
return {
selectedUrl,
setSelectedUrl,
readerLoading,
setReaderLoading,
readerContent,
setReaderContent,
handleSelectUrl
}
}

View File

@@ -80,6 +80,6 @@ export function useExternalUrlLoader({
}
loadExternalUrl()
}, [url, relayPool])
}, [url, relayPool, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId])
}

View File

@@ -0,0 +1,49 @@
import { useMemo } from 'react'
import { Highlight } from '../types/highlights'
import { HighlightVisibility } from '../components/HighlightsPanel'
import { normalizeUrl } from '../utils/urlHelpers'
import { classifyHighlights } from '../utils/highlightClassification'
interface UseFilteredHighlightsParams {
highlights: Highlight[]
selectedUrl?: string
highlightVisibility: HighlightVisibility
currentUserPubkey?: string
followedPubkeys: Set<string>
}
export const useFilteredHighlights = ({
highlights,
selectedUrl,
highlightVisibility,
currentUserPubkey,
followedPubkeys
}: UseFilteredHighlightsParams) => {
return useMemo(() => {
if (!selectedUrl) return highlights
let urlFiltered = highlights
// For Nostr articles, we already fetched highlights specifically for this article
if (!selectedUrl.startsWith('nostr:')) {
const normalizedSelected = normalizeUrl(selectedUrl)
urlFiltered = highlights.filter(h => {
if (!h.urlReference) return false
const normalizedRef = normalizeUrl(h.urlReference)
return normalizedSelected === normalizedRef ||
normalizedSelected.includes(normalizedRef) ||
normalizedRef.includes(normalizedSelected)
})
}
// Classify and filter by visibility levels
const classified = classifyHighlights(urlFiltered, currentUserPubkey, followedPubkeys)
return classified.filter(h => {
if (h.level === 'mine') return highlightVisibility.mine
if (h.level === 'friends') return highlightVisibility.friends
return highlightVisibility.nostrverse
})
}, [highlights, selectedUrl, highlightVisibility, currentUserPubkey, followedPubkeys])
}

View File

@@ -0,0 +1,84 @@
import { useCallback, useRef } from 'react'
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { Highlight } from '../types/highlights'
import { ReadableContent } from '../services/readerService'
import { createHighlight, eventToHighlight } from '../services/highlightCreationService'
import { HighlightButtonRef } from '../components/HighlightButton'
import { UserSettings } from '../services/settingsService'
interface UseHighlightCreationParams {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
activeAccount: any
relayPool: RelayPool | null
currentArticle: NostrEvent | undefined
selectedUrl: string | undefined
readerContent: ReadableContent | undefined
onHighlightCreated: (highlight: Highlight) => void
settings?: UserSettings
}
export const useHighlightCreation = ({
activeAccount,
relayPool,
currentArticle,
selectedUrl,
readerContent,
onHighlightCreated,
settings
}: UseHighlightCreationParams) => {
const highlightButtonRef = useRef<HighlightButtonRef>(null)
const handleTextSelection = useCallback((text: string) => {
highlightButtonRef.current?.updateSelection(text)
}, [])
const handleClearSelection = useCallback(() => {
highlightButtonRef.current?.clearSelection()
}, [])
const handleCreateHighlight = useCallback(async (text: string) => {
if (!activeAccount || !relayPool) {
console.error('Missing requirements for highlight creation')
return
}
if (!currentArticle && !selectedUrl) {
console.error('No source available for highlight creation')
return
}
try {
const source = currentArticle || selectedUrl!
const contentForContext = currentArticle
? currentArticle.content
: readerContent?.markdown || readerContent?.html
const signedEvent = await createHighlight(
text,
source,
activeAccount,
relayPool,
contentForContext,
undefined,
settings
)
console.log('✅ Highlight created successfully!')
highlightButtonRef.current?.clearSelection()
const newHighlight = eventToHighlight(signedEvent)
onHighlightCreated(newHighlight)
} catch (error) {
console.error('Failed to create highlight:', error)
}
}, [activeAccount, relayPool, currentArticle, selectedUrl, readerContent, onHighlightCreated, settings])
return {
highlightButtonRef,
handleTextSelection,
handleClearSelection,
handleCreateHighlight
}
}

View File

@@ -0,0 +1,81 @@
import { useEffect, useCallback, useRef } from 'react'
interface UseHighlightInteractionsParams {
onHighlightClick?: (highlightId: string) => void
selectedHighlightId?: string
onTextSelection?: (text: string) => void
onClearSelection?: () => void
}
export const useHighlightInteractions = ({
onHighlightClick,
selectedHighlightId,
onTextSelection,
onClearSelection
}: UseHighlightInteractionsParams) => {
const contentRef = useRef<HTMLDivElement>(null)
// Attach click handlers to highlight marks
useEffect(() => {
if (!onHighlightClick || !contentRef.current) return
const marks = contentRef.current.querySelectorAll('mark[data-highlight-id]')
const handlers = new Map<Element, () => void>()
marks.forEach(mark => {
const highlightId = mark.getAttribute('data-highlight-id')
if (highlightId) {
const handler = () => onHighlightClick(highlightId)
mark.addEventListener('click', handler)
;(mark as HTMLElement).style.cursor = 'pointer'
handlers.set(mark, handler)
}
})
return () => {
handlers.forEach((handler, mark) => {
mark.removeEventListener('click', handler)
})
}
}, [onHighlightClick])
// Scroll to selected highlight
useEffect(() => {
if (!selectedHighlightId || !contentRef.current) return
const markElement = contentRef.current.querySelector(`mark[data-highlight-id="${selectedHighlightId}"]`)
if (markElement) {
markElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
const htmlElement = markElement as HTMLElement
setTimeout(() => {
htmlElement.classList.add('highlight-pulse')
setTimeout(() => htmlElement.classList.remove('highlight-pulse'), 1500)
}, 500)
}
}, [selectedHighlightId])
// Handle text selection
const handleMouseUp = useCallback(() => {
setTimeout(() => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) {
onClearSelection?.()
return
}
const range = selection.getRangeAt(0)
const text = selection.toString().trim()
if (text.length > 0 && contentRef.current?.contains(range.commonAncestorContainer)) {
onTextSelection?.(text)
} else {
onClearSelection?.()
}
}, 10)
}, [onTextSelection, onClearSelection])
return { contentRef, handleMouseUp }
}

View File

@@ -0,0 +1,87 @@
import { useMemo } from 'react'
import { Highlight } from '../types/highlights'
import { applyHighlightsToHTML } from '../utils/highlightMatching'
import { filterHighlightsByUrl } from '../utils/urlHelpers'
import { HighlightVisibility } from '../components/HighlightsPanel'
import { classifyHighlights } from '../utils/highlightClassification'
interface UseHighlightedContentParams {
html?: string
markdown?: string
renderedMarkdownHtml: string
highlights: Highlight[]
showHighlights: boolean
highlightStyle: 'marker' | 'underline'
selectedUrl?: string
highlightVisibility: HighlightVisibility
currentUserPubkey?: string
followedPubkeys: Set<string>
}
export const useHighlightedContent = ({
html,
markdown,
renderedMarkdownHtml,
highlights,
showHighlights,
highlightStyle,
selectedUrl,
highlightVisibility,
currentUserPubkey,
followedPubkeys
}: UseHighlightedContentParams) => {
// Filter highlights by URL and visibility settings
const relevantHighlights = useMemo(() => {
console.log('🔍 ContentPanel: Processing highlights', {
totalHighlights: highlights.length,
selectedUrl,
showHighlights
})
const urlFiltered = filterHighlightsByUrl(highlights, selectedUrl)
console.log('📌 URL filtered highlights:', urlFiltered.length)
// Apply visibility filtering
const classified = classifyHighlights(urlFiltered, currentUserPubkey, followedPubkeys)
const filtered = classified.filter(h => {
if (h.level === 'mine') return highlightVisibility.mine
if (h.level === 'friends') return highlightVisibility.friends
return highlightVisibility.nostrverse
})
console.log('✅ Relevant highlights after filtering:', filtered.length, filtered.map(h => h.content.substring(0, 30)))
return filtered
}, [selectedUrl, highlights, highlightVisibility, currentUserPubkey, followedPubkeys, showHighlights])
// Prepare the final HTML with highlights applied
const finalHtml = useMemo(() => {
const sourceHtml = markdown ? renderedMarkdownHtml : html
console.log('🎨 Preparing final HTML:', {
hasMarkdown: !!markdown,
hasHtml: !!html,
renderedHtmlLength: renderedMarkdownHtml.length,
sourceHtmlLength: sourceHtml?.length || 0,
showHighlights,
relevantHighlightsCount: relevantHighlights.length
})
if (!sourceHtml) {
console.warn('⚠️ No source HTML available')
return ''
}
if (showHighlights && relevantHighlights.length > 0) {
console.log('✨ Applying', relevantHighlights.length, 'highlights to HTML')
const highlightedHtml = applyHighlightsToHTML(sourceHtml, relevantHighlights, highlightStyle)
console.log('✅ Highlights applied, result length:', highlightedHtml.length)
return highlightedHtml
}
console.log('📄 Returning source HTML without highlights')
return sourceHtml
}, [html, renderedMarkdownHtml, markdown, relevantHighlights, showHighlights, highlightStyle])
return { finalHtml, relevantHighlights }
}

View File

@@ -0,0 +1,35 @@
import React, { useState, useEffect, useRef } from 'react'
/**
* Hook to convert markdown to HTML using a hidden ReactMarkdown component
*/
export const useMarkdownToHTML = (markdown?: string): { renderedHtml: string, previewRef: React.RefObject<HTMLDivElement> } => {
const previewRef = useRef<HTMLDivElement>(null)
const [renderedHtml, setRenderedHtml] = useState<string>('')
useEffect(() => {
if (!markdown) {
setRenderedHtml('')
return
}
console.log('📝 Converting markdown to HTML...')
const rafId = requestAnimationFrame(() => {
if (previewRef.current) {
const html = previewRef.current.innerHTML
console.log('✅ Markdown converted to HTML:', html.length, 'chars')
setRenderedHtml(html)
} else {
console.warn('⚠️ markdownPreviewRef.current is null')
}
})
return () => cancelAnimationFrame(rafId)
}, [markdown])
return { renderedHtml, previewRef }
}
// Removed separate useMarkdownPreviewRef; use useMarkdownToHTML to obtain previewRef

View File

@@ -501,17 +501,20 @@ body {
}
.reader-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 2rem;
}
.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 {
@@ -1070,6 +1073,8 @@ body {
margin: 0 0 2rem 0;
border-radius: 8px;
overflow: hidden;
position: relative;
min-height: 300px;
}
.reader-hero-image img {
@@ -1080,6 +1085,50 @@ 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 .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;
@@ -2020,6 +2069,104 @@ body {
font-weight: 500;
}
.zap-split-container {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.zap-split-labels {
display: flex;
justify-content: space-between;
align-items: center;
}
.zap-split-label {
font-size: 0.9rem;
color: #ccc;
font-weight: 500;
}
.zap-split-slider {
width: 100%;
height: 8px;
background: linear-gradient(to right, #646cff 0%, #646cff 50%, #f97316 50%, #f97316 100%);
border-radius: 4px;
outline: none;
cursor: pointer;
-webkit-appearance: none;
appearance: none;
}
.zap-split-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
background: white;
border: 2px solid #646cff;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s;
}
.zap-split-slider::-moz-range-thumb {
width: 20px;
height: 20px;
background: white;
border: 2px solid #646cff;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s;
}
.zap-split-slider::-webkit-slider-thumb:hover {
transform: scale(1.1);
box-shadow: 0 0 8px rgba(100, 108, 255, 0.5);
}
.zap-split-slider::-moz-range-thumb:hover {
transform: scale(1.1);
box-shadow: 0 0 8px rgba(100, 108, 255, 0.5);
}
.zap-split-description {
font-size: 0.8rem;
color: #999;
line-height: 1.4;
text-align: left;
}
.zap-preset-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.zap-preset-btn {
padding: 0.5rem 1rem;
background: #2a2a2a;
border: 1px solid #444;
border-radius: 6px;
color: #ccc;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
flex: 1;
min-width: 80px;
}
.zap-preset-btn:hover {
background: #333;
border-color: #646cff;
color: white;
transform: translateY(-1px);
}
.zap-preset-btn:active {
transform: translateY(0);
}
.settings-footer {
display: flex;
justify-content: flex-start;
@@ -2095,3 +2242,168 @@ 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;
}

View File

@@ -6,6 +6,10 @@ import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { RELAYS } from '../config/relays'
import { Highlight } from '../types/highlights'
import { UserSettings } from './settingsService'
// Boris pubkey for zap splits
const BORIS_PUBKEY = '6e468422dfb74a5738702a8823b9b28168fc6cfb119d613e49ca0ec5a0bbd0c3'
const {
getHighlightText,
@@ -30,7 +34,8 @@ export async function createHighlight(
account: IAccount,
relayPool: RelayPool,
contentForContext?: string,
comment?: string
comment?: string,
settings?: UserSettings
): Promise<NostrEvent> {
if (!selectedText || !source) {
throw new Error('Missing required data to create highlight')
@@ -72,6 +77,30 @@ export async function createHighlight(
highlightEvent.tags.push(['alt', 'Highlight created by Boris. readwithboris.com'])
}
// Add zap tags for nostr-native content (NIP-57 Appendix G)
if (typeof source === 'object' && 'kind' in source) {
// Migrate old settings format to new weight-based format if needed
let highlighterWeight = settings?.zapSplitHighlighterWeight
let borisWeight = settings?.zapSplitBorisWeight
let authorWeight = settings?.zapSplitAuthorWeight
const anySettings = settings as Record<string, unknown> | undefined
if (!highlighterWeight && anySettings && 'zapSplitPercentage' in anySettings) {
highlighterWeight = anySettings.zapSplitPercentage as number
authorWeight = 100 - (anySettings.zapSplitPercentage as number)
}
if (!borisWeight && anySettings && 'borisSupportPercentage' in anySettings) {
borisWeight = anySettings.borisSupportPercentage as number
}
// Use defaults if still undefined
highlighterWeight = highlighterWeight ?? 50
borisWeight = borisWeight ?? 2.1
authorWeight = authorWeight ?? 50
addZapTags(highlightEvent, account.pubkey, source, highlighterWeight, borisWeight, authorWeight)
}
// Sign the event
const signedEvent = await factory.sign(highlightEvent)
@@ -176,6 +205,76 @@ function extractContext(selectedText: string, articleContent: string): string |
return contextParts.length > 1 ? contextParts.join(' ') : undefined
}
/**
* Adds zap tags to a highlight event for split payments (NIP-57 Appendix G)
* Respects existing zap tags in the source event (author group)
* @param event The highlight event to add zap tags to (can be EventTemplate or NostrEvent)
* @param highlighterPubkey The pubkey of the user creating the highlight
* @param sourceEvent The source event (may contain existing zap tags)
* @param highlighterWeight Weight to give to the highlighter (default 50)
* @param borisWeight Weight to give to Boris (default 2.1)
* @param authorWeight Weight to give to author(s) (default 50)
*/
function addZapTags(
event: { tags: string[][] },
highlighterPubkey: string,
sourceEvent: NostrEvent,
highlighterWeight: number = 50,
borisWeight: number = 2.1,
authorWeight: number = 50
): void {
// Use a reliable relay for zap metadata lookup (first non-local relay)
const zapRelay = RELAYS.find(r => !r.includes('localhost')) || RELAYS[0]
// Extract existing zap tags from source event (the "author group")
const existingZapTags = sourceEvent.tags.filter(tag => tag[0] === 'zap')
// Add zap tag for the highlighter
if (highlighterWeight > 0) {
event.tags.push(['zap', highlighterPubkey, zapRelay, highlighterWeight.toString()])
}
// Add zap tag for Boris (if weight > 0 and Boris is not the highlighter)
if (borisWeight > 0 && BORIS_PUBKEY !== highlighterPubkey) {
event.tags.push(['zap', BORIS_PUBKEY, zapRelay, borisWeight.toFixed(1)])
}
if (existingZapTags.length > 0 && authorWeight > 0) {
// Calculate total weight from existing zap tags
const totalExistingWeight = existingZapTags.reduce((sum, tag) => {
const weight = parseFloat(tag[3] || '1')
return sum + weight
}, 0)
// Add proportionally adjusted zap tags for each existing author
// Don't add the highlighter or Boris again if they're already in the author group
for (const zapTag of existingZapTags) {
const authorPubkey = zapTag[1]
// Skip if this is the highlighter or Boris (they already have their shares)
if (authorPubkey === highlighterPubkey || authorPubkey === BORIS_PUBKEY) continue
const originalWeight = parseFloat(zapTag[3] || '1')
const originalRelay = zapTag[2] || zapRelay
// Calculate proportional weight: (original weight / total weight) * author group weight
const adjustedWeight = (originalWeight / totalExistingWeight) * authorWeight
// Only add if weight is greater than 0
if (adjustedWeight > 0) {
event.tags.push(['zap', authorPubkey, originalRelay, adjustedWeight.toFixed(1)])
}
}
} else if (authorWeight > 0) {
// No existing zap tags, give full author weight to source author
// Add zap tag for the original author (only if different from highlighter and Boris)
if (sourceEvent.pubkey !== highlighterPubkey && sourceEvent.pubkey !== BORIS_PUBKEY) {
event.tags.push(['zap', sourceEvent.pubkey, zapRelay, authorWeight.toFixed(1)])
}
}
}
/**
* Converts a NostrEvent to a Highlight object for immediate UI display
*/

View File

@@ -0,0 +1,66 @@
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { Highlight } from '../types/highlights'
const {
getHighlightText,
getHighlightContext,
getHighlightComment,
getHighlightSourceEventPointer,
getHighlightSourceAddressPointer,
getHighlightSourceUrl,
getHighlightAttributions
} = Helpers
/**
* Convert a NostrEvent to a Highlight object
*/
export function eventToHighlight(event: NostrEvent): Highlight {
const highlightText = getHighlightText(event)
const context = getHighlightContext(event)
const comment = getHighlightComment(event)
const sourceEventPointer = getHighlightSourceEventPointer(event)
const sourceAddressPointer = getHighlightSourceAddressPointer(event)
const sourceUrl = getHighlightSourceUrl(event)
const attributions = getHighlightAttributions(event)
const author = attributions.find(a => a.role === 'author')?.pubkey
const eventReference = sourceEventPointer?.id ||
(sourceAddressPointer ? `${sourceAddressPointer.kind}:${sourceAddressPointer.pubkey}:${sourceAddressPointer.identifier}` : undefined)
return {
id: event.id,
pubkey: event.pubkey,
created_at: event.created_at,
content: highlightText,
tags: event.tags,
eventReference,
urlReference: sourceUrl,
author,
context,
comment
}
}
/**
* Deduplicate highlight events by ID
*/
export function dedupeHighlights(events: NostrEvent[]): NostrEvent[] {
const byId = new Map<string, NostrEvent>()
for (const event of events) {
if (event?.id && !byId.has(event.id)) {
byId.set(event.id, event)
}
}
return Array.from(byId.values())
}
/**
* Sort highlights by creation time (newest first)
*/
export function sortHighlights(highlights: Highlight[]): Highlight[] {
return highlights.sort((a, b) => b.created_at - a.created_at)
}

View File

@@ -1,36 +1,9 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { lastValueFrom, takeUntil, timer, tap, toArray } from 'rxjs'
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { Highlight } from '../types/highlights'
import { RELAYS } from '../config/relays'
const {
getHighlightText,
getHighlightContext,
getHighlightComment,
getHighlightSourceEventPointer,
getHighlightSourceAddressPointer,
getHighlightSourceUrl,
getHighlightAttributions
} = Helpers
/**
* Deduplicate highlight events by ID
* Since highlights can come from multiple relays, we need to ensure
* we only show each unique highlight once
*/
function dedupeHighlights(events: NostrEvent[]): NostrEvent[] {
const byId = new Map<string, NostrEvent>()
for (const event of events) {
if (event?.id && !byId.has(event.id)) {
byId.set(event.id, event)
}
}
return Array.from(byId.values())
}
import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor'
/**
* Fetches highlights for a specific article by its address coordinate and/or event ID
@@ -53,31 +26,7 @@ export const fetchHighlightsForArticle = async (
const processEvent = (event: NostrEvent): Highlight | null => {
if (seenIds.has(event.id)) return null
seenIds.add(event.id)
const highlightText = getHighlightText(event)
const context = getHighlightContext(event)
const comment = getHighlightComment(event)
const sourceEventPointer = getHighlightSourceEventPointer(event)
const sourceAddressPointer = getHighlightSourceAddressPointer(event)
const sourceUrl = getHighlightSourceUrl(event)
const attributions = getHighlightAttributions(event)
const author = attributions.find(a => a.role === 'author')?.pubkey
const eventReference = sourceEventPointer?.id ||
(sourceAddressPointer ? `${sourceAddressPointer.kind}:${sourceAddressPointer.pubkey}:${sourceAddressPointer.identifier}` : undefined)
return {
id: event.id,
pubkey: event.pubkey,
created_at: event.created_at,
content: highlightText,
tags: event.tags,
eventReference,
urlReference: sourceUrl,
author,
context,
comment
}
return eventToHighlight(event)
}
// Query for highlights that reference this article via the 'a' tag
@@ -138,39 +87,8 @@ export const fetchHighlightsForArticle = async (
const uniqueEvents = dedupeHighlights(rawEvents)
console.log('📊 Unique highlight events after deduplication:', uniqueEvents.length)
const highlights: Highlight[] = uniqueEvents.map((event: NostrEvent) => {
// Use applesauce helpers to extract highlight data
const highlightText = getHighlightText(event)
const context = getHighlightContext(event)
const comment = getHighlightComment(event)
const sourceEventPointer = getHighlightSourceEventPointer(event)
const sourceAddressPointer = getHighlightSourceAddressPointer(event)
const sourceUrl = getHighlightSourceUrl(event)
const attributions = getHighlightAttributions(event)
// Get author from attributions
const author = attributions.find(a => a.role === 'author')?.pubkey
// Get event reference (prefer event pointer, fallback to address pointer)
const eventReference = sourceEventPointer?.id ||
(sourceAddressPointer ? `${sourceAddressPointer.kind}:${sourceAddressPointer.pubkey}:${sourceAddressPointer.identifier}` : undefined)
return {
id: event.id,
pubkey: event.pubkey,
created_at: event.created_at,
content: highlightText,
tags: event.tags,
eventReference,
urlReference: sourceUrl,
author,
context,
comment
}
})
// Sort by creation time (newest first)
return highlights.sort((a, b) => b.created_at - a.created_at)
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
return sortHighlights(highlights)
} catch (error) {
console.error('Failed to fetch highlights for article:', error)
return []
@@ -207,34 +125,8 @@ export const fetchHighlightsForUrl = async (
console.log('📊 Highlights for URL:', rawEvents.length)
const uniqueEvents = dedupeHighlights(rawEvents)
const highlights: Highlight[] = uniqueEvents.map((event: NostrEvent) => {
const highlightText = getHighlightText(event)
const context = getHighlightContext(event)
const comment = getHighlightComment(event)
const sourceEventPointer = getHighlightSourceEventPointer(event)
const sourceAddressPointer = getHighlightSourceAddressPointer(event)
const sourceUrl = getHighlightSourceUrl(event)
const attributions = getHighlightAttributions(event)
const author = attributions.find(a => a.role === 'author')?.pubkey
const eventReference = sourceEventPointer?.id ||
(sourceAddressPointer ? `${sourceAddressPointer.kind}:${sourceAddressPointer.pubkey}:${sourceAddressPointer.identifier}` : undefined)
return {
id: event.id,
pubkey: event.pubkey,
created_at: event.created_at,
content: highlightText,
tags: event.tags,
eventReference,
urlReference: sourceUrl,
author,
context,
comment
}
})
return highlights.sort((a, b) => b.created_at - a.created_at)
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
return sortHighlights(highlights)
} catch (error) {
console.error('Failed to fetch highlights for URL:', error)
return []
@@ -266,32 +158,7 @@ export const fetchHighlights = async (
tap((event: NostrEvent) => {
if (!seenIds.has(event.id)) {
seenIds.add(event.id)
const highlightText = getHighlightText(event)
const context = getHighlightContext(event)
const comment = getHighlightComment(event)
const sourceEventPointer = getHighlightSourceEventPointer(event)
const sourceAddressPointer = getHighlightSourceAddressPointer(event)
const sourceUrl = getHighlightSourceUrl(event)
const attributions = getHighlightAttributions(event)
const author = attributions.find(a => a.role === 'author')?.pubkey
const eventReference = sourceEventPointer?.id ||
(sourceAddressPointer ? `${sourceAddressPointer.kind}:${sourceAddressPointer.pubkey}:${sourceAddressPointer.identifier}` : undefined)
const highlight: Highlight = {
id: event.id,
pubkey: event.pubkey,
created_at: event.created_at,
content: highlightText,
tags: event.tags,
eventReference,
urlReference: sourceUrl,
author,
context,
comment
}
const highlight = eventToHighlight(event)
if (onHighlight) {
onHighlight(highlight)
}
@@ -309,35 +176,8 @@ export const fetchHighlights = async (
const uniqueEvents = dedupeHighlights(rawEvents)
console.log('📊 Unique highlight events after deduplication:', uniqueEvents.length)
const highlights: Highlight[] = uniqueEvents.map((event: NostrEvent) => {
const highlightText = getHighlightText(event)
const context = getHighlightContext(event)
const comment = getHighlightComment(event)
const sourceEventPointer = getHighlightSourceEventPointer(event)
const sourceAddressPointer = getHighlightSourceAddressPointer(event)
const sourceUrl = getHighlightSourceUrl(event)
const attributions = getHighlightAttributions(event)
const author = attributions.find(a => a.role === 'author')?.pubkey
const eventReference = sourceEventPointer?.id ||
(sourceAddressPointer ? `${sourceAddressPointer.kind}:${sourceAddressPointer.pubkey}:${sourceAddressPointer.identifier}` : undefined)
return {
id: event.id,
pubkey: event.pubkey,
created_at: event.created_at,
content: highlightText,
tags: event.tags,
eventReference,
urlReference: sourceUrl,
author,
context,
comment
}
})
// Sort by creation time (newest first)
return highlights.sort((a, b) => b.created_at - a.created_at)
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
return sortHighlights(highlights)
} catch (error) {
console.error('Failed to fetch highlights by author:', error)
return []

View File

@@ -7,6 +7,7 @@ export interface ReadableContent {
html?: string
markdown?: string
image?: string
summary?: string
}
interface CachedContent {
@@ -57,7 +58,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

@@ -35,6 +35,10 @@ export interface UserSettings {
defaultHighlightVisibilityNostrverse?: boolean
defaultHighlightVisibilityFriends?: boolean
defaultHighlightVisibilityMine?: boolean
// Zap split weights (treated as relative weights, not strict percentages)
zapSplitHighlighterWeight?: number // default 50
zapSplitBorisWeight?: number // default 2.1
zapSplitAuthorWeight?: number // default 50
}
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

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

@@ -0,0 +1,34 @@
import { Highlight } from '../types/highlights'
export type HighlightLevel = 'mine' | 'friends' | 'nostrverse'
/**
* Classify a highlight based on the current user and their followed pubkeys
*/
export function classifyHighlight(
highlight: Highlight,
currentUserPubkey?: string,
followedPubkeys: Set<string> = new Set()
): Highlight & { level: HighlightLevel } {
let level: HighlightLevel = 'nostrverse'
if (highlight.pubkey === currentUserPubkey) {
level = 'mine'
} else if (followedPubkeys.has(highlight.pubkey)) {
level = 'friends'
}
return { ...highlight, level }
}
/**
* Classify an array of highlights
*/
export function classifyHighlights(
highlights: Highlight[],
currentUserPubkey?: string,
followedPubkeys: Set<string> = new Set()
): Array<Highlight & { level: HighlightLevel }> {
return highlights.map(h => classifyHighlight(h, currentUserPubkey, followedPubkeys))
}

View File

@@ -1,46 +1,11 @@
import React from 'react'
import { Highlight } from '../types/highlights'
export interface HighlightMatch {
highlight: Highlight
startIndex: number
endIndex: number
}
export type { HighlightMatch } from './highlightMatching/textMatching'
export { findHighlightMatches } from './highlightMatching/textMatching'
export { applyHighlightsToHTML } from './highlightMatching/htmlMatching'
/**
* Find all occurrences of highlight text in the content
*/
export function findHighlightMatches(
content: string,
highlights: Highlight[]
): HighlightMatch[] {
const matches: HighlightMatch[] = []
for (const highlight of highlights) {
if (!highlight.content || highlight.content.trim().length === 0) {
continue
}
const searchText = highlight.content.trim()
let startIndex = 0
// Find all occurrences of this highlight in the content
let index = content.indexOf(searchText, startIndex)
while (index !== -1) {
matches.push({
highlight,
startIndex: index,
endIndex: index + searchText.length
})
startIndex = index + searchText.length
index = content.indexOf(searchText, startIndex)
}
}
// Sort by start index
return matches.sort((a, b) => a.startIndex - b.startIndex)
}
import { findHighlightMatches as _findHighlightMatches } from './highlightMatching/textMatching'
/**
* Apply highlights to text content by wrapping matched text in span elements
@@ -49,7 +14,7 @@ export function applyHighlightsToText(
text: string,
highlights: Highlight[]
): React.ReactNode {
const matches = findHighlightMatches(text, highlights)
const matches = _findHighlightMatches(text, highlights)
if (matches.length === 0) {
return text
@@ -61,17 +26,14 @@ export function applyHighlightsToText(
for (let i = 0; i < matches.length; i++) {
const match = matches[i]
// Skip overlapping highlights (keep the first one)
if (match.startIndex < lastIndex) {
continue
}
// Add text before the highlight
if (match.startIndex > lastIndex) {
result.push(text.substring(lastIndex, match.startIndex))
}
// Add the highlighted text
const highlightedText = text.substring(match.startIndex, match.endIndex)
const levelClass = match.highlight.level ? ` level-${match.highlight.level}` : ''
result.push(
@@ -89,129 +51,9 @@ export function applyHighlightsToText(
lastIndex = match.endIndex
}
// Add remaining text
if (lastIndex < text.length) {
result.push(text.substring(lastIndex))
}
return <>{result}</>
}
// Helper to normalize whitespace for flexible matching
const normalizeWhitespace = (str: string) => str.replace(/\s+/g, ' ').trim()
// Helper to create a mark element for a highlight
function createMarkElement(highlight: Highlight, matchText: string, highlightStyle: 'marker' | 'underline' = 'marker'): HTMLElement {
const mark = document.createElement('mark')
const levelClass = highlight.level ? ` level-${highlight.level}` : ''
mark.className = `content-highlight-${highlightStyle}${levelClass}`
mark.setAttribute('data-highlight-id', highlight.id)
mark.setAttribute('data-highlight-level', highlight.level || 'nostrverse')
mark.setAttribute('title', `Highlighted ${new Date(highlight.created_at * 1000).toLocaleDateString()}`)
mark.textContent = matchText
return mark
}
// Helper to replace text node with mark element
function replaceTextWithMark(textNode: Text, before: string, after: string, mark: HTMLElement) {
const parent = textNode.parentNode
if (!parent) return
if (before) parent.insertBefore(document.createTextNode(before), textNode)
parent.insertBefore(mark, textNode)
if (after) {
textNode.textContent = after
} else {
parent.removeChild(textNode)
}
}
// Helper to find and mark text in nodes
function tryMarkInTextNodes(
textNodes: Text[],
searchText: string,
highlight: Highlight,
useNormalized: boolean,
highlightStyle: 'marker' | 'underline' = 'marker'
): boolean {
const normalizedSearch = normalizeWhitespace(searchText)
for (const textNode of textNodes) {
const text = textNode.textContent || ''
const searchIn = useNormalized ? normalizeWhitespace(text) : text
const searchFor = useNormalized ? normalizedSearch : searchText
const index = searchIn.indexOf(searchFor)
if (index === -1) continue
let actualIndex = index
if (useNormalized) {
// Map normalized index back to original text
let normalizedIdx = 0
for (let i = 0; i < text.length && normalizedIdx < index; i++) {
if (!/\s/.test(text[i]) || (i > 0 && !/\s/.test(text[i-1]))) normalizedIdx++
actualIndex = i + 1
}
}
const before = text.substring(0, actualIndex)
const match = text.substring(actualIndex, actualIndex + searchText.length)
const after = text.substring(actualIndex + searchText.length)
const mark = createMarkElement(highlight, match, highlightStyle)
replaceTextWithMark(textNode, before, after, mark)
return true
}
return false
}
/**
* Apply highlights to HTML content by injecting mark tags using DOM manipulation
*/
export function applyHighlightsToHTML(html: string, highlights: Highlight[], highlightStyle: 'marker' | 'underline' = 'marker'): string {
if (!html || highlights.length === 0) {
console.log('⚠️ applyHighlightsToHTML: No HTML or highlights', { htmlLength: html?.length, highlightsCount: highlights.length })
return html
}
console.log('🔨 applyHighlightsToHTML: Processing', highlights.length, 'highlights')
const tempDiv = document.createElement('div')
tempDiv.innerHTML = html
let appliedCount = 0
for (const highlight of highlights) {
const searchText = highlight.content.trim()
if (!searchText) {
console.warn('⚠️ Empty highlight content:', highlight.id)
continue
}
console.log('🔍 Searching for highlight:', searchText.substring(0, 50) + '...')
// Collect all text nodes
const walker = document.createTreeWalker(tempDiv, NodeFilter.SHOW_TEXT, null)
const textNodes: Text[] = []
let node: Node | null
while ((node = walker.nextNode())) textNodes.push(node as Text)
console.log('📄 Found', textNodes.length, 'text nodes to search')
// Try exact match first, then normalized match
const found = tryMarkInTextNodes(textNodes, searchText, highlight, false, highlightStyle) ||
tryMarkInTextNodes(textNodes, searchText, highlight, true, highlightStyle)
if (found) {
appliedCount++
console.log('✅ Highlight applied successfully')
} else {
console.warn('❌ Could not find match for highlight:', searchText.substring(0, 50))
}
}
console.log('🎉 Applied', appliedCount, '/', highlights.length, 'highlights')
return tempDiv.innerHTML
}

View File

@@ -0,0 +1,84 @@
import { Highlight } from '../../types/highlights'
import { normalizeWhitespace } from './textMatching'
/**
* Create a mark element for a highlight
*/
export function createMarkElement(
highlight: Highlight,
matchText: string,
highlightStyle: 'marker' | 'underline' = 'marker'
): HTMLElement {
const mark = document.createElement('mark')
const levelClass = highlight.level ? ` level-${highlight.level}` : ''
mark.className = `content-highlight-${highlightStyle}${levelClass}`
mark.setAttribute('data-highlight-id', highlight.id)
mark.setAttribute('data-highlight-level', highlight.level || 'nostrverse')
mark.setAttribute('title', `Highlighted ${new Date(highlight.created_at * 1000).toLocaleDateString()}`)
mark.textContent = matchText
return mark
}
/**
* Replace text node with mark element
*/
export function replaceTextWithMark(
textNode: Text,
before: string,
after: string,
mark: HTMLElement
): void {
const parent = textNode.parentNode
if (!parent) return
if (before) parent.insertBefore(document.createTextNode(before), textNode)
parent.insertBefore(mark, textNode)
if (after) {
textNode.textContent = after
} else {
parent.removeChild(textNode)
}
}
/**
* Try to find and mark text in text nodes
*/
export function tryMarkInTextNodes(
textNodes: Text[],
searchText: string,
highlight: Highlight,
useNormalized: boolean,
highlightStyle: 'marker' | 'underline' = 'marker'
): boolean {
const normalizedSearch = normalizeWhitespace(searchText)
for (const textNode of textNodes) {
const text = textNode.textContent || ''
const searchIn = useNormalized ? normalizeWhitespace(text) : text
const searchFor = useNormalized ? normalizedSearch : searchText
const index = searchIn.indexOf(searchFor)
if (index === -1) continue
let actualIndex = index
if (useNormalized) {
// Map normalized index back to original text
let normalizedIdx = 0
for (let i = 0; i < text.length && normalizedIdx < index; i++) {
if (!/\s/.test(text[i]) || (i > 0 && !/\s/.test(text[i-1]))) normalizedIdx++
actualIndex = i + 1
}
}
const before = text.substring(0, actualIndex)
const match = text.substring(actualIndex, actualIndex + searchText.length)
const after = text.substring(actualIndex + searchText.length)
const mark = createMarkElement(highlight, match, highlightStyle)
replaceTextWithMark(textNode, before, after, mark)
return true
}
return false
}

View File

@@ -0,0 +1,60 @@
import { Highlight } from '../../types/highlights'
import { tryMarkInTextNodes } from './domUtils'
/**
* Apply highlights to HTML content by injecting mark tags using DOM manipulation
*/
export function applyHighlightsToHTML(
html: string,
highlights: Highlight[],
highlightStyle: 'marker' | 'underline' = 'marker'
): string {
if (!html || highlights.length === 0) {
console.log('⚠️ applyHighlightsToHTML: No HTML or highlights', {
htmlLength: html?.length,
highlightsCount: highlights.length
})
return html
}
console.log('🔨 applyHighlightsToHTML: Processing', highlights.length, 'highlights')
const tempDiv = document.createElement('div')
tempDiv.innerHTML = html
let appliedCount = 0
for (const highlight of highlights) {
const searchText = highlight.content.trim()
if (!searchText) {
console.warn('⚠️ Empty highlight content:', highlight.id)
continue
}
console.log('🔍 Searching for highlight:', searchText.substring(0, 50) + '...')
// Collect all text nodes
const walker = document.createTreeWalker(tempDiv, NodeFilter.SHOW_TEXT, null)
const textNodes: Text[] = []
let node: Node | null
while ((node = walker.nextNode())) textNodes.push(node as Text)
console.log('📄 Found', textNodes.length, 'text nodes to search')
// Try exact match first, then normalized match
const found = tryMarkInTextNodes(textNodes, searchText, highlight, false, highlightStyle) ||
tryMarkInTextNodes(textNodes, searchText, highlight, true, highlightStyle)
if (found) {
appliedCount++
console.log('✅ Highlight applied successfully')
} else {
console.warn('❌ Could not find match for highlight:', searchText.substring(0, 50))
}
}
console.log('🎉 Applied', appliedCount, '/', highlights.length, 'highlights')
return tempDiv.innerHTML
}

View File

@@ -0,0 +1,46 @@
import { Highlight } from '../../types/highlights'
export interface HighlightMatch {
highlight: Highlight
startIndex: number
endIndex: number
}
/**
* Normalize whitespace for flexible matching
*/
export const normalizeWhitespace = (str: string) => str.replace(/\s+/g, ' ').trim()
/**
* Find all occurrences of highlight text in the content
*/
export function findHighlightMatches(
content: string,
highlights: Highlight[]
): HighlightMatch[] {
const matches: HighlightMatch[] = []
for (const highlight of highlights) {
if (!highlight.content || highlight.content.trim().length === 0) {
continue
}
const searchText = highlight.content.trim()
let startIndex = 0
let index = content.indexOf(searchText, startIndex)
while (index !== -1) {
matches.push({
highlight,
startIndex: index,
endIndex: index + searchText.length
})
startIndex = index + searchText.length
index = content.indexOf(searchText, startIndex)
}
}
return matches.sort((a, b) => a.startIndex - b.startIndex)
}