Compare commits
259 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd374cd705 | ||
|
|
20b4658bef | ||
|
|
0850ba250c | ||
|
|
b71d188fd8 | ||
|
|
579f6b9a96 | ||
|
|
d9403a73c6 | ||
|
|
747811fa94 | ||
|
|
489e480394 | ||
|
|
418bcb0295 | ||
|
|
88f01554e7 | ||
|
|
c85092a644 | ||
|
|
096478bcec | ||
|
|
b8de4a85e0 | ||
|
|
a5b7cedfaa | ||
|
|
0adb8d6766 | ||
|
|
6a6b8c4fad | ||
|
|
4f952816ea | ||
|
|
76835e2509 | ||
|
|
63af770c83 | ||
|
|
165c427e5f | ||
|
|
a0e30aa197 | ||
|
|
3a8203d26e | ||
|
|
ffe848883e | ||
|
|
078a13c45d | ||
|
|
8a69d5bc6b | ||
|
|
6783ff23f9 | ||
|
|
72a264a01e | ||
|
|
5a67be8096 | ||
|
|
9a929a6be4 | ||
|
|
e0ca010026 | ||
|
|
8bd5d7aadf | ||
|
|
9115c38cde | ||
|
|
0c7c1d54d9 | ||
|
|
d529d83eb8 | ||
|
|
a3127c7836 | ||
|
|
4d5fe1f425 | ||
|
|
c7a4de9786 | ||
|
|
d873718e88 | ||
|
|
706276839a | ||
|
|
d281ca5f87 | ||
|
|
6a9036bfef | ||
|
|
1b242f75c6 | ||
|
|
7ffd37289d | ||
|
|
cb859ae599 | ||
|
|
a17346c9c2 | ||
|
|
c17a39588d | ||
|
|
33cee9c0c2 | ||
|
|
e6d2920c27 | ||
|
|
d8195dbe2a | ||
|
|
4843f129c4 | ||
|
|
fcd1218dc4 | ||
|
|
eef0f971d7 | ||
|
|
ff09a8aba0 | ||
|
|
0c4b523d05 | ||
|
|
de7a435a01 | ||
|
|
124d399d1f | ||
|
|
e22cf71b15 | ||
|
|
670997ed36 | ||
|
|
1ccb6388e3 | ||
|
|
7d5be8d6aa | ||
|
|
133e4756b2 | ||
|
|
39ada734d5 | ||
|
|
19d88c5fba | ||
|
|
461b0936e2 | ||
|
|
e9ee5e87be | ||
|
|
5e66c5ef76 | ||
|
|
307dc3d726 | ||
|
|
e514a5f063 | ||
|
|
880b7974f4 | ||
|
|
47048f435f | ||
|
|
53ad492729 | ||
|
|
eb4da419ae | ||
|
|
c66dfc9e2e | ||
|
|
a31f05d498 | ||
|
|
6548e89c54 | ||
|
|
8a21b46ebd | ||
|
|
bc5fe1ae30 | ||
|
|
b57ea3f640 | ||
|
|
3b55d64468 | ||
|
|
4caf1f0b22 | ||
|
|
1eb9911645 | ||
|
|
38268c453c | ||
|
|
9686b80b09 | ||
|
|
f32dec16fb | ||
|
|
cb444b532f | ||
|
|
962062130a | ||
|
|
e429931139 | ||
|
|
e56d28f82a | ||
|
|
13a30d35c4 | ||
|
|
e3174d8777 | ||
|
|
829a8d5dca | ||
|
|
00978e2e64 | ||
|
|
a5fcf36e83 | ||
|
|
a92a9ee3a3 | ||
|
|
f39e34c699 | ||
|
|
b58f34d587 | ||
|
|
76d1d4544e | ||
|
|
5e56176e2d | ||
|
|
a2a4e7e454 | ||
|
|
b266288b0f | ||
|
|
1619e328da | ||
|
|
b852dad243 | ||
|
|
1552a5f106 | ||
|
|
0feaffb21b | ||
|
|
9b3a4e20de | ||
|
|
c83b972a68 | ||
|
|
2e96f93d81 | ||
|
|
1e8182d984 | ||
|
|
b20a67d4d0 | ||
|
|
60975b449d | ||
|
|
704fce4d80 | ||
|
|
4d1eb0f9fd | ||
|
|
ceafe277d3 | ||
|
|
8f2ecd5fe1 | ||
|
|
d6be6f364b | ||
|
|
035d4d3bd0 | ||
|
|
43d5554c0c | ||
|
|
724a3e5cfa | ||
|
|
0c49988d36 | ||
|
|
70de68848b | ||
|
|
8a12ae72cb | ||
|
|
f8d5d19a9f | ||
|
|
dbd20e676f | ||
|
|
bbdf47fb94 | ||
|
|
1b754e02dc | ||
|
|
a2e410252a | ||
|
|
c9a14d151d | ||
|
|
b286562e86 | ||
|
|
507288f51c | ||
|
|
e08bc54f15 | ||
|
|
4306069191 | ||
|
|
56e56af8ec | ||
|
|
4d65cd73a7 | ||
|
|
d36d5b33b6 | ||
|
|
4cd54834ce | ||
|
|
1134a41192 | ||
|
|
aced38b147 | ||
|
|
82f52f73cc | ||
|
|
4239f50129 | ||
|
|
4e3bb36ea5 | ||
|
|
0c58f4347b | ||
|
|
2dd0711a20 | ||
|
|
53b3dd1c7f | ||
|
|
47e2204c3f | ||
|
|
cc8b742731 | ||
|
|
529fc6b630 | ||
|
|
0c5c4b6c23 | ||
|
|
d7320c4bc8 | ||
|
|
98c107d387 | ||
|
|
ebe801ae92 | ||
|
|
d9730bb5f8 | ||
|
|
6a142f5163 | ||
|
|
2105dfe3f6 | ||
|
|
24c0889e9f | ||
|
|
db30c05aa0 | ||
|
|
4504377c36 | ||
|
|
3c1114ad21 | ||
|
|
e7c05b2c52 | ||
|
|
ca35e4e7cc | ||
|
|
2d5e48a64e | ||
|
|
be86634a65 | ||
|
|
a2041bd14d | ||
|
|
d294287c64 | ||
|
|
95162d4423 | ||
|
|
4224c989c6 | ||
|
|
3330f22f82 | ||
|
|
450776f9d0 | ||
|
|
0478713fd5 | ||
|
|
0f2b94cc61 | ||
|
|
b511d40375 | ||
|
|
d090b953bf | ||
|
|
19595d19ca | ||
|
|
239ebba439 | ||
|
|
67c6b75cb7 | ||
|
|
502dbd801a | ||
|
|
e114223e46 | ||
|
|
a9c73d35ef | ||
|
|
b8f20b73d1 | ||
|
|
dc8d687f0c | ||
|
|
3180fc7c73 | ||
|
|
a0cba9fb6f | ||
|
|
3483532944 | ||
|
|
db20e73ea3 | ||
|
|
b055294afc | ||
|
|
831cb18b66 | ||
|
|
bb51788a1d | ||
|
|
4cf2ac9172 | ||
|
|
bdab9c06e4 | ||
|
|
6636d540aa | ||
|
|
aa8332831f | ||
|
|
4ea03c9042 | ||
|
|
4720416f2c | ||
|
|
8ad9e652fb | ||
|
|
98c72389e2 | ||
|
|
e032f432dd | ||
|
|
852465bee7 | ||
|
|
39d0147cfa | ||
|
|
10cc7ce9b0 | ||
|
|
6b8442ebdd | ||
|
|
5aba283e92 | ||
|
|
59df232e2e | ||
|
|
702c001d46 | ||
|
|
48a9919db8 | ||
|
|
d6d0755b89 | ||
|
|
facdd36145 | ||
|
|
5d379a280b | ||
|
|
22a02d228d | ||
|
|
61fd5bbadc | ||
|
|
d642c87527 | ||
|
|
fea425b5d0 | ||
|
|
1609c6e580 | ||
|
|
270ea94c70 | ||
|
|
83e2f23357 | ||
|
|
9df0261071 | ||
|
|
1dfe66651a | ||
|
|
dcb7933ede | ||
|
|
aa72ac44c8 | ||
|
|
44fb07033b | ||
|
|
7e2d412869 | ||
|
|
19021af49a | ||
|
|
bdbb89c50e | ||
|
|
687f60db3f | ||
|
|
8ee7d347be | ||
|
|
8e9242e6f2 | ||
|
|
1df3962064 | ||
|
|
4edc22cec2 | ||
|
|
82977fa5d4 | ||
|
|
1a84817453 | ||
|
|
a0b98231b7 | ||
|
|
d452f96f79 | ||
|
|
dcf43cfce1 | ||
|
|
815b3cc57d | ||
|
|
7e54a01237 | ||
|
|
ec4692da15 | ||
|
|
f6d2f98eae | ||
|
|
9b97715274 | ||
|
|
fa1e536a26 | ||
|
|
238aac1921 | ||
|
|
29edd159e7 | ||
|
|
a3edb64e4c | ||
|
|
1609416e1e | ||
|
|
00d9fbdbab | ||
|
|
239ab5763d | ||
|
|
f4f0de3508 | ||
|
|
0c3e697df6 | ||
|
|
a9847a8848 | ||
|
|
5ef7d2c41c | ||
|
|
e1f704a690 | ||
|
|
59ecc29b9c | ||
|
|
9ae918f744 | ||
|
|
ac71d0b5a4 | ||
|
|
7c0d3b909b | ||
|
|
7b390aae66 | ||
|
|
70a830fb66 | ||
|
|
eee7628096 | ||
|
|
298e80cd49 | ||
|
|
5b7ad1d697 | ||
|
|
0b5bf59e98 | ||
|
|
a72a3e3f77 |
6
.cursor/rules/mobile-first-ui-ux.mdc
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description: anything related to UI/UX
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
This is a mobile-first application. All UI elements should be designed with that in mind. The application should work well on small screens, including older smartphones. The UX should be immaculate on mobile, even when in flight mode. (We use local caches and local relays, so that app works offline too.)
|
||||
20
.cursor/rules/zaps-and-zap-splits.mdc
Normal 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%
|
||||
4
.gitignore
vendored
@@ -7,3 +7,7 @@ dist
|
||||
# Misc
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
# Applesauce Reference
|
||||
applesauce
|
||||
|
||||
|
||||
697
CHANGELOG.md
Normal file
@@ -0,0 +1,697 @@
|
||||
# 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).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.4.3] - 2025-10-11
|
||||
|
||||
### Added
|
||||
- Mark as read functionality for articles (NIP-25)
|
||||
- Button at the end of each article to mark as read with 📚 emoji
|
||||
- Creates kind:7 reactions for nostr-native articles (`/a/` paths)
|
||||
- Creates kind:17 reactions for external websites (`/r/` paths)
|
||||
- Button shows loading state while publishing reaction
|
||||
- Only visible when user is logged in
|
||||
- Highlight deletion with confirmation dialog (NIP-09)
|
||||
- Small delete button (trash icon) on highlight items
|
||||
- Only visible for user's own highlights
|
||||
- Confirmation dialog prevents accidental deletions
|
||||
- Styled to match relay indicator (subtle, same size)
|
||||
- Removes highlights from UI immediately after deletion request
|
||||
- `/me` page showing user's recent highlights
|
||||
- Accessible by clicking profile picture in bookmark sidebar
|
||||
- Displays all highlights created by the logged-in user
|
||||
- Uses same rendering as Settings and Explore pages
|
||||
- Includes highlight count in header
|
||||
- Confirmation dialog component
|
||||
- Reusable modal with danger/warning/info variants
|
||||
- Backdrop blur effect
|
||||
- Mobile-responsive design
|
||||
- Prevents accidental destructive actions
|
||||
|
||||
### Changed
|
||||
- Relay status indicator on mobile now displays in compact mode
|
||||
- Shows only airplane icon by default (44x44px touch target)
|
||||
- Tap to expand for full connection details
|
||||
- Reduces screen clutter on mobile while keeping info accessible
|
||||
- Smooth transition between compact and expanded states
|
||||
- Desktop view remains unchanged (always shows full details)
|
||||
|
||||
## [0.4.2] - 2025-10-11
|
||||
|
||||
### Added
|
||||
- NIP-19 identifier resolution in article content (NIP-19, NIP-27)
|
||||
- Support for `nostr:npub1...`, `nostr:note1...`, `nostr:nprofile1...`, `nostr:nevent1...`, `nostr:naddr1...`
|
||||
- Converts nostr: URIs to clickable links with human-readable labels
|
||||
- Automatically fetches and displays article titles for `naddr` references
|
||||
- Falls back to identifier when title fetch fails
|
||||
- Auto-hide mobile UI buttons on scroll down
|
||||
- Floating bookmark/highlights buttons hide when scrolling down
|
||||
- Buttons reappear when scrolling up for distraction-free reading
|
||||
- Smooth opacity transitions for better UX
|
||||
- Scroll direction detection hook (`useScrollDirection`)
|
||||
- Supports both window and element-based scroll detection
|
||||
- Configurable threshold and enable/disable options
|
||||
|
||||
### Changed
|
||||
- Article references (`naddr`) now link internally to `/a/{naddr}` instead of external njump.me
|
||||
- Sidebar auto-closes on mobile when navigating to content via routes
|
||||
- Handles clicking on blog posts in Explore view
|
||||
- Complements existing sidebar auto-close for bookmarks
|
||||
- Markdown processing now async to support article title resolution
|
||||
- Article title resolution fetches titles in parallel for better performance
|
||||
|
||||
### Fixed
|
||||
- Mobile button scroll detection now correctly monitors main pane element
|
||||
- Previously monitored window scroll which didn't work on mobile
|
||||
- Content scrolls within `.pane.main` div on mobile devices
|
||||
- All ESLint warnings and TypeScript type errors resolved
|
||||
- Added react-hooks plugin to ESLint configuration
|
||||
- Fixed exhaustive-deps warnings in components
|
||||
- Added block scoping to switch case statements
|
||||
- Corrected type references for nostr-tools decode result
|
||||
|
||||
## [0.4.1] - 2025-10-10
|
||||
|
||||
### Fixed
|
||||
- Long article summaries overlapping with hero image content on mobile devices
|
||||
- Article summary now moves below hero image on mobile when longer than 150 characters
|
||||
- Article summary line clamp reduced from 3 to 2 lines on mobile for better space utilization
|
||||
|
||||
### Changed
|
||||
- Hero image rendering on mobile now uses zoom-to-fit approach with viewport-based sizing
|
||||
- Hero image height on mobile set to 50vh (constrained between 280px-400px)
|
||||
- Improved image cropping with center positioning for better visual presentation
|
||||
- Optimized reader header overlay padding and title sizing on mobile
|
||||
|
||||
## [0.4.0] - 2025-10-10
|
||||
|
||||
### Added
|
||||
- Mobile-responsive design with overlay sidebar drawer
|
||||
- Media query hooks for responsive behavior (`useIsMobile`, `useIsTablet`, `useIsCoarsePointer`)
|
||||
- Auto-collapse sidebar setting for mobile devices
|
||||
- Touch-optimized UI with 44x44px minimum touch targets
|
||||
- Safe area inset support for notched devices
|
||||
- Mobile hamburger menu and backdrop
|
||||
- Focus trap in mobile sidebar with ESC key support
|
||||
- Body scroll locking when mobile sidebar is open
|
||||
- Mobile-optimized modals (full-screen sheet style)
|
||||
- Mobile-optimized toast notifications (bottom position)
|
||||
- Dynamic viewport height support (100dvh)
|
||||
- Mobile highlights panel as overlay with toggle button
|
||||
|
||||
### Changed
|
||||
- Sidebar now displays as overlay drawer on mobile (≤768px)
|
||||
- Highlights panel hidden on mobile for better content focus
|
||||
- Sidebar auto-closes when selecting content on mobile
|
||||
- Hover effects disabled on touch devices
|
||||
- Replace hamburger icon with bookmark icon on mobile
|
||||
|
||||
### Fixed
|
||||
- Ensure bookmarks container fills mobile sidepane properly
|
||||
- Restore desktop grid layout for highlights panel
|
||||
- Improve empty state and loading visibility in mobile sidepanes
|
||||
- Add flex properties to mobile bookmark containers for proper filling
|
||||
- Force bookmarks pane expanded on mobile and ensure highlights pane sits above content on desktop
|
||||
- Reduce mobile backdrop opacity and ensure sidepanes appear above it
|
||||
- Replace any type with proper bookmark interface for linter compliance
|
||||
|
||||
## [0.3.8] - 2025-10-10
|
||||
|
||||
### Fixed
|
||||
- Add vercel.json configuration to properly handle SPA routing on Vercel deployments (fixes 404 errors on page refresh)
|
||||
|
||||
## [0.3.7] - 2025-10-10
|
||||
|
||||
### Fixed
|
||||
- Logout button functionality - now properly clears active account using clearActive() method
|
||||
|
||||
## [0.3.6] - 2025-10-10
|
||||
|
||||
### Added
|
||||
- Compact date format for highlights (now, 5m, 3h, 2d, 1mo, 1y)
|
||||
- Ultra-compact date format for bookmarks sidebar
|
||||
- Encode event links as nevent/naddr per NIP-19 for better client compatibility
|
||||
- Render /explore within ThreePaneLayout to keep side panels visible
|
||||
|
||||
### Fixed
|
||||
- Remove incorrect padding-right from highlights container
|
||||
- Reduce font size of highlight metadata for cleaner look
|
||||
- Position highlight FAB button relative to article pane instead of viewport
|
||||
- Adjust relay indicator position for better visual alignment
|
||||
- Ensure highlight metadata elements align on single visual line with consistent line-height
|
||||
- Prevent bookmark icons from being cut off in compact view
|
||||
- Clean up nested borders in bookmark items and sidebar view mode controls
|
||||
- Align highlight metadata elements on single line in sidebar
|
||||
- Change explore header icon from compass to newspaper
|
||||
|
||||
### Changed
|
||||
- Make connecting notification more subtle with muted blue background
|
||||
- Update Boris pubkey for zap splits to npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x
|
||||
- Update domain references to read.withboris.com (URLs, SEO metadata, and documentation)
|
||||
|
||||
## [0.3.5] - 2025-10-09
|
||||
|
||||
### Fixed
|
||||
- Ensure connecting state shows for minimum 15 seconds to prevent premature offline display
|
||||
- Add Cloudflare Pages routing config for SPA paths
|
||||
|
||||
### Changed
|
||||
- Extend connecting state duration and remove subtitle text for cleaner UI
|
||||
|
||||
## [0.3.4] - 2025-10-09
|
||||
|
||||
### Fixed
|
||||
- Add p tag (author tag) to highlights of nostr-native content for proper attribution
|
||||
|
||||
## [0.3.3] - 2025-10-09
|
||||
|
||||
### Added
|
||||
- Service Worker for robust offline image caching
|
||||
- /explore route to discover blog posts from friends on Nostr
|
||||
- Explore button (newspaper icon) in bookmarks header
|
||||
- "Connecting" status indicator on page load (instead of immediately showing "Offline")
|
||||
- Last fetch time display with relative timestamps in bookmarks list
|
||||
|
||||
### Changed
|
||||
- Simplify image caching to use Service Worker transparently
|
||||
- Move refresh button from top bar to end of bookmarks list
|
||||
- Make explore page article cards proper links (supports CMD+click to open in new tab)
|
||||
- Reorganize bookmarks UI for better UX
|
||||
|
||||
### Fixed
|
||||
- Improve image cache resilience for offline viewing and hard reloads
|
||||
- Correct TypeScript types for cache stats state
|
||||
- Resolve linter errors for unused parameters
|
||||
- Import useEventModel from applesauce-react/hooks for proper type safety
|
||||
- Import Models from applesauce-core instead of applesauce-react
|
||||
- Use correct useEventModel hook for profile loading in BlogPostCard
|
||||
|
||||
## [0.3.0] - 2025-10-09
|
||||
|
||||
### Added
|
||||
- Flight Mode with offline highlight creation and local relay support
|
||||
- Automatic offline sync - rebroadcast local events when back online
|
||||
- Relay indicator icon on highlight items showing sync status
|
||||
- Click-to-rebroadcast functionality for highlights
|
||||
- Flight mode indicator (plane icon) on offline-created highlights
|
||||
- Relay rebroadcast settings for caching and propagation
|
||||
- Local relay status indicator for local-only/offline mode
|
||||
- Second local relay support (localhost:4869)
|
||||
- Relay connection status tracking and display
|
||||
- 6th font size option for better UI scaling
|
||||
|
||||
### Fixed
|
||||
- Highlight creation resilient to offline/flight mode
|
||||
- TypeScript type errors in offline sync
|
||||
- Relay indicator tooltip accuracy and reliability
|
||||
- Always show relay indicator icon on highlights
|
||||
- Show remote relay list for fetched highlights
|
||||
- Publish highlights to all connected relays instead of just one
|
||||
- Keep all relay connections alive, not just local ones
|
||||
- Check actual relay connection status instead of pool membership
|
||||
- Skip rebroadcasting when in flight mode
|
||||
- Update relay info after automatic sync completes
|
||||
- Only show successfully reachable relays in flight mode
|
||||
- Include local relays in relay indicator tooltip
|
||||
|
||||
### Changed
|
||||
- Rename 'Offline Mode' to 'Flight Mode' throughout UI
|
||||
- Move publication date to top-right corner with subtle border styling
|
||||
- Consolidate relay/status indicators into single unified icon
|
||||
- Simplify relay indicator tooltip to show relay list
|
||||
- Move rebroadcast settings to dedicated Flight Mode section
|
||||
- Place Reading Font and Font Size settings side-by-side
|
||||
- Improve font size scale and default value
|
||||
- Use wifi icon for disconnected remote relays
|
||||
- Use airplane icons for local relay indicators
|
||||
- Make Relays heading same level as Flight Mode in settings
|
||||
- Simplify rebroadcast settings UI with consistent checkbox style
|
||||
|
||||
### Performance
|
||||
- Make highlight creation instant with non-blocking relay publish
|
||||
- Reduce relay status polling interval to 20 seconds
|
||||
- Show sync progress and hide indicator after successful sync
|
||||
|
||||
## [0.2.10] - 2025-10-09
|
||||
|
||||
### Added
|
||||
- URL-based settings navigation with /settings route
|
||||
- Active zap split preset highlighting
|
||||
- Educational links about relays in reader view
|
||||
- Article publication date display in reader
|
||||
- Local relay recommendations in settings
|
||||
- Relays section showing active and recently connected relays
|
||||
|
||||
### Fixed
|
||||
- Remove trailing slash from relay URLs
|
||||
- Constrain Reading Font dropdown width
|
||||
|
||||
### Changed
|
||||
- Rename 'Default View Mode' to 'Default Bookmark View' in settings
|
||||
- Reorganize settings layout for better UX
|
||||
- Use sidebar-style colored buttons for highlight visibility
|
||||
- Simplify Relays section presentation
|
||||
|
||||
## [0.2.9] - 2025-10-09
|
||||
|
||||
### Fixed
|
||||
- Deduplicate highlights in streaming callbacks
|
||||
|
||||
## [0.2.8] - 2025-10-09
|
||||
|
||||
### Added
|
||||
- Display article summary in header
|
||||
- Overlay title and metadata on hero images
|
||||
- Apply reading font to article titles
|
||||
|
||||
### Fixed
|
||||
- Pass article summary through to ReadableContent
|
||||
- Correct Jina AI Reader proxy URL format
|
||||
|
||||
### Changed
|
||||
- Update homepage URL to read.withboris.com
|
||||
- Reorder toolbar buttons for better UX
|
||||
|
||||
## [0.2.7] - 2025-10-08
|
||||
|
||||
### Added
|
||||
- Web bookmark creation (NIP-B0, kind:39701)
|
||||
- Tags support for web bookmarks per NIP-B0
|
||||
- Auto-fetch title and description when URL is pasted
|
||||
- Prioritize OpenGraph tags for metadata extraction
|
||||
- Auto-extract tags from metadata with boris as default tag
|
||||
- Zap split preset buttons
|
||||
- Boris support percentage to zap splits
|
||||
- Respect existing zap tags in source content when creating highlights
|
||||
|
||||
### Fixed
|
||||
- Revert to fetchReadableContent to avoid CORS issues
|
||||
- Improve modal spacing with proper box-sizing
|
||||
- Prevent sliders from jumping when resetting settings
|
||||
- Pass relayPool as prop instead of using non-existent hook
|
||||
- Correct type signature for addZapTags function
|
||||
|
||||
### Changed
|
||||
- Reorder toolbar buttons for better UX
|
||||
- DRY up tag extraction with normalizeTags helper
|
||||
- Use url-metadata package for robust metadata extraction
|
||||
- Make zap split sliders independent using weights
|
||||
- Move zap splits to dedicated settings section
|
||||
- Publish bookmarks to relays in background for better performance
|
||||
|
||||
## [0.2.6] - 2025-10-08
|
||||
|
||||
### Added
|
||||
- Home button to bookmark bar
|
||||
- Configurable zap split for highlights on nostr-native content
|
||||
|
||||
## [0.2.5] - 2025-10-07
|
||||
|
||||
### Fixed
|
||||
- Wire preview ref to markdown conversion hook
|
||||
- Add missing useEffect dependencies for article loading
|
||||
|
||||
### Changed
|
||||
- DRY up highlight classification and URL normalization
|
||||
- Split highlighting utilities into modules
|
||||
- Extract highlights panel components
|
||||
- Extract content rendering hooks
|
||||
- Split Settings into section components
|
||||
- Extract event processing utilities
|
||||
- Split Bookmarks.tsx into smaller hooks and components
|
||||
|
||||
## [0.2.4] - 2025-10-07
|
||||
|
||||
### Added
|
||||
- Domain configuration for https://xn--bris-v0b.com/
|
||||
- Public assets and deployment configuration
|
||||
- Hide bookmarks without content or URL
|
||||
|
||||
### Fixed
|
||||
- Encode/decode URLs in /r/ route to preserve special characters
|
||||
|
||||
### Changed
|
||||
- Cleanup after build fixes (remove shims, update locks)
|
||||
- Stop tracking node_modules/dist
|
||||
- Update dependencies and dedupe
|
||||
- Add .gitignore for node_modules and dist
|
||||
|
||||
## [0.2.3] - 2025-10-07
|
||||
|
||||
### Added
|
||||
- Parse and display summary tag for nostr articles
|
||||
- Merge and flatten bookmarks from multiple lists
|
||||
- Update URL path when opening bookmarks from sidebar
|
||||
|
||||
### Fixed
|
||||
- Ensure bookmarks are sorted newest first after merging lists
|
||||
- Hide empty bookmarks without content
|
||||
- Remove encrypted cyphertext display from bookmark list
|
||||
|
||||
### Changed
|
||||
- Remove created date from bookmark list display
|
||||
|
||||
## [0.2.2] - 2025-10-06
|
||||
|
||||
### Added
|
||||
- Support for web bookmarks (NIP-B0, kind:39701)
|
||||
- Default highlight visibility settings
|
||||
- Proxy.nostr-relay.app relay to configuration
|
||||
- Comprehensive logging to settings service
|
||||
|
||||
### Fixed
|
||||
- Handle web bookmarks with URLs in d tag and prevent crash
|
||||
- Load settings from local cache first to eliminate FOUT
|
||||
- Ensure fonts are fully loaded before applying styles
|
||||
- Improve highlight rendering pipeline with comprehensive debugging
|
||||
|
||||
### Changed
|
||||
- Use icon toggle buttons for highlight visibility settings
|
||||
- Change nostrverse icon from fa-globe to fa-network-wired
|
||||
|
||||
## [0.2.1] - 2025-10-05
|
||||
|
||||
### Added
|
||||
- Local relay support and centralize relay configuration
|
||||
- Optimistic updates for highlight creation
|
||||
- Enable highlight creation from external URLs
|
||||
- Add routing support for external URLs
|
||||
- Add context to highlights (previous and next sentences)
|
||||
- Boris branding to highlight alt tag
|
||||
|
||||
### Fixed
|
||||
- Properly await account loading from localStorage on refresh
|
||||
- Add protected routes to prevent logout on page refresh
|
||||
- Use undo icon for reset to defaults button
|
||||
- Update local relay port to 10547
|
||||
|
||||
### Changed
|
||||
- Remove dedicated login page, handle login through main UI
|
||||
- Simplify to single RELAYS constant (DRY)
|
||||
|
||||
## [0.2.0] - 2025-10-05
|
||||
|
||||
### Added
|
||||
- Simple highlight creation feature (FAB style)
|
||||
- Reset to defaults button in settings
|
||||
- Load and apply settings upon login
|
||||
|
||||
### Fixed
|
||||
- Replace any types with proper NostrEvent types
|
||||
- Move FAB to Bookmarks component for proper floating
|
||||
- Highlight button positioning with scroll
|
||||
|
||||
### Changed
|
||||
- Update color palette to include default friends/nostrverse colors
|
||||
- Show author name in highlight cards
|
||||
- Sync highlight level toggles between sidebar and main article text
|
||||
- Rename 'underlines' to 'highlights' throughout codebase
|
||||
|
||||
## [0.1.11] - 2025-10-05
|
||||
|
||||
### Added
|
||||
- Stream highlights progressively as they arrive from relays
|
||||
|
||||
### Fixed
|
||||
- Display article immediately without waiting for highlights to load
|
||||
- Show highlights immediately when opening panel if already loaded
|
||||
- Prevent bookmark text from being cut off in compact view
|
||||
- Correct default highlight color for 'mine' to yellow (#ffff00)
|
||||
|
||||
### Changed
|
||||
- Reduce padding between bookmark items and panel edge
|
||||
- Update default highlight colors to orange for friends and purple for nostrverse
|
||||
|
||||
## [0.1.10] - 2025-10-05
|
||||
|
||||
### Added
|
||||
- Three-level highlight system (mine/friends/nostrverse)
|
||||
|
||||
### Fixed
|
||||
- Ensure highlights always render on markdown content
|
||||
- Classify highlights before passing to ContentPanel
|
||||
- Position toggle buttons directly adjacent to main panel
|
||||
- Remove redundant setReaderLoading call in error handler
|
||||
|
||||
### Changed
|
||||
- Always show friends and user highlight buttons
|
||||
- Remove Highlights title and count from panel
|
||||
|
||||
## [0.1.9] - 2025-10-05
|
||||
|
||||
### Fixed
|
||||
- Show markdown content immediately when finalHtml is empty
|
||||
- Prevent highlight bleeding into sidebar
|
||||
|
||||
## [0.1.8] - 2025-10-05
|
||||
|
||||
### Fixed
|
||||
- Prevent 'No readable content' flash for markdown articles
|
||||
- Enable highlights display and scroll-to for markdown content
|
||||
|
||||
### Added
|
||||
- Persist accounts to localStorage
|
||||
|
||||
### Changed
|
||||
- Simplify login by handling it directly in sidebar
|
||||
|
||||
## [0.1.7] - 2025-10-05
|
||||
|
||||
### Added
|
||||
- Show highlights in article content and add mode toggle
|
||||
|
||||
### Fixed
|
||||
- Show highlights for nostr articles by skipping URL filter
|
||||
- Refresh button now works without login for article highlights
|
||||
- Query highlights using both a-tag and e-tag
|
||||
|
||||
### Changed
|
||||
- Keep Bookmarks.tsx under 210 lines by extracting logic
|
||||
|
||||
## [0.1.6] - 2025-10-03
|
||||
|
||||
### Added
|
||||
- Native support for rendering Nostr long-form articles (NIP-23)
|
||||
- Display article titles for kind:30023 bookmarks
|
||||
- Enable clicking on kind:30023 articles to open in reader
|
||||
- Display article hero images in bookmark views and reader
|
||||
- Configurable highlight colors
|
||||
- Highlight style setting (marker & underline)
|
||||
|
||||
### Fixed
|
||||
- Use bookmark pubkey for article author instead of tag lookup
|
||||
- Ensure highlight color CSS variable inherits from parent
|
||||
|
||||
### Changed
|
||||
- Integrate long-form article rendering into existing reader view
|
||||
- Extract components to keep files under 210 lines
|
||||
- Make font size and color buttons match icon button size (33px)
|
||||
|
||||
## [0.1.5] - 2025-10-03
|
||||
|
||||
### Added
|
||||
- Settings panel with NIP-78 storage
|
||||
- Auto-save for settings with toast notifications
|
||||
- Reading time estimate to articles
|
||||
- Font size setting
|
||||
- Configurable reading font using Bunny Fonts
|
||||
- Live preview of reading font in settings
|
||||
- Settings subscription to watch for Nostr updates
|
||||
|
||||
### Fixed
|
||||
- Prevent settings from saving unnecessarily
|
||||
- Prevent save settings button from being cut off
|
||||
- Replace custom reading time with reading-time-estimator package
|
||||
- Update originalHtmlRef when content changes
|
||||
|
||||
### Changed
|
||||
- Reduce file sizes to meet 210 line limit
|
||||
- Extract settings logic into custom hook
|
||||
- Consolidate settings initialization on login
|
||||
- Remove debounce from settings auto-save
|
||||
|
||||
## [0.1.4] - 2025-10-03
|
||||
|
||||
### Added
|
||||
- Inline highlight annotations in content panel
|
||||
- NIP-84 highlights panel with three-pane layout
|
||||
- Toggle button to show/hide highlight underlines
|
||||
- Click-to-scroll for highlights
|
||||
- Pulsing animation when scrolling to highlight
|
||||
|
||||
### Fixed
|
||||
- Apply highlights to markdown content as well as HTML
|
||||
- Use requestAnimationFrame for highlight DOM manipulation
|
||||
- Improve HTML highlight matching with DOM manipulation
|
||||
- Filter highlights panel to show only current article
|
||||
|
||||
### Changed
|
||||
- Use applesauce helpers for highlight parsing
|
||||
- DRY up highlightMatching to stay under 210 lines
|
||||
- Change highlights to fluorescent marker style
|
||||
- Deduplicate highlight events by ID
|
||||
|
||||
## [0.1.3] - 2025-10-03
|
||||
|
||||
### Added
|
||||
- View mode switching for bookmarks with compact list view
|
||||
- Large preview view mode
|
||||
- Image preview for large view cards
|
||||
- Hero images using free CORS proxy
|
||||
|
||||
### Changed
|
||||
- Make entire compact list row clickable to open reader
|
||||
- Make card view timestamp clickable to open event
|
||||
- Enhance card view design with modern styling
|
||||
|
||||
## [0.1.2] - 2025-10-03
|
||||
|
||||
### Added
|
||||
- Open bookmark URLs in reader instead of new window
|
||||
- localStorage caching for fetched articles
|
||||
- Collapsible bookmarks sidebar
|
||||
|
||||
### Fixed
|
||||
- Make sidebar and reader scroll independently
|
||||
- Replace relative-time with date-fns for timestamp formatting
|
||||
|
||||
### Changed
|
||||
- Display timestamps as relative time
|
||||
- Replace user text with profile image in sidebar header
|
||||
- Move user info and logout to sidebar header bar
|
||||
- Reduce IconButton size by 25%
|
||||
|
||||
## [0.1.1] - 2025-10-03
|
||||
|
||||
### Added
|
||||
- Classify URLs by type and adjust action buttons
|
||||
- Collapse/expand functionality for bookmarks sidebar
|
||||
- IconButton component with square styling
|
||||
- Resolve nprofile/npub mentions to names in content
|
||||
|
||||
### Fixed
|
||||
- Enforce 210-char truncation for both plain and parsed content
|
||||
- Use Rules of Hooks correctly
|
||||
|
||||
### Changed
|
||||
- Use IconButton for all icon-only actions to enforce square sizing
|
||||
- Sort bookmarks by added_at (recently added first)
|
||||
- Make kind icon square to match IconButton sizing
|
||||
- Remove colored borders and gradients; keep neutral cards
|
||||
|
||||
## [0.1.0] - 2025-10-03
|
||||
|
||||
### Added
|
||||
- Two-pane layout and content fetching pipeline
|
||||
- ContentPanel component to render readable HTML
|
||||
- Lightweight readability fetcher via r.jina.ai proxy
|
||||
- Markdown rendering support with react-markdown and remark-gfm
|
||||
- READ NOW button to bookmark cards
|
||||
- Spinner to content loading state
|
||||
- FontAwesome icons for event kinds
|
||||
|
||||
### Fixed
|
||||
- Show bookmarked event author instead of list signer
|
||||
- Enable reactive profile fetch via address loader
|
||||
- Left-align content and constrain images in content panel
|
||||
|
||||
### Changed
|
||||
- Resolve author names using applesauce ProfileModel
|
||||
- Propagate URL selection through BookmarkList to parent
|
||||
- Display URLs clearly in individual bookmarks
|
||||
|
||||
## [0.0.3] - 2025-10-02
|
||||
|
||||
### Added
|
||||
- Manual decryption for unrecognized event kinds
|
||||
- Try NIP-44 then NIP-04 for manual decryption
|
||||
- Detailed debugging for decryption process
|
||||
- Support for hidden bookmarks decryption
|
||||
|
||||
### Fixed
|
||||
- Surface manually decrypted hidden tags in UI
|
||||
- Dedupe individual bookmarks by id
|
||||
|
||||
### Changed
|
||||
- Sort individual bookmarks by timestamp (newest first)
|
||||
- Increase bookmark loading timeout by 2x
|
||||
- Extract helpers and event processing
|
||||
|
||||
## [0.0.2] - 2025-10-02
|
||||
|
||||
### Added
|
||||
- Fetch all NIP-51 events
|
||||
- Unlock private bookmarks via applesauce helpers
|
||||
- Copy-to-clipboard icons for event id and author pubkey
|
||||
- FontAwesome globe/lock icons
|
||||
- Display content identically for private/public bookmarks
|
||||
|
||||
### Fixed
|
||||
- Properly configure browser extension signer
|
||||
- Aggregate list(10003) + set(30001)
|
||||
- Handle applesauce bookmark structure correctly
|
||||
- Resolve loading state stuck issue
|
||||
|
||||
### Changed
|
||||
- Change bookmarks display from grid to social feed list layout
|
||||
- Simplify bookmark service using applesauce helpers
|
||||
- Extract components and utilities to keep files under 210 lines
|
||||
|
||||
## [0.0.1] - 2025-10-02
|
||||
|
||||
### Added
|
||||
- Initial release
|
||||
- Browser extension login support
|
||||
- NIP-51 bookmark fetching from nostr relays
|
||||
- User profile display
|
||||
- Relay pool configuration
|
||||
- Basic UI with profile resolution
|
||||
|
||||
### Changed
|
||||
- Migrate to applesauce-accounts for proper account management
|
||||
- Use proper applesauce-loaders for NIP-51 bookmark fetching
|
||||
- Optimize relay usage following applesauce-relay best practices
|
||||
- Use applesauce-react event models for better profile handling
|
||||
|
||||
[0.4.0]: https://github.com/dergigi/boris/compare/v0.3.8...v0.4.0
|
||||
[0.3.8]: https://github.com/dergigi/boris/compare/v0.3.7...v0.3.8
|
||||
[0.3.7]: https://github.com/dergigi/boris/compare/v0.3.6...v0.3.7
|
||||
[0.3.6]: https://github.com/dergigi/boris/compare/v0.3.5...v0.3.6
|
||||
[0.3.5]: https://github.com/dergigi/boris/compare/v0.3.4...v0.3.5
|
||||
[0.3.4]: https://github.com/dergigi/boris/compare/v0.3.3...v0.3.4
|
||||
[0.3.3]: https://github.com/dergigi/boris/compare/v0.3.2...v0.3.3
|
||||
[0.3.2]: https://github.com/dergigi/boris/compare/v0.3.1...v0.3.2
|
||||
[0.3.1]: https://github.com/dergigi/boris/compare/v0.3.0...v0.3.1
|
||||
[0.3.0]: https://github.com/dergigi/boris/compare/v0.2.10...v0.3.0
|
||||
[0.2.10]: https://github.com/dergigi/boris/compare/v0.2.9...v0.2.10
|
||||
[0.2.9]: https://github.com/dergigi/boris/compare/v0.2.8...v0.2.9
|
||||
[0.2.8]: https://github.com/dergigi/boris/compare/v0.2.7...v0.2.8
|
||||
[0.2.7]: https://github.com/dergigi/boris/compare/v0.2.6...v0.2.7
|
||||
[0.2.6]: https://github.com/dergigi/boris/compare/v0.2.5...v0.2.6
|
||||
[0.2.5]: https://github.com/dergigi/boris/compare/v0.2.4...v0.2.5
|
||||
[0.2.4]: https://github.com/dergigi/boris/compare/v0.2.3...v0.2.4
|
||||
[0.2.3]: https://github.com/dergigi/boris/compare/v0.2.2...v0.2.3
|
||||
[0.2.2]: https://github.com/dergigi/boris/compare/v0.2.1...v0.2.2
|
||||
[0.2.1]: https://github.com/dergigi/boris/compare/v0.2.0...v0.2.1
|
||||
[0.2.0]: https://github.com/dergigi/boris/compare/v0.1.11...v0.2.0
|
||||
[0.1.11]: https://github.com/dergigi/boris/compare/v0.1.10...v0.1.11
|
||||
[0.1.10]: https://github.com/dergigi/boris/compare/v0.1.9...v0.1.10
|
||||
[0.1.9]: https://github.com/dergigi/boris/compare/v0.1.8...v0.1.9
|
||||
[0.1.8]: https://github.com/dergigi/boris/compare/v0.1.7...v0.1.8
|
||||
[0.1.7]: https://github.com/dergigi/boris/compare/v0.1.6...v0.1.7
|
||||
[0.1.6]: https://github.com/dergigi/boris/compare/v0.1.5...v0.1.6
|
||||
[0.1.5]: https://github.com/dergigi/boris/compare/v0.1.4...v0.1.5
|
||||
[0.1.4]: https://github.com/dergigi/boris/compare/v0.1.3...v0.1.4
|
||||
[0.1.3]: https://github.com/dergigi/boris/compare/v0.1.2...v0.1.3
|
||||
[0.1.2]: https://github.com/dergigi/boris/compare/v0.1.1...v0.1.2
|
||||
[0.1.1]: https://github.com/dergigi/boris/compare/v0.1.0...v0.1.1
|
||||
[0.1.0]: https://github.com/dergigi/boris/compare/v0.0.3...v0.1.0
|
||||
[0.0.3]: https://github.com/dergigi/boris/compare/v0.0.2...v0.0.3
|
||||
[0.0.2]: https://github.com/dergigi/boris/compare/v0.0.1...v0.0.2
|
||||
[0.0.1]: https://github.com/dergigi/boris/releases/tag/v0.0.1
|
||||
|
||||
156
MOBILE_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# Mobile Implementation Summary
|
||||
|
||||
## Overview
|
||||
Boris is now mobile-friendly! The app now works seamlessly on mobile devices with a responsive design that includes:
|
||||
- Auto-collapsing sidebar that opens as an overlay drawer on small screens
|
||||
- Touch-optimized UI with proper touch target sizes (44x44px minimum)
|
||||
- Safe area insets for notched devices (iPhone X+, etc.)
|
||||
- Focus trap and keyboard navigation in the mobile sidebar
|
||||
- Mobile-optimized modals, toasts, and other UI elements
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Viewport & Base Setup
|
||||
**File: `index.html`**
|
||||
- Updated viewport meta tag to include `viewport-fit=cover` for proper safe area handling
|
||||
|
||||
### 2. Media Query Hooks
|
||||
**File: `src/hooks/useMediaQuery.ts` (NEW)**
|
||||
- `useMediaQuery(query)` - Generic hook for any media query
|
||||
- `useIsMobile()` - Detects mobile viewport (≤768px)
|
||||
- `useIsTablet()` - Detects tablet viewport (≤1024px)
|
||||
- `useIsCoarsePointer()` - Detects touch devices
|
||||
|
||||
### 3. Mobile CSS Styles
|
||||
**File: `src/index.css`**
|
||||
- Added CSS custom properties for mobile breakpoints and safe areas
|
||||
- Mobile-specific three-pane layout that stacks into single column
|
||||
- Overlay sidebar with backdrop and transitions
|
||||
- Touch target improvements (44x44px minimum)
|
||||
- Disabled hover effects on touch devices
|
||||
- Mobile-optimized modals (full-screen sheet style)
|
||||
- Mobile-optimized toasts (bottom position with safe area)
|
||||
- Dynamic viewport height support (`100dvh`)
|
||||
- Overscroll behavior and body scroll locking
|
||||
|
||||
### 4. Sidebar State Management
|
||||
**File: `src/hooks/useBookmarksUI.ts`**
|
||||
- Added `isMobile` state from media query
|
||||
- Added `isSidebarOpen` state for mobile overlay
|
||||
- Added `toggleSidebar()` function
|
||||
- Auto-collapse logic based on `autoCollapseSidebarOnMobile` setting
|
||||
- Mobile sidebar defaults to closed, desktop defaults to open
|
||||
|
||||
### 5. Three-Pane Layout Mobile Support
|
||||
**File: `src/components/ThreePaneLayout.tsx`**
|
||||
- Mobile hamburger button (visible only on mobile)
|
||||
- Mobile backdrop for closing sidebar
|
||||
- Body scroll locking when sidebar is open
|
||||
- ESC key handler to close sidebar
|
||||
- Focus trap in sidebar (Tab navigation stays within sidebar)
|
||||
- Focus restoration when closing sidebar
|
||||
- Accessibility attributes (`aria-hidden`, `aria-expanded`, etc.)
|
||||
|
||||
### 6. Sidebar Header Mobile Controls
|
||||
**File: `src/components/SidebarHeader.tsx`**
|
||||
- Close button (X) visible on mobile instead of collapse chevron
|
||||
- Hamburger button hidden in header (shown in layout instead)
|
||||
|
||||
### 7. Bookmark List Mobile Props
|
||||
**File: `src/components/BookmarkList.tsx`**
|
||||
- Added `isMobile` prop support
|
||||
- Passes mobile state to SidebarHeader
|
||||
|
||||
### 8. Main Bookmarks Component
|
||||
**File: `src/components/Bookmarks.tsx`**
|
||||
- Uses mobile state from `useBookmarksUI`
|
||||
- Auto-closes sidebar when selecting bookmark on mobile
|
||||
- Closes sidebar when opening settings on mobile
|
||||
- Proper desktop/mobile toggle behavior
|
||||
|
||||
### 9. Icon Button Enhancement
|
||||
**File: `src/components/IconButton.tsx`**
|
||||
- Added optional `className` prop for additional styling
|
||||
|
||||
### 10. Mobile Settings
|
||||
**File: `src/services/settingsService.ts`**
|
||||
- Added `autoCollapseSidebarOnMobile?: boolean` setting (default: true)
|
||||
|
||||
**File: `src/components/Settings/StartupPreferencesSettings.tsx`**
|
||||
- Added UI toggle for "Auto-collapse sidebar on small screens"
|
||||
|
||||
## Accessibility Features
|
||||
- Focus trap in mobile sidebar (Tab key navigation stays within drawer)
|
||||
- ESC key closes mobile sidebar
|
||||
- Backdrop click closes mobile sidebar
|
||||
- Proper ARIA attributes (`aria-hidden`, `aria-expanded`, `aria-controls`)
|
||||
- Touch target minimum size enforcement (44x44px)
|
||||
- Focus restoration when closing sidebar
|
||||
|
||||
## Mobile Behaviors
|
||||
1. **Sidebar**: Slides in from left as overlay drawer with backdrop
|
||||
2. **Hamburger Menu**: Fixed position top-left when sidebar closed
|
||||
3. **Selecting Content**: Auto-closes sidebar on mobile
|
||||
4. **Opening Settings**: Auto-closes sidebar on mobile
|
||||
5. **Highlights Panel**: Hidden on mobile (content takes full width)
|
||||
6. **Modals**: Full-screen sheet style from bottom
|
||||
7. **Toasts**: Bottom position with safe area padding
|
||||
|
||||
## Responsive Breakpoints
|
||||
- **Mobile**: ≤768px (sidebar overlay, single column)
|
||||
- **Tablet**: ≤1024px (defined but not actively used yet)
|
||||
- **Desktop**: >768px (three-pane layout as before)
|
||||
|
||||
## Browser Support
|
||||
- Modern browsers with CSS Grid support
|
||||
- iOS Safari (including safe area insets)
|
||||
- Chrome for Android
|
||||
- Firefox Mobile
|
||||
- Safari on iPadOS
|
||||
|
||||
## Safe Area Support
|
||||
The app respects device safe areas (notches, home indicators) through CSS environment variables:
|
||||
- `env(safe-area-inset-top)`
|
||||
- `env(safe-area-inset-bottom)`
|
||||
- `env(safe-area-inset-left)`
|
||||
- `env(safe-area-inset-right)`
|
||||
|
||||
## Future Enhancements
|
||||
Potential improvements for future iterations:
|
||||
- Swipe gesture to open/close sidebar
|
||||
- Pull-to-refresh on mobile
|
||||
- Bottom sheet for highlights panel on mobile
|
||||
- Optimized font sizes for mobile reading
|
||||
- Mobile-specific view mode (perhaps auto-switch to compact on mobile)
|
||||
- Haptic feedback on interactions (iOS/Android)
|
||||
- Share sheet integration
|
||||
- Install prompt for PWA
|
||||
|
||||
## Testing Checklist
|
||||
- [x] Sidebar opens/closes on mobile
|
||||
- [x] Hamburger button visible on mobile
|
||||
- [x] Backdrop closes sidebar
|
||||
- [x] ESC key closes sidebar
|
||||
- [x] Focus trap works in sidebar
|
||||
- [x] Selecting bookmark closes sidebar
|
||||
- [x] No horizontal scroll
|
||||
- [x] Touch targets ≥ 44px
|
||||
- [x] Modals are full-screen on mobile
|
||||
- [x] Toasts appear at bottom with safe area
|
||||
- [x] Build completes without errors
|
||||
- [ ] Test on actual iOS device (iPhone)
|
||||
- [ ] Test on actual Android device
|
||||
- [ ] Test with keyboard navigation
|
||||
- [ ] Test with screen reader
|
||||
- [ ] Test landscape orientation
|
||||
- [ ] Test on various screen sizes (320px, 375px, 414px, 768px)
|
||||
|
||||
## Commit History
|
||||
1. `feat: update viewport meta for mobile support`
|
||||
2. `feat: add media query hooks for responsive design`
|
||||
3. `feat: add mobile sidebar state management to useBookmarksUI`
|
||||
4. `feat: add mobile-responsive CSS with breakpoints and safe areas`
|
||||
5. `feat: implement mobile overlay sidebar with focus trap and ESC handling`
|
||||
6. `feat: add mobile auto-collapse setting`
|
||||
7. `fix: resolve TypeScript errors for mobile implementation`
|
||||
|
||||
@@ -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 three‑pane reader: bookmarks on the left, the article in the middle, and highlights on the right.
|
||||
|
||||
## Live
|
||||
|
||||
- App: [https://read.withboris.com/](https://read.withboris.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 distraction‑free 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 full‑focus reading
|
||||
- Remembers simple preferences like view mode, fonts, and highlight style
|
||||
|
||||
|
||||
24
index.html
@@ -2,9 +2,29 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>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://read.withboris.com/" />
|
||||
|
||||
<!-- Open Graph / Social Media -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://read.withboris.com/" />
|
||||
<meta property="og:title" content="Boris - Nostr Bookmarks" />
|
||||
<meta property="og:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
||||
<meta property="og:site_name" content="Boris" />
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:url" content="https://read.withboris.com/" />
|
||||
<meta name="twitter:title" content="Boris - Nostr Bookmarks" />
|
||||
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
4181
package-lock.json
generated
11
package.json
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.2.4",
|
||||
"version": "0.4.3",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -39,7 +40,9 @@
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.8"
|
||||
"vite": "^5.0.8",
|
||||
"vite-plugin-pwa": "^1.0.3",
|
||||
"workbox-window": "^7.3.0"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
@@ -58,7 +61,8 @@
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": [
|
||||
"@typescript-eslint",
|
||||
"react-refresh"
|
||||
"react-refresh",
|
||||
"react-hooks"
|
||||
],
|
||||
"rules": {
|
||||
"react-refresh/only-export-components": [
|
||||
@@ -67,6 +71,7 @@
|
||||
"allowConstantExport": true
|
||||
}
|
||||
],
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
|
||||
9
public/_headers
Normal 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
@@ -0,0 +1,3 @@
|
||||
# SPA redirect for client-side routing
|
||||
/* /index.html 200
|
||||
|
||||
6
public/_routes.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"include": ["/*"],
|
||||
"exclude": ["/assets/*", "/robots.txt", "/sw.js", "/_headers", "/_redirects"]
|
||||
}
|
||||
|
||||
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 465 B |
BIN
public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/icon-192.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/icon-512.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
public/icon-maskable-192.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/icon-maskable-512.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
37
public/manifest.webmanifest
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "Boris - Nostr Bookmarks",
|
||||
"short_name": "Boris",
|
||||
"description": "Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"theme_color": "#0f172a",
|
||||
"background_color": "#0b1220",
|
||||
"orientation": "any",
|
||||
"categories": ["productivity", "social", "utilities"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-maskable-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
5
public/robots.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://read.withboris.com/sitemap.xml
|
||||
|
||||
70
src/App.tsx
@@ -11,6 +11,7 @@ import { createAddressLoader } from 'applesauce-loaders/loaders'
|
||||
import Bookmarks from './components/Bookmarks'
|
||||
import Toast from './components/Toast'
|
||||
import { useToast } from './hooks/useToast'
|
||||
import { useOnlineStatus } from './hooks/useOnlineStatus'
|
||||
import { RELAYS } from './config/relays'
|
||||
|
||||
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
||||
@@ -27,8 +28,7 @@ function AppRoutes({
|
||||
const accountManager = Hooks.useAccountManager()
|
||||
|
||||
const handleLogout = () => {
|
||||
accountManager.setActive(undefined as never)
|
||||
localStorage.removeItem('active')
|
||||
accountManager.clearActive()
|
||||
showToast('Logged out successfully')
|
||||
}
|
||||
|
||||
@@ -52,6 +52,33 @@ function AppRoutes({
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/explore"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
|
||||
</Routes>
|
||||
)
|
||||
@@ -62,6 +89,7 @@ function App() {
|
||||
const [accountManager, setAccountManager] = useState<AccountManager | null>(null)
|
||||
const [relayPool, setRelayPool] = useState<RelayPool | null>(null)
|
||||
const { toastMessage, toastType, showToast, clearToast } = useToast()
|
||||
const isOnline = useOnlineStatus()
|
||||
|
||||
useEffect(() => {
|
||||
const initializeApp = async () => {
|
||||
@@ -109,6 +137,19 @@ function App() {
|
||||
console.log('Created relay group with', RELAYS.length, 'relays (including local)')
|
||||
console.log('Relay URLs:', RELAYS)
|
||||
|
||||
// Keep all relay connections alive indefinitely by creating a persistent subscription
|
||||
// This prevents disconnection when no other subscriptions are active
|
||||
// Create a minimal subscription that never completes to keep connections alive
|
||||
const keepAliveSub = pool.subscription(RELAYS, { kinds: [0], limit: 0 }).subscribe({
|
||||
next: () => {}, // No-op, we don't care about events
|
||||
error: (err) => console.warn('Keep-alive subscription error:', err)
|
||||
})
|
||||
console.log('🔗 Created keep-alive subscription for', RELAYS.length, 'relay(s)')
|
||||
|
||||
// Store subscription for cleanup
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(pool as any)._keepAliveSubscription = keepAliveSub
|
||||
|
||||
// Attach address/replaceable loaders so ProfileModel can fetch profiles
|
||||
const addressLoader = createAddressLoader(pool, {
|
||||
eventStore: store,
|
||||
@@ -125,6 +166,12 @@ function App() {
|
||||
return () => {
|
||||
accountsSub.unsubscribe()
|
||||
activeSub.unsubscribe()
|
||||
// Clean up keep-alive subscription if it exists
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if ((pool as any)._keepAliveSubscription) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(pool as any)._keepAliveSubscription.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +185,25 @@ function App() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Monitor online/offline status
|
||||
useEffect(() => {
|
||||
if (!isOnline) {
|
||||
showToast('You are offline. Some features may be limited.')
|
||||
}
|
||||
}, [isOnline, showToast])
|
||||
|
||||
// Listen for service worker updates
|
||||
useEffect(() => {
|
||||
const handleSWUpdate = () => {
|
||||
showToast('New version available! Refresh to update.')
|
||||
}
|
||||
|
||||
window.addEventListener('sw-update-available', handleSWUpdate)
|
||||
return () => {
|
||||
window.removeEventListener('sw-update-available', handleSWUpdate)
|
||||
}
|
||||
}, [showToast])
|
||||
|
||||
if (!eventStore || !accountManager || !relayPool) {
|
||||
return (
|
||||
<div className="loading">
|
||||
|
||||
288
src/components/AddBookmarkModal.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [url]) // Only depend on url - title, description, tagsInput are intentionally checked but not dependencies
|
||||
|
||||
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
|
||||
|
||||
43
src/components/AuthorCard.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faUserCircle } from '@fortawesome/free-solid-svg-icons'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
|
||||
interface AuthorCardProps {
|
||||
authorPubkey: string
|
||||
}
|
||||
|
||||
const AuthorCard: React.FC<AuthorCardProps> = ({ authorPubkey }) => {
|
||||
const profile = useEventModel(Models.ProfileModel, [authorPubkey])
|
||||
|
||||
const getAuthorName = () => {
|
||||
if (profile?.name) return profile.name
|
||||
if (profile?.display_name) return profile.display_name
|
||||
return `${authorPubkey.slice(0, 8)}...${authorPubkey.slice(-8)}`
|
||||
}
|
||||
|
||||
const authorImage = profile?.picture || profile?.image
|
||||
const authorBio = profile?.about
|
||||
|
||||
return (
|
||||
<div className="author-card">
|
||||
<div className="author-card-avatar">
|
||||
{authorImage ? (
|
||||
<img src={authorImage} alt={getAuthorName()} />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faUserCircle} />
|
||||
)}
|
||||
</div>
|
||||
<div className="author-card-content">
|
||||
<div className="author-card-name">{getAuthorName()}</div>
|
||||
{authorBio && (
|
||||
<p className="author-card-bio">{authorBio}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AuthorCard
|
||||
|
||||
61
src/components/BlogPostCard.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faCalendar, faUser } from '@fortawesome/free-solid-svg-icons'
|
||||
import { formatDistance } from 'date-fns'
|
||||
import { BlogPostPreview } from '../services/exploreService'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
|
||||
interface BlogPostCardProps {
|
||||
post: BlogPostPreview
|
||||
href: string
|
||||
}
|
||||
|
||||
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href }) => {
|
||||
const profile = useEventModel(Models.ProfileModel, [post.author])
|
||||
const displayName = profile?.name || profile?.display_name ||
|
||||
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
|
||||
|
||||
const publishedDate = post.published || post.event.created_at
|
||||
const formattedDate = formatDistance(new Date(publishedDate * 1000), new Date(), {
|
||||
addSuffix: true
|
||||
})
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
className="blog-post-card"
|
||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
{post.image && (
|
||||
<div className="blog-post-card-image">
|
||||
<img
|
||||
src={post.image}
|
||||
alt={post.title}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="blog-post-card-content">
|
||||
<h3 className="blog-post-card-title">{post.title}</h3>
|
||||
{post.summary && (
|
||||
<p className="blog-post-card-summary">{post.summary}</p>
|
||||
)}
|
||||
<div className="blog-post-card-meta">
|
||||
<span className="blog-post-card-author">
|
||||
<FontAwesomeIcon icon={faUser} />
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="blog-post-card-date">
|
||||
<FontAwesomeIcon icon={faCalendar} />
|
||||
{formattedDate}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default BlogPostCard
|
||||
|
||||
@@ -11,15 +11,17 @@ import { getPreviewImage, fetchOgImage } from '../utils/imagePreview'
|
||||
import { CompactView } from './BookmarkViews/CompactView'
|
||||
import { LargeView } from './BookmarkViews/LargeView'
|
||||
import { CardView } from './BookmarkViews/CardView'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
|
||||
interface BookmarkItemProps {
|
||||
bookmark: IndividualBookmark
|
||||
index: number
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
viewMode?: ViewMode
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards' }) => {
|
||||
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards', settings }) => {
|
||||
const [ogImage, setOgImage] = useState<string | null>(null)
|
||||
|
||||
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
|
||||
@@ -115,7 +117,8 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleImage,
|
||||
articleSummary
|
||||
articleSummary,
|
||||
settings
|
||||
}
|
||||
|
||||
if (viewMode === 'compact') {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Bookmark } from '../types/bookmarks'
|
||||
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage, faRotate } from '@fortawesome/free-solid-svg-icons'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||
import { BookmarkItem } from './BookmarkItem'
|
||||
import SidebarHeader from './SidebarHeader'
|
||||
import IconButton from './IconButton'
|
||||
import { ViewMode } from './Bookmarks'
|
||||
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
|
||||
interface BookmarkListProps {
|
||||
bookmarks: Bookmark[]
|
||||
@@ -19,7 +23,11 @@ interface BookmarkListProps {
|
||||
onOpenSettings: () => void
|
||||
onRefresh?: () => void
|
||||
isRefreshing?: boolean
|
||||
lastFetchTime?: number | null
|
||||
loading?: boolean
|
||||
relayPool: RelayPool | null
|
||||
settings?: UserSettings
|
||||
isMobile?: boolean
|
||||
}
|
||||
|
||||
export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
@@ -34,12 +42,41 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
onOpenSettings,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
loading = false
|
||||
lastFetchTime,
|
||||
loading = false,
|
||||
relayPool,
|
||||
settings,
|
||||
isMobile = false
|
||||
}) => {
|
||||
// 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) {
|
||||
@@ -70,8 +107,8 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
onLogout={onLogout}
|
||||
onOpenSettings={onOpenSettings}
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
relayPool={relayPool}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
@@ -82,6 +119,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
<div className="empty-state">
|
||||
<p>No bookmarks found.</p>
|
||||
<p>Add bookmarks using your nostr client to see them here.</p>
|
||||
<p>If you aren't on nostr yet, start here: <a href="https://nstart.me/" target="_blank" rel="noopener noreferrer">nstart.me</a></p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bookmarks-list">
|
||||
@@ -93,9 +131,38 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
index={index}
|
||||
onSelectUrl={onSelectUrl}
|
||||
viewMode={viewMode}
|
||||
settings={settings}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{onRefresh && (
|
||||
<div className="refresh-section" style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '1rem',
|
||||
marginTop: '1rem',
|
||||
borderTop: '1px solid var(--border-color)',
|
||||
fontSize: '0.85rem',
|
||||
color: 'var(--text-secondary)'
|
||||
}}>
|
||||
<IconButton
|
||||
icon={faRotate}
|
||||
onClick={onRefresh}
|
||||
title="Refresh bookmarks"
|
||||
ariaLabel="Refresh bookmarks"
|
||||
variant="ghost"
|
||||
disabled={isRefreshing}
|
||||
spin={isRefreshing}
|
||||
/>
|
||||
{lastFetchTime && (
|
||||
<span>
|
||||
Updated {formatDistanceToNow(lastFetchTime, { addSuffix: true })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="view-mode-controls">
|
||||
|
||||
@@ -7,6 +7,8 @@ import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||
import IconButton from '../IconButton'
|
||||
import { classifyUrl } from '../../utils/helpers'
|
||||
import { IconGetter } from './shared'
|
||||
import { useImageCache } from '../../hooks/useImageCache'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
|
||||
interface CardViewProps {
|
||||
bookmark: IndividualBookmark
|
||||
@@ -22,6 +24,7 @@ interface CardViewProps {
|
||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
articleImage?: string
|
||||
articleSummary?: string
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
export const CardView: React.FC<CardViewProps> = ({
|
||||
@@ -37,8 +40,10 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleImage,
|
||||
articleSummary
|
||||
articleSummary,
|
||||
settings
|
||||
}) => {
|
||||
const cachedImage = useImageCache(articleImage, settings)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [urlsExpanded, setUrlsExpanded] = useState(false)
|
||||
const contentLength = (bookmark.content || '').length
|
||||
@@ -48,10 +53,10 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
|
||||
return (
|
||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
||||
{isArticle && articleImage && (
|
||||
{isArticle && cachedImage && (
|
||||
<div
|
||||
className="article-hero-image"
|
||||
style={{ backgroundImage: `url(${articleImage})` }}
|
||||
style={{ backgroundImage: `url(${cachedImage})` }}
|
||||
onClick={() => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faBookmark, faUserLock, faGlobe } from '@fortawesome/free-solid-svg-icons'
|
||||
import { IndividualBookmark } from '../../types/bookmarks'
|
||||
import { formatDate } from '../../utils/bookmarkUtils'
|
||||
import { formatDateCompact } from '../../utils/bookmarkUtils'
|
||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||
import { IconGetter } from './shared'
|
||||
|
||||
@@ -75,7 +75,7 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
<ContentWithResolvedProfiles content={displayText.slice(0, 60) + (displayText.length > 60 ? '…' : '')} />
|
||||
</div>
|
||||
)}
|
||||
<span className="bookmark-date-compact">{formatDate(bookmark.created_at)}</span>
|
||||
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at)}</span>
|
||||
{isClickable && (
|
||||
<button
|
||||
className="compact-read-btn"
|
||||
|
||||
@@ -4,6 +4,8 @@ import { IndividualBookmark } from '../../types/bookmarks'
|
||||
import { formatDate } from '../../utils/bookmarkUtils'
|
||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||
import { IconGetter } from './shared'
|
||||
import { useImageCache } from '../../hooks/useImageCache'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
|
||||
interface LargeViewProps {
|
||||
bookmark: IndividualBookmark
|
||||
@@ -19,6 +21,7 @@ interface LargeViewProps {
|
||||
getAuthorDisplayName: () => string
|
||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
articleSummary?: string
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
export const LargeView: React.FC<LargeViewProps> = ({
|
||||
@@ -34,13 +37,15 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleSummary
|
||||
articleSummary,
|
||||
settings
|
||||
}) => {
|
||||
const cachedImage = useImageCache(previewImage || undefined, settings)
|
||||
const isArticle = bookmark.kind === 30023
|
||||
|
||||
return (
|
||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark large ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
||||
{(hasUrls || (isArticle && previewImage)) && (
|
||||
{(hasUrls || (isArticle && cachedImage)) && (
|
||||
<div
|
||||
className="large-preview-image"
|
||||
onClick={() => {
|
||||
@@ -50,7 +55,7 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
onSelectUrl?.(extractedUrls[0])
|
||||
}
|
||||
}}
|
||||
style={previewImage ? { backgroundImage: `url(${previewImage})` } : undefined}
|
||||
style={cachedImage ? { backgroundImage: `url(${cachedImage})` } : undefined}
|
||||
>
|
||||
{!previewImage && hasUrls && (
|
||||
<div className="preview-placeholder">
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import React, { useMemo, useEffect, useRef } from 'react'
|
||||
import { useParams, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { useEventStore } from 'applesauce-react/hooks'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
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 { useRelayStatus } from '../hooks/useRelayStatus'
|
||||
import { useOfflineSync } from '../hooks/useOfflineSync'
|
||||
import ThreePaneLayout from './ThreePaneLayout'
|
||||
import Explore from './Explore'
|
||||
import Me from './Me'
|
||||
import { classifyHighlights } from '../utils/highlightClassification'
|
||||
|
||||
export type ViewMode = 'compact' | 'cards' | 'large'
|
||||
|
||||
interface BookmarksProps {
|
||||
@@ -34,39 +28,26 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
const { naddr } = useParams<{ naddr?: string }>()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const previousLocationRef = useRef<string>()
|
||||
|
||||
// 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 showSettings = location.pathname === '/settings'
|
||||
const showExplore = location.pathname === '/explore'
|
||||
const showMe = location.pathname === '/me'
|
||||
|
||||
// Track previous location for going back from settings/me/explore
|
||||
useEffect(() => {
|
||||
if (!showSettings && !showMe && !showExplore) {
|
||||
previousLocationRef.current = location.pathname
|
||||
}
|
||||
}, [location.pathname, showSettings, showMe, showExplore])
|
||||
|
||||
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 +56,112 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
accountManager
|
||||
})
|
||||
|
||||
// Monitor relay status for offline sync
|
||||
const relayStatuses = useRelayStatus({ relayPool })
|
||||
|
||||
// Automatically sync local events to remote relays when coming back online
|
||||
useOfflineSync({
|
||||
relayPool,
|
||||
account: activeAccount || null,
|
||||
eventStore,
|
||||
relayStatuses,
|
||||
enabled: true
|
||||
})
|
||||
|
||||
const {
|
||||
isMobile,
|
||||
isSidebarOpen,
|
||||
toggleSidebar,
|
||||
isCollapsed,
|
||||
setIsCollapsed,
|
||||
isHighlightsCollapsed,
|
||||
setIsHighlightsCollapsed,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
showHighlights,
|
||||
setShowHighlights,
|
||||
selectedHighlightId,
|
||||
setSelectedHighlightId,
|
||||
currentArticleCoordinate,
|
||||
setCurrentArticleCoordinate,
|
||||
currentArticleEventId,
|
||||
setCurrentArticleEventId,
|
||||
currentArticle,
|
||||
setCurrentArticle,
|
||||
highlightVisibility,
|
||||
setHighlightVisibility
|
||||
} = useBookmarksUI({ settings })
|
||||
|
||||
// Close sidebar on mobile when route changes (e.g., clicking on blog posts in Explore)
|
||||
useEffect(() => {
|
||||
if (isMobile && isSidebarOpen) {
|
||||
toggleSidebar()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.pathname])
|
||||
|
||||
const {
|
||||
bookmarks,
|
||||
bookmarksLoading,
|
||||
highlights,
|
||||
setHighlights,
|
||||
highlightsLoading,
|
||||
setHighlightsLoading,
|
||||
followedPubkeys,
|
||||
isRefreshing,
|
||||
lastFetchTime,
|
||||
handleFetchHighlights,
|
||||
handleRefreshAll
|
||||
} = useBookmarksData({
|
||||
relayPool,
|
||||
activeAccount,
|
||||
accountManager,
|
||||
naddr,
|
||||
currentArticleCoordinate,
|
||||
currentArticleEventId,
|
||||
settings
|
||||
})
|
||||
|
||||
const {
|
||||
selectedUrl,
|
||||
setSelectedUrl,
|
||||
readerLoading,
|
||||
setReaderLoading,
|
||||
readerContent,
|
||||
setReaderContent,
|
||||
handleSelectUrl: baseHandleSelectUrl
|
||||
} = useContentSelection({
|
||||
relayPool,
|
||||
settings,
|
||||
setIsCollapsed,
|
||||
setShowSettings: () => {}, // No-op since we use route-based settings now
|
||||
setCurrentArticle
|
||||
})
|
||||
|
||||
// Wrap handleSelectUrl to close mobile sidebar when selecting content
|
||||
const handleSelectUrl = (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => {
|
||||
if (isMobile && isSidebarOpen) {
|
||||
toggleSidebar()
|
||||
}
|
||||
baseHandleSelectUrl(url, bookmark)
|
||||
}
|
||||
|
||||
const {
|
||||
highlightButtonRef,
|
||||
handleTextSelection,
|
||||
handleClearSelection,
|
||||
handleCreateHighlight
|
||||
} = useHighlightCreation({
|
||||
activeAccount,
|
||||
relayPool,
|
||||
eventStore,
|
||||
currentArticle,
|
||||
selectedUrl,
|
||||
readerContent,
|
||||
onHighlightCreated: (highlight) => setHighlights(prev => [highlight, ...prev]),
|
||||
settings
|
||||
})
|
||||
|
||||
// Load nostr-native article if naddr is in URL
|
||||
useArticleLoader({
|
||||
naddr,
|
||||
@@ -87,7 +174,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
setHighlightsLoading,
|
||||
setCurrentArticleCoordinate,
|
||||
setCurrentArticleEventId,
|
||||
setCurrentArticle
|
||||
setCurrentArticle,
|
||||
settings
|
||||
})
|
||||
|
||||
// Load external URL if /r/* route is used
|
||||
@@ -104,288 +192,83 @@ 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}
|
||||
isSidebarOpen={isSidebarOpen}
|
||||
showSettings={showSettings}
|
||||
showExplore={showExplore}
|
||||
showMe={showMe}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
viewMode={viewMode}
|
||||
isRefreshing={isRefreshing}
|
||||
lastFetchTime={lastFetchTime}
|
||||
onToggleSidebar={isMobile ? toggleSidebar : () => setIsCollapsed(!isCollapsed)}
|
||||
onLogout={onLogout}
|
||||
onViewModeChange={setViewMode}
|
||||
onOpenSettings={() => {
|
||||
navigate('/settings')
|
||||
if (isMobile) {
|
||||
toggleSidebar()
|
||||
} else {
|
||||
setIsCollapsed(true)
|
||||
}
|
||||
setIsHighlightsCollapsed(true)
|
||||
}}
|
||||
onRefresh={handleRefreshAll}
|
||||
relayPool={relayPool}
|
||||
eventStore={eventStore}
|
||||
readerLoading={readerLoading}
|
||||
readerContent={readerContent}
|
||||
selectedUrl={selectedUrl}
|
||||
settings={settings}
|
||||
onSaveSettings={saveSettings}
|
||||
onCloseSettings={() => {
|
||||
// Navigate back to previous location or default
|
||||
const backTo = previousLocationRef.current || '/'
|
||||
navigate(backTo)
|
||||
}}
|
||||
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}
|
||||
activeAccount={activeAccount}
|
||||
currentArticle={currentArticle}
|
||||
highlights={highlights}
|
||||
highlightsLoading={highlightsLoading}
|
||||
onToggleHighlightsPanel={() => setIsHighlightsCollapsed(!isHighlightsCollapsed)}
|
||||
onSelectUrl={handleSelectUrl}
|
||||
onToggleHighlights={setShowHighlights}
|
||||
onRefreshHighlights={handleFetchHighlights}
|
||||
onHighlightVisibilityChange={setHighlightVisibility}
|
||||
highlightButtonRef={highlightButtonRef}
|
||||
onCreateHighlight={handleCreateHighlight}
|
||||
hasActiveAccount={!!(activeAccount && relayPool)}
|
||||
explore={showExplore ? (
|
||||
relayPool ? <Explore relayPool={relayPool} /> : null
|
||||
) : undefined}
|
||||
me={showMe ? (
|
||||
relayPool ? <Me relayPool={relayPool} /> : null
|
||||
) : undefined}
|
||||
toastMessage={toastMessage ?? undefined}
|
||||
toastType={toastType}
|
||||
onClearToast={clearToast}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
56
src/components/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean
|
||||
title: string
|
||||
message: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
variant?: 'danger' | 'warning' | 'info'
|
||||
}
|
||||
|
||||
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||
isOpen,
|
||||
title,
|
||||
message,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
variant = 'warning'
|
||||
}) => {
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="confirm-dialog-overlay" onClick={onCancel}>
|
||||
<div className="confirm-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className={`confirm-dialog-icon ${variant}`}>
|
||||
<FontAwesomeIcon icon={faExclamationTriangle} />
|
||||
</div>
|
||||
<h3 className="confirm-dialog-title">{title}</h3>
|
||||
<p className="confirm-dialog-message">{message}</p>
|
||||
<div className="confirm-dialog-actions">
|
||||
<button
|
||||
className="confirm-dialog-btn cancel"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
className={`confirm-dialog-btn confirm ${variant}`}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConfirmDialog
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import React, { useMemo, useEffect, useRef, useState, useCallback } from 'react'
|
||||
import React, { useMemo, useState } 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 { faSpinner, faBook } from '@fortawesome/free-solid-svg-icons'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
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'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import { createEventReaction, createWebsiteReaction } from '../services/reactionService'
|
||||
import AuthorCard from './AuthorCard'
|
||||
|
||||
interface ContentPanelProps {
|
||||
loading: boolean
|
||||
@@ -18,6 +25,8 @@ interface ContentPanelProps {
|
||||
markdown?: string
|
||||
selectedUrl?: string
|
||||
image?: string
|
||||
summary?: string
|
||||
published?: number
|
||||
highlights?: Highlight[]
|
||||
showHighlights?: boolean
|
||||
highlightStyle?: 'marker' | 'underline'
|
||||
@@ -27,6 +36,10 @@ interface ContentPanelProps {
|
||||
highlightVisibility?: HighlightVisibility
|
||||
currentUserPubkey?: string
|
||||
followedPubkeys?: Set<string>
|
||||
settings?: UserSettings
|
||||
relayPool?: RelayPool | null
|
||||
activeAccount?: IAccount | null
|
||||
currentArticle?: NostrEvent | null
|
||||
// For highlight creation
|
||||
onTextSelection?: (text: string) => void
|
||||
onClearSelection?: () => void
|
||||
@@ -39,183 +52,93 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
markdown,
|
||||
selectedUrl,
|
||||
image,
|
||||
summary,
|
||||
published,
|
||||
highlights = [],
|
||||
showHighlights = true,
|
||||
highlightStyle = 'marker',
|
||||
highlightColor = '#ffff00',
|
||||
settings,
|
||||
relayPool,
|
||||
activeAccount,
|
||||
currentArticle,
|
||||
onHighlightClick,
|
||||
selectedHighlightId,
|
||||
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 [isMarkingAsRead, setIsMarkingAsRead] = useState(false)
|
||||
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool)
|
||||
|
||||
// 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, handleSelectionEnd } = 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
|
||||
}
|
||||
// Determine if we're on a nostr-native article (/a/) or external URL (/r/)
|
||||
const isNostrArticle = selectedUrl && selectedUrl.startsWith('nostr:')
|
||||
|
||||
const handleMarkAsRead = async () => {
|
||||
if (!activeAccount || !relayPool) {
|
||||
console.warn('Cannot mark as read: no account or relay pool')
|
||||
return
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
const text = selection.toString().trim()
|
||||
setIsMarkingAsRead(true)
|
||||
|
||||
if (text.length > 0 && contentRef.current?.contains(range.commonAncestorContainer)) {
|
||||
onTextSelection?.(text)
|
||||
} else {
|
||||
onClearSelection?.()
|
||||
try {
|
||||
if (isNostrArticle && currentArticle) {
|
||||
// Kind 7 reaction for nostr-native articles
|
||||
await createEventReaction(
|
||||
currentArticle.id,
|
||||
currentArticle.pubkey,
|
||||
currentArticle.kind,
|
||||
activeAccount,
|
||||
relayPool
|
||||
)
|
||||
console.log('✅ Marked nostr article as read')
|
||||
} else if (selectedUrl) {
|
||||
// Kind 17 reaction for external websites
|
||||
await createWebsiteReaction(
|
||||
selectedUrl,
|
||||
activeAccount,
|
||||
relayPool
|
||||
)
|
||||
console.log('✅ Marked website as read')
|
||||
}
|
||||
}, 10)
|
||||
}, [onTextSelection, onClearSelection])
|
||||
} catch (error) {
|
||||
console.error('Failed to mark as read:', error)
|
||||
} finally {
|
||||
setIsMarkingAsRead(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedUrl) {
|
||||
return (
|
||||
@@ -243,7 +166,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
{markdown && (
|
||||
<div ref={markdownPreviewRef} style={{ display: 'none' }}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{markdown}
|
||||
{processedMarkdown || markdown}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
@@ -251,37 +174,63 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
<ReaderHeader
|
||||
title={title}
|
||||
image={image}
|
||||
summary={summary}
|
||||
published={published}
|
||||
readingTimeText={readingStats ? readingStats.text : null}
|
||||
hasHighlights={hasHighlights}
|
||||
highlightCount={relevantHighlights.length}
|
||||
settings={settings}
|
||||
/>
|
||||
{markdown || html ? (
|
||||
markdown ? (
|
||||
// For markdown, always use finalHtml once it's ready to ensure highlights are applied
|
||||
renderedHtml && finalHtml ? (
|
||||
<>
|
||||
{markdown ? (
|
||||
renderedMarkdownHtml && finalHtml ? (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="reader-markdown"
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml }}
|
||||
onMouseUp={handleSelectionEnd}
|
||||
onTouchEnd={handleSelectionEnd}
|
||||
/>
|
||||
) : (
|
||||
<div className="reader-markdown">
|
||||
<div className="loading-spinner">
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="reader-markdown"
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml }}
|
||||
onMouseUp={handleMouseUp}
|
||||
className="reader-html"
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
|
||||
onMouseUp={handleSelectionEnd}
|
||||
onTouchEnd={handleSelectionEnd}
|
||||
/>
|
||||
) : (
|
||||
// Show loading state while markdown is being converted to HTML
|
||||
<div className="reader-markdown">
|
||||
<div className="loading-spinner">
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="sm" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mark as Read button */}
|
||||
{activeAccount && (
|
||||
<div className="mark-as-read-container">
|
||||
<button
|
||||
className="mark-as-read-btn"
|
||||
onClick={handleMarkAsRead}
|
||||
disabled={isMarkingAsRead}
|
||||
title="Mark as Read"
|
||||
>
|
||||
<FontAwesomeIcon icon={isMarkingAsRead ? faSpinner : faBook} spin={isMarkingAsRead} />
|
||||
<span>{isMarkingAsRead ? 'Marking...' : 'Mark as Read'}</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
// For HTML, use finalHtml directly
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="reader-html"
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
|
||||
onMouseUp={handleMouseUp}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Author info card for nostr-native articles */}
|
||||
{isNostrArticle && currentArticle && (
|
||||
<div className="author-card-container">
|
||||
<AuthorCard authorPubkey={currentArticle.pubkey} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="reader empty">
|
||||
<p>No readable content found for this URL.</p>
|
||||
|
||||
129
src/components/Explore.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner, faExclamationCircle, faNewspaper } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { fetchContacts } from '../services/contactService'
|
||||
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
||||
import BlogPostCard from './BlogPostCard'
|
||||
|
||||
interface ExploreProps {
|
||||
relayPool: RelayPool
|
||||
}
|
||||
|
||||
const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const [blogPosts, setBlogPosts] = useState<BlogPostPreview[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const loadBlogPosts = async () => {
|
||||
if (!activeAccount) {
|
||||
setError('Please log in to explore content from your friends')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
// Fetch the user's contacts (friends)
|
||||
const contacts = await fetchContacts(relayPool, activeAccount.pubkey)
|
||||
|
||||
if (contacts.size === 0) {
|
||||
setError('You are not following anyone yet. Follow some people to see their blog posts!')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Get relay URLs from pool
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
|
||||
// Fetch blog posts from friends
|
||||
const posts = await fetchBlogPostsFromAuthors(
|
||||
relayPool,
|
||||
Array.from(contacts),
|
||||
relayUrls
|
||||
)
|
||||
|
||||
if (posts.length === 0) {
|
||||
setError('No blog posts found from your friends yet')
|
||||
}
|
||||
|
||||
setBlogPosts(posts)
|
||||
} catch (err) {
|
||||
console.error('Failed to load blog posts:', err)
|
||||
setError('Failed to load blog posts. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadBlogPosts()
|
||||
}, [relayPool, activeAccount])
|
||||
|
||||
const getPostUrl = (post: BlogPostPreview) => {
|
||||
// Get the d-tag identifier
|
||||
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
|
||||
// Create naddr
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
pubkey: post.author,
|
||||
identifier: dTag
|
||||
})
|
||||
|
||||
return `/a/${naddr}`
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="explore-container">
|
||||
<div className="explore-loading">
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||
<p>Loading blog posts from your friends...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="explore-container">
|
||||
<div className="explore-error">
|
||||
<FontAwesomeIcon icon={faExclamationCircle} size="2x" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="explore-container">
|
||||
<div className="explore-header">
|
||||
<h1>
|
||||
<FontAwesomeIcon icon={faNewspaper} />
|
||||
Explore
|
||||
</h1>
|
||||
<p className="explore-subtitle">
|
||||
Discover blog posts from your friends on Nostr
|
||||
</p>
|
||||
</div>
|
||||
<div className="explore-grid">
|
||||
{blogPosts.map((post) => (
|
||||
<BlogPostCard
|
||||
key={`${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1]}`}
|
||||
post={post}
|
||||
href={getPostUrl(post)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Explore
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faQuoteLeft, faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faServer, faTrash } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
import { Models, IEventStore } from 'applesauce-core'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { areAllRelaysLocal } from '../utils/helpers'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { formatDateCompact } from '../utils/bookmarkUtils'
|
||||
import { createDeletionRequest } from '../services/deletionService'
|
||||
import ConfirmDialog from './ConfirmDialog'
|
||||
|
||||
interface HighlightWithLevel extends Highlight {
|
||||
level?: 'mine' | 'friends' | 'nostrverse'
|
||||
@@ -15,10 +23,30 @@ interface HighlightItemProps {
|
||||
onSelectUrl?: (url: string) => void
|
||||
isSelected?: boolean
|
||||
onHighlightClick?: (highlightId: string) => void
|
||||
relayPool?: RelayPool | null
|
||||
eventStore?: IEventStore | null
|
||||
onHighlightUpdate?: (highlight: Highlight) => void
|
||||
onHighlightDelete?: (highlightId: string) => void
|
||||
}
|
||||
|
||||
export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelectUrl, isSelected, onHighlightClick }) => {
|
||||
export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
highlight,
|
||||
onSelectUrl,
|
||||
isSelected,
|
||||
onHighlightClick,
|
||||
relayPool,
|
||||
eventStore,
|
||||
onHighlightUpdate,
|
||||
onHighlightDelete
|
||||
}) => {
|
||||
const itemRef = useRef<HTMLDivElement>(null)
|
||||
const [isSyncing, setIsSyncing] = useState(() => isEventSyncing(highlight.id))
|
||||
const [showOfflineIndicator, setShowOfflineIndicator] = useState(() => highlight.isOfflineCreated && !isSyncing)
|
||||
const [isRebroadcasting, setIsRebroadcasting] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
|
||||
// Resolve the profile of the user who made the highlight
|
||||
const profile = useEventModel(Models.ProfileModel, [highlight.pubkey])
|
||||
@@ -30,6 +58,39 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelec
|
||||
return `${highlight.pubkey.slice(0, 8)}...` // fallback to short pubkey
|
||||
}
|
||||
|
||||
// Update offline indicator when highlight prop changes
|
||||
useEffect(() => {
|
||||
if (highlight.isOfflineCreated && !isSyncing) {
|
||||
setShowOfflineIndicator(true)
|
||||
}
|
||||
}, [highlight.isOfflineCreated, isSyncing])
|
||||
|
||||
// Listen to sync state changes
|
||||
useEffect(() => {
|
||||
const unsubscribe = onSyncStateChange((eventId, syncingState) => {
|
||||
if (eventId === highlight.id) {
|
||||
setIsSyncing(syncingState)
|
||||
// When sync completes successfully, update highlight to show all relays
|
||||
if (!syncingState) {
|
||||
setShowOfflineIndicator(false)
|
||||
|
||||
// Update the highlight with all relays after successful sync
|
||||
if (onHighlightUpdate && highlight.isLocalOnly) {
|
||||
const updatedHighlight = {
|
||||
...highlight,
|
||||
publishedRelays: RELAYS,
|
||||
isLocalOnly: false,
|
||||
isOfflineCreated: false
|
||||
}
|
||||
onHighlightUpdate(updatedHighlight)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [highlight, onHighlightUpdate])
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelected && itemRef.current) {
|
||||
itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
@@ -51,14 +112,191 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelec
|
||||
|
||||
const getSourceLink = () => {
|
||||
if (highlight.eventReference) {
|
||||
return `https://search.dergigi.com/e/${highlight.eventReference}`
|
||||
// Check if it's a coordinate string (kind:pubkey:identifier) or a simple event ID
|
||||
if (highlight.eventReference.includes(':')) {
|
||||
// It's an addressable event coordinate, encode as naddr
|
||||
const parts = highlight.eventReference.split(':')
|
||||
if (parts.length === 3) {
|
||||
const [kindStr, pubkey, identifier] = parts
|
||||
const kind = parseInt(kindStr, 10)
|
||||
|
||||
// Get non-local relays for the hint
|
||||
const relayHints = RELAYS.filter(r =>
|
||||
!r.includes('localhost') && !r.includes('127.0.0.1')
|
||||
).slice(0, 3) // Include up to 3 relay hints
|
||||
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind,
|
||||
pubkey,
|
||||
identifier,
|
||||
relays: relayHints
|
||||
})
|
||||
return `https://njump.me/${naddr}`
|
||||
}
|
||||
} else {
|
||||
// It's a simple event ID, encode as nevent
|
||||
// Get non-local relays for the hint
|
||||
const relayHints = RELAYS.filter(r =>
|
||||
!r.includes('localhost') && !r.includes('127.0.0.1')
|
||||
).slice(0, 3) // Include up to 3 relay hints
|
||||
|
||||
const nevent = nip19.neventEncode({
|
||||
id: highlight.eventReference,
|
||||
relays: relayHints,
|
||||
author: highlight.author
|
||||
})
|
||||
return `https://njump.me/${nevent}`
|
||||
}
|
||||
}
|
||||
return highlight.urlReference
|
||||
}
|
||||
|
||||
const sourceLink = getSourceLink()
|
||||
|
||||
// Handle rebroadcast to all relays
|
||||
const handleRebroadcast = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation() // Prevent triggering highlight selection
|
||||
|
||||
if (!relayPool || !eventStore || isRebroadcasting) return
|
||||
|
||||
setIsRebroadcasting(true)
|
||||
|
||||
try {
|
||||
// Get the event from the event store
|
||||
const event = eventStore.getEvent(highlight.id)
|
||||
if (!event) {
|
||||
console.error('Event not found in store:', highlight.id)
|
||||
return
|
||||
}
|
||||
|
||||
// Publish to all configured relays - let the relay pool handle connection state
|
||||
const targetRelays = RELAYS
|
||||
|
||||
console.log('📡 Rebroadcasting highlight to', targetRelays.length, 'relay(s):', targetRelays)
|
||||
|
||||
await relayPool.publish(targetRelays, event)
|
||||
|
||||
console.log('✅ Rebroadcast successful!')
|
||||
|
||||
// Update the highlight with new relay info
|
||||
const isLocalOnly = areAllRelaysLocal(targetRelays)
|
||||
const updatedHighlight = {
|
||||
...highlight,
|
||||
publishedRelays: targetRelays,
|
||||
isLocalOnly,
|
||||
isOfflineCreated: false
|
||||
}
|
||||
|
||||
// Notify parent of the update
|
||||
if (onHighlightUpdate) {
|
||||
onHighlightUpdate(updatedHighlight)
|
||||
}
|
||||
|
||||
// Update local state
|
||||
setShowOfflineIndicator(false)
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to rebroadcast:', error)
|
||||
} finally {
|
||||
setIsRebroadcasting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine relay indicator icon and tooltip
|
||||
const getRelayIndicatorInfo = () => {
|
||||
// Show spinner if manually rebroadcasting OR auto-syncing
|
||||
if (isRebroadcasting || isSyncing) {
|
||||
return {
|
||||
icon: faSpinner,
|
||||
tooltip: isRebroadcasting ? 'rebroadcasting...' : 'syncing...',
|
||||
spin: true
|
||||
}
|
||||
}
|
||||
|
||||
// Always show relay list, use plane icon for local-only
|
||||
const isLocalOrOffline = highlight.isLocalOnly || showOfflineIndicator
|
||||
|
||||
// Show server icon with relay info if available
|
||||
if (highlight.publishedRelays && highlight.publishedRelays.length > 0) {
|
||||
const relayNames = highlight.publishedRelays.map(url =>
|
||||
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
||||
)
|
||||
return {
|
||||
icon: isLocalOrOffline ? faPlane : faServer,
|
||||
tooltip: relayNames.join('\n'),
|
||||
spin: false
|
||||
}
|
||||
}
|
||||
|
||||
if (highlight.seenOnRelays && highlight.seenOnRelays.length > 0) {
|
||||
const relayNames = highlight.seenOnRelays.map(url =>
|
||||
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
||||
)
|
||||
return {
|
||||
icon: faServer,
|
||||
tooltip: relayNames.join('\n'),
|
||||
spin: false
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: show all relays we queried (where this was likely fetched from)
|
||||
const relayNames = RELAYS.map(url =>
|
||||
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
||||
)
|
||||
return {
|
||||
icon: faServer,
|
||||
tooltip: relayNames.join('\n'),
|
||||
spin: false
|
||||
}
|
||||
}
|
||||
|
||||
const relayIndicator = getRelayIndicatorInfo()
|
||||
|
||||
// Check if current user can delete this highlight
|
||||
const canDelete = activeAccount && highlight.pubkey === activeAccount.pubkey
|
||||
|
||||
const handleDeleteClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setShowDeleteConfirm(true)
|
||||
}
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!activeAccount || !relayPool) {
|
||||
console.warn('Cannot delete: no account or relay pool')
|
||||
return
|
||||
}
|
||||
|
||||
setIsDeleting(true)
|
||||
setShowDeleteConfirm(false)
|
||||
|
||||
try {
|
||||
await createDeletionRequest(
|
||||
highlight.id,
|
||||
9802, // kind for highlights
|
||||
'Deleted by user',
|
||||
activeAccount,
|
||||
relayPool
|
||||
)
|
||||
|
||||
console.log('✅ Highlight deletion request published')
|
||||
|
||||
// Notify parent to remove this highlight from the list
|
||||
if (onHighlightDelete) {
|
||||
onHighlightDelete(highlight.id)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete highlight:', error)
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelDelete = () => {
|
||||
setShowDeleteConfirm(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={itemRef}
|
||||
className={`highlight-item ${isSelected ? 'selected' : ''} ${highlight.level ? `level-${highlight.level}` : ''}`}
|
||||
@@ -68,6 +306,25 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelec
|
||||
>
|
||||
<div className="highlight-quote-icon">
|
||||
<FontAwesomeIcon icon={faQuoteLeft} />
|
||||
{relayIndicator && (
|
||||
<div
|
||||
className="highlight-relay-indicator"
|
||||
title={relayIndicator.tooltip}
|
||||
onClick={handleRebroadcast}
|
||||
style={{ cursor: relayPool && eventStore ? 'pointer' : 'default' }}
|
||||
>
|
||||
<FontAwesomeIcon icon={relayIndicator.icon} spin={relayIndicator.spin} />
|
||||
</div>
|
||||
)}
|
||||
{canDelete && (
|
||||
<div
|
||||
className="highlight-delete-btn"
|
||||
title="Delete highlight"
|
||||
onClick={handleDeleteClick}
|
||||
>
|
||||
<FontAwesomeIcon icon={isDeleting ? faSpinner : faTrash} spin={isDeleting} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="highlight-content">
|
||||
@@ -88,7 +345,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelec
|
||||
</span>
|
||||
<span className="highlight-meta-separator">•</span>
|
||||
<span className="highlight-time">
|
||||
{formatDistanceToNow(new Date(highlight.created_at * 1000), { addSuffix: true })}
|
||||
{formatDateCompact(highlight.created_at)}
|
||||
</span>
|
||||
|
||||
{sourceLink && (
|
||||
@@ -106,6 +363,18 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelec
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={showDeleteConfirm}
|
||||
title="Delete Highlight?"
|
||||
message="This will request deletion of your highlight. It may still be visible on some relays that don't honor deletion requests."
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
variant="danger"
|
||||
onConfirm={handleConfirmDelete}
|
||||
onCancel={handleCancelDelete}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
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'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
|
||||
export interface HighlightVisibility {
|
||||
nostrverse: boolean
|
||||
@@ -25,6 +30,8 @@ interface HighlightsPanelProps {
|
||||
highlightVisibility?: HighlightVisibility
|
||||
onHighlightVisibilityChange?: (visibility: HighlightVisibility) => void
|
||||
followedPubkeys?: Set<string>
|
||||
relayPool?: RelayPool | null
|
||||
eventStore?: IEventStore | null
|
||||
}
|
||||
|
||||
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
@@ -41,9 +48,12 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
currentUserPubkey,
|
||||
highlightVisibility = { nostrverse: true, friends: true, mine: true },
|
||||
onHighlightVisibilityChange,
|
||||
followedPubkeys = new Set()
|
||||
followedPubkeys = new Set(),
|
||||
relayPool,
|
||||
eventStore
|
||||
}) => {
|
||||
const [showHighlights, setShowHighlights] = useState(true)
|
||||
const [localHighlights, setLocalHighlights] = useState(highlights)
|
||||
|
||||
const handleToggleHighlights = () => {
|
||||
const newValue = !showHighlights
|
||||
@@ -51,153 +61,52 @@ 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])
|
||||
// Keep track of highlight updates
|
||||
React.useEffect(() => {
|
||||
setLocalHighlights(highlights)
|
||||
}, [highlights])
|
||||
|
||||
const handleHighlightUpdate = (updatedHighlight: Highlight) => {
|
||||
setLocalHighlights(prev =>
|
||||
prev.map(h => h.id === updatedHighlight.id ? updatedHighlight : h)
|
||||
)
|
||||
}
|
||||
|
||||
const handleHighlightDelete = (highlightId: string) => {
|
||||
// Remove highlight from local state
|
||||
setLocalHighlights(prev => prev.filter(h => h.id !== highlightId))
|
||||
}
|
||||
|
||||
const filteredHighlights = useFilteredHighlights({
|
||||
highlights: localHighlights,
|
||||
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">
|
||||
@@ -222,6 +131,10 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
onSelectUrl={onSelectUrl}
|
||||
isSelected={highlight.id === selectedHighlightId}
|
||||
onHighlightClick={onHighlightClick}
|
||||
relayPool={relayPool}
|
||||
eventStore={eventStore}
|
||||
onHighlightUpdate={handleHighlightUpdate}
|
||||
onHighlightDelete={handleHighlightDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
30
src/components/HighlightsPanel/HighlightsPanelCollapsed.tsx
Normal 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
|
||||
|
||||
111
src/components/HighlightsPanel/HighlightsPanelHeader.tsx
Normal 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
|
||||
|
||||
@@ -11,6 +11,7 @@ interface IconButtonProps {
|
||||
size?: number
|
||||
disabled?: boolean
|
||||
spin?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const IconButton: React.FC<IconButtonProps> = ({
|
||||
@@ -21,11 +22,12 @@ const IconButton: React.FC<IconButtonProps> = ({
|
||||
variant = 'ghost',
|
||||
size = 33,
|
||||
disabled = false,
|
||||
spin = false
|
||||
spin = false,
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
className={`icon-button ${variant}`}
|
||||
className={`icon-button ${variant} ${className}`.trim()}
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-label={ariaLabel || title}
|
||||
|
||||
119
src/components/Me.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner, faExclamationCircle, faUser, faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { HighlightItem } from './HighlightItem'
|
||||
import { fetchHighlights } from '../services/highlightService'
|
||||
|
||||
interface MeProps {
|
||||
relayPool: RelayPool
|
||||
}
|
||||
|
||||
const Me: React.FC<MeProps> = ({ relayPool }) => {
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const profile = useEventModel(Models.ProfileModel, activeAccount ? [activeAccount.pubkey] : null)
|
||||
|
||||
const getUserDisplayName = () => {
|
||||
if (!activeAccount) return 'Unknown User'
|
||||
if (profile?.name) return profile.name
|
||||
if (profile?.display_name) return profile.display_name
|
||||
if (profile?.nip05) return profile.nip05
|
||||
return `${activeAccount.pubkey.slice(0, 8)}...${activeAccount.pubkey.slice(-8)}`
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const loadHighlights = async () => {
|
||||
if (!activeAccount) {
|
||||
setError('Please log in to view your highlights')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
// Fetch highlights created by the user
|
||||
const userHighlights = await fetchHighlights(
|
||||
relayPool,
|
||||
activeAccount.pubkey
|
||||
)
|
||||
|
||||
if (userHighlights.length === 0) {
|
||||
setError('No highlights yet. Start highlighting content to see them here!')
|
||||
}
|
||||
|
||||
setHighlights(userHighlights)
|
||||
} catch (err) {
|
||||
console.error('Failed to load highlights:', err)
|
||||
setError('Failed to load highlights. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadHighlights()
|
||||
}, [relayPool, activeAccount])
|
||||
|
||||
const handleHighlightDelete = (highlightId: string) => {
|
||||
// Remove highlight from local state
|
||||
setHighlights(prev => prev.filter(h => h.id !== highlightId))
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="explore-container">
|
||||
<div className="explore-loading">
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||
<p>Loading your highlights...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="explore-container">
|
||||
<div className="explore-error">
|
||||
<FontAwesomeIcon icon={faExclamationCircle} size="2x" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="explore-container">
|
||||
<div className="explore-header">
|
||||
<h1>
|
||||
<FontAwesomeIcon icon={faUser} />
|
||||
{getUserDisplayName()}
|
||||
</h1>
|
||||
<p className="explore-subtitle">
|
||||
<FontAwesomeIcon icon={faHighlighter} /> {highlights.length} highlight{highlights.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="highlights-list me-highlights-list">
|
||||
{highlights.map((highlight) => (
|
||||
<HighlightItem
|
||||
key={highlight.id}
|
||||
highlight={{ ...highlight, level: 'mine' }}
|
||||
relayPool={relayPool}
|
||||
onHighlightDelete={handleHighlightDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Me
|
||||
|
||||
@@ -1,32 +1,86 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHighlighter, faClock } from '@fortawesome/free-solid-svg-icons'
|
||||
import { format } from 'date-fns'
|
||||
import { useImageCache } from '../hooks/useImageCache'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
|
||||
interface ReaderHeaderProps {
|
||||
title?: string
|
||||
image?: string
|
||||
summary?: string
|
||||
published?: number
|
||||
readingTimeText?: string | null
|
||||
hasHighlights: boolean
|
||||
highlightCount: number
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
title,
|
||||
image,
|
||||
summary,
|
||||
published,
|
||||
readingTimeText,
|
||||
hasHighlights,
|
||||
highlightCount
|
||||
highlightCount,
|
||||
settings
|
||||
}) => {
|
||||
const cachedImage = useImageCache(image, settings)
|
||||
const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null
|
||||
const isLongSummary = summary && summary.length > 150
|
||||
|
||||
if (cachedImage) {
|
||||
return (
|
||||
<>
|
||||
<div className="reader-hero-image">
|
||||
<img src={cachedImage} alt={title || 'Article image'} />
|
||||
{formattedDate && (
|
||||
<div className="publish-date-topright">
|
||||
{formattedDate}
|
||||
</div>
|
||||
)}
|
||||
{title && (
|
||||
<div className="reader-header-overlay">
|
||||
<h2 className="reader-title">{title}</h2>
|
||||
{summary && <p className={`reader-summary ${isLongSummary ? 'hide-on-mobile' : ''}`}>{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>
|
||||
{isLongSummary && (
|
||||
<div className="reader-summary-below-image">
|
||||
<p className="reader-summary">{summary}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{image && (
|
||||
<div className="reader-hero-image">
|
||||
<img src={image} alt={title || 'Article image'} />
|
||||
</div>
|
||||
)}
|
||||
{title && (
|
||||
<div className="reader-header">
|
||||
{formattedDate && (
|
||||
<div className="publish-date-topright">
|
||||
{formattedDate}
|
||||
</div>
|
||||
)}
|
||||
<h2 className="reader-title">{title}</h2>
|
||||
{summary && <p className="reader-summary">{summary}</p>}
|
||||
<div className="reader-meta">
|
||||
{readingTimeText && (
|
||||
<div className="reading-time">
|
||||
|
||||
116
src/components/RelayStatusIndicator.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faPlane, faGlobe, faCircle, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||
import { isLocalRelay } from '../utils/helpers'
|
||||
import { useIsMobile } from '../hooks/useMediaQuery'
|
||||
|
||||
interface RelayStatusIndicatorProps {
|
||||
relayPool: RelayPool | null
|
||||
}
|
||||
|
||||
export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ relayPool }) => {
|
||||
// Poll frequently for responsive offline indicator (5s instead of default 20s)
|
||||
const relayStatuses = useRelayStatus({ relayPool, pollingInterval: 5000 })
|
||||
const [isConnecting, setIsConnecting] = useState(true)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
if (!relayPool) return null
|
||||
|
||||
// Get currently connected relays
|
||||
const connectedRelays = relayStatuses.filter(r => r.isInPool)
|
||||
const connectedUrls = connectedRelays.map(r => r.url)
|
||||
|
||||
// Determine connection status
|
||||
const hasLocalRelay = connectedUrls.some(url => isLocalRelay(url))
|
||||
const hasRemoteRelay = connectedUrls.some(url => !isLocalRelay(url))
|
||||
const localOnlyMode = hasLocalRelay && !hasRemoteRelay
|
||||
const offlineMode = connectedUrls.length === 0
|
||||
|
||||
// Show "Connecting" for first few seconds or until relays connect
|
||||
useEffect(() => {
|
||||
if (connectedUrls.length > 0) {
|
||||
// Connected! Stop showing connecting state
|
||||
setIsConnecting(false)
|
||||
} else {
|
||||
// No connections yet - show connecting for 8 seconds
|
||||
setIsConnecting(true)
|
||||
const timeout = setTimeout(() => {
|
||||
setIsConnecting(false)
|
||||
}, 8000)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [connectedUrls.length])
|
||||
|
||||
// Debug logging
|
||||
useEffect(() => {
|
||||
console.log('🔌 Relay Status Indicator:', {
|
||||
mode: isConnecting ? 'CONNECTING' : offlineMode ? 'OFFLINE' : localOnlyMode ? 'LOCAL_ONLY' : 'ONLINE',
|
||||
totalStatuses: relayStatuses.length,
|
||||
connectedCount: connectedUrls.length,
|
||||
connectedUrls: connectedUrls.map(u => u.replace(/^wss?:\/\//, '')),
|
||||
hasLocalRelay,
|
||||
hasRemoteRelay,
|
||||
isConnecting
|
||||
})
|
||||
}, [offlineMode, localOnlyMode, connectedUrls, relayStatuses.length, hasLocalRelay, hasRemoteRelay, isConnecting])
|
||||
|
||||
// Don't show indicator when fully connected (but show when connecting)
|
||||
if (!localOnlyMode && !offlineMode && !isConnecting) return null
|
||||
|
||||
const handleClick = () => {
|
||||
if (isMobile) {
|
||||
setIsExpanded(!isExpanded)
|
||||
}
|
||||
}
|
||||
|
||||
const showDetails = !isMobile || isExpanded
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relay-status-indicator ${isConnecting ? 'connecting' : ''} ${isMobile ? 'mobile' : ''} ${isExpanded ? 'expanded' : ''}`}
|
||||
title={
|
||||
!isMobile ? (
|
||||
isConnecting
|
||||
? 'Connecting to relays...'
|
||||
: offlineMode
|
||||
? 'Offline - No relays connected'
|
||||
: 'Local Relays Only - Highlights will be marked as local'
|
||||
) : undefined
|
||||
}
|
||||
onClick={handleClick}
|
||||
style={{ cursor: isMobile ? 'pointer' : 'default' }}
|
||||
>
|
||||
<div className="relay-status-icon">
|
||||
<FontAwesomeIcon icon={isConnecting ? faSpinner : offlineMode ? faCircle : faPlane} spin={isConnecting} />
|
||||
</div>
|
||||
{showDetails && (
|
||||
<>
|
||||
<div className="relay-status-text">
|
||||
{isConnecting ? (
|
||||
<span className="relay-status-title">Connecting</span>
|
||||
) : offlineMode ? (
|
||||
<>
|
||||
<span className="relay-status-title">Offline</span>
|
||||
<span className="relay-status-subtitle">No relays connected</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="relay-status-title">Flight Mode</span>
|
||||
<span className="relay-status-subtitle">{connectedUrls.length} local relay{connectedUrls.length !== 1 ? 's' : ''}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!offlineMode && !isConnecting && (
|
||||
<div className="relay-status-pulse">
|
||||
<FontAwesomeIcon icon={faGlobe} className="pulse-icon" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
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 { RelayPool } from 'applesauce-relay'
|
||||
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'
|
||||
import OfflineModeSettings from './Settings/OfflineModeSettings'
|
||||
import RelaySettings from './Settings/RelaySettings'
|
||||
import PWASettings from './Settings/PWASettings'
|
||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||
|
||||
const DEFAULT_SETTINGS: UserSettings = {
|
||||
collapseOnArticleOpen: true,
|
||||
@@ -14,7 +20,7 @@ const DEFAULT_SETTINGS: UserSettings = {
|
||||
sidebarCollapsed: true,
|
||||
highlightsCollapsed: true,
|
||||
readingFont: 'source-serif-4',
|
||||
fontSize: 18,
|
||||
fontSize: 21,
|
||||
highlightStyle: 'marker',
|
||||
highlightColor: '#ffff00',
|
||||
highlightColorNostrverse: '#9333ea',
|
||||
@@ -23,20 +29,57 @@ const DEFAULT_SETTINGS: UserSettings = {
|
||||
defaultHighlightVisibilityNostrverse: true,
|
||||
defaultHighlightVisibilityFriends: true,
|
||||
defaultHighlightVisibilityMine: true,
|
||||
zapSplitHighlighterWeight: 50,
|
||||
zapSplitBorisWeight: 2.1,
|
||||
zapSplitAuthorWeight: 50,
|
||||
useLocalRelayAsCache: true,
|
||||
rebroadcastToAllRelays: false,
|
||||
}
|
||||
|
||||
interface SettingsProps {
|
||||
settings: UserSettings
|
||||
onSave: (settings: UserSettings) => Promise<void>
|
||||
onClose: () => void
|
||||
relayPool: RelayPool | null
|
||||
}
|
||||
|
||||
const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
const [localSettings, setLocalSettings] = useState<UserSettings>(settings)
|
||||
const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPool }) => {
|
||||
const [localSettings, setLocalSettings] = useState<UserSettings>(() => {
|
||||
// Migrate old settings format to new weight-based format
|
||||
const migrated = { ...settings }
|
||||
const anySettings = migrated as Record<string, unknown>
|
||||
if ('zapSplitPercentage' in anySettings && !('zapSplitHighlighterWeight' in migrated)) {
|
||||
migrated.zapSplitHighlighterWeight = (anySettings.zapSplitPercentage as number) ?? 50
|
||||
migrated.zapSplitAuthorWeight = 100 - ((anySettings.zapSplitPercentage as number) ?? 50)
|
||||
}
|
||||
if ('borisSupportPercentage' in anySettings && !('zapSplitBorisWeight' in migrated)) {
|
||||
migrated.zapSplitBorisWeight = (anySettings.borisSupportPercentage as number) ?? 2.1
|
||||
}
|
||||
return migrated
|
||||
})
|
||||
const isInitialMount = useRef(true)
|
||||
const saveTimeoutRef = useRef<number | null>(null)
|
||||
const isLocallyUpdating = useRef(false)
|
||||
|
||||
// Poll for relay status updates
|
||||
const relayStatuses = useRelayStatus({ relayPool })
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSettings(settings)
|
||||
// Don't update from external settings if we're currently making local changes
|
||||
if (isLocallyUpdating.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const migrated = { ...settings }
|
||||
const anySettings = migrated as Record<string, unknown>
|
||||
if ('zapSplitPercentage' in anySettings && !('zapSplitHighlighterWeight' in migrated)) {
|
||||
migrated.zapSplitHighlighterWeight = (anySettings.zapSplitPercentage as number) ?? 50
|
||||
migrated.zapSplitAuthorWeight = 100 - ((anySettings.zapSplitPercentage as number) ?? 50)
|
||||
}
|
||||
if ('borisSupportPercentage' in anySettings && !('zapSplitBorisWeight' in migrated)) {
|
||||
migrated.zapSplitBorisWeight = (anySettings.borisSupportPercentage as number) ?? 2.1
|
||||
}
|
||||
setLocalSettings(migrated)
|
||||
}, [settings])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -48,29 +91,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 +159,13 @@ 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} />
|
||||
<OfflineModeSettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
|
||||
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
|
||||
<PWASettings />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
60
src/components/Settings/LayoutNavigationSettings.tsx
Normal 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 Bookmark View</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
|
||||
|
||||
173
src/components/Settings/OfflineModeSettings.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { faTrash } from '@fortawesome/free-solid-svg-icons'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
import { getImageCacheStatsAsync, clearImageCache } from '../../services/imageCacheService'
|
||||
import IconButton from '../IconButton'
|
||||
|
||||
interface OfflineModeSettingsProps {
|
||||
settings: UserSettings
|
||||
onUpdate: (updates: Partial<UserSettings>) => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const OfflineModeSettings: React.FC<OfflineModeSettingsProps> = ({ settings, onUpdate, onClose }) => {
|
||||
const navigate = useNavigate()
|
||||
const [cacheStats, setCacheStats] = useState<{
|
||||
totalSizeMB: number
|
||||
itemCount: number
|
||||
items: Array<{ url: string, sizeMB: number }>
|
||||
}>({ totalSizeMB: 0, itemCount: 0, items: [] })
|
||||
|
||||
const handleLinkClick = (url: string) => {
|
||||
if (onClose) onClose()
|
||||
navigate(`/r/${encodeURIComponent(url)}`)
|
||||
}
|
||||
|
||||
const handleClearCache = async () => {
|
||||
if (confirm('Are you sure you want to clear all cached images?')) {
|
||||
await clearImageCache()
|
||||
const stats = await getImageCacheStatsAsync()
|
||||
setCacheStats(stats)
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache stats periodically
|
||||
useEffect(() => {
|
||||
const updateStats = async () => {
|
||||
const stats = await getImageCacheStatsAsync()
|
||||
setCacheStats(stats)
|
||||
}
|
||||
|
||||
updateStats() // Initial load
|
||||
const interval = setInterval(updateStats, 3000) // Update every 3 seconds
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Flight Mode</h3>
|
||||
|
||||
<div className="setting-group" style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<label htmlFor="enableImageCache" className="checkbox-label" style={{ marginBottom: 0 }}>
|
||||
<input
|
||||
id="enableImageCache"
|
||||
type="checkbox"
|
||||
checked={settings.enableImageCache ?? true}
|
||||
onChange={(e) => onUpdate({ enableImageCache: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Use local image cache</span>
|
||||
</label>
|
||||
|
||||
{(settings.enableImageCache ?? true) && (
|
||||
<div style={{
|
||||
fontSize: '0.85rem',
|
||||
color: 'var(--text-secondary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem'
|
||||
}}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
( {cacheStats.totalSizeMB.toFixed(1)} MB /
|
||||
<input
|
||||
id="imageCacheSizeMB"
|
||||
type="number"
|
||||
min="10"
|
||||
max="500"
|
||||
value={settings.imageCacheSizeMB ?? 210}
|
||||
onChange={(e) => onUpdate({ imageCacheSizeMB: parseInt(e.target.value) || 210 })}
|
||||
style={{
|
||||
width: '50px',
|
||||
padding: '0.15rem 0.35rem',
|
||||
background: 'var(--surface-secondary)',
|
||||
border: '1px solid var(--border-color, #333)',
|
||||
borderRadius: '4px',
|
||||
color: 'inherit',
|
||||
fontSize: 'inherit',
|
||||
fontFamily: 'inherit',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
/>
|
||||
MB used )
|
||||
</span>
|
||||
<IconButton
|
||||
icon={faTrash}
|
||||
onClick={handleClearCache}
|
||||
title="Clear cache"
|
||||
variant="ghost"
|
||||
size={28}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="useLocalRelayAsCache" className="checkbox-label">
|
||||
<input
|
||||
id="useLocalRelayAsCache"
|
||||
type="checkbox"
|
||||
checked={settings.useLocalRelayAsCache ?? true}
|
||||
onChange={(e) => onUpdate({ useLocalRelayAsCache: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Use local relays as cache</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginTop: '1.5rem',
|
||||
padding: '1rem',
|
||||
background: 'var(--surface-secondary)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.9rem',
|
||||
lineHeight: '1.6'
|
||||
}}>
|
||||
<p style={{ margin: 0, color: 'var(--text-secondary)' }}>
|
||||
Boris works best with a local relay. Consider running{' '}
|
||||
<a
|
||||
href="https://github.com/greenart7c3/Citrine?tab=readme-ov-file#download"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--accent, #8b5cf6)' }}
|
||||
>
|
||||
Citrine
|
||||
</a>
|
||||
{' or '}
|
||||
<a
|
||||
href="https://github.com/CodyTseng/nostr-relay-tray/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--accent, #8b5cf6)' }}
|
||||
>
|
||||
nostr-relay-tray
|
||||
</a>
|
||||
. Don't know what relays are? Learn more{' '}
|
||||
<a
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleLinkClick('https://nostr.how/en/relays')
|
||||
}}
|
||||
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
|
||||
>
|
||||
here
|
||||
</a>
|
||||
{' and '}
|
||||
<a
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleLinkClick('https://davidebtc186.substack.com/p/the-importance-of-hosting-your-own')
|
||||
}}
|
||||
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
|
||||
>
|
||||
here
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OfflineModeSettings
|
||||
|
||||
84
src/components/Settings/PWASettings.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React from 'react'
|
||||
import { faDownload, faCheckCircle, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { usePWAInstall } from '../../hooks/usePWAInstall'
|
||||
|
||||
const PWASettings: React.FC = () => {
|
||||
const { isInstallable, isInstalled, installApp } = usePWAInstall()
|
||||
|
||||
const handleInstall = async () => {
|
||||
const success = await installApp()
|
||||
if (success) {
|
||||
console.log('App installed successfully')
|
||||
}
|
||||
}
|
||||
|
||||
if (isInstalled) {
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3>Progressive Web App</h3>
|
||||
<div className="setting-item">
|
||||
<div className="setting-info">
|
||||
<FontAwesomeIcon icon={faCheckCircle} style={{ color: '#22c55e', marginRight: '8px' }} />
|
||||
<span>Boris is installed as an app</span>
|
||||
</div>
|
||||
<p className="setting-description">
|
||||
You can launch Boris from your home screen or app drawer.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isInstallable) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3>Progressive Web App</h3>
|
||||
<div className="setting-item">
|
||||
<div className="setting-info">
|
||||
<FontAwesomeIcon icon={faMobileAlt} style={{ marginRight: '8px' }} />
|
||||
<span>Install Boris as an app</span>
|
||||
</div>
|
||||
<p className="setting-description">
|
||||
Install Boris on your device for a native app experience with offline support.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="install-button"
|
||||
style={{
|
||||
marginTop: '12px',
|
||||
padding: '8px 16px',
|
||||
background: 'linear-gradient(135deg, #3b82f6 0%, #1e40af 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(59, 130, 246, 0.3)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
e.currentTarget.style.boxShadow = 'none'
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faDownload} />
|
||||
Install App
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PWASettings
|
||||
|
||||
170
src/components/Settings/ReadingDisplaySettings.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHighlighter, faUnderline, faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
import IconButton from '../IconButton'
|
||||
import ColorPicker from '../ColorPicker'
|
||||
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 style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<div className="setting-group setting-inline" style={{ flex: '1 1 auto', minWidth: '200px' }}>
|
||||
<label htmlFor="readingFont">Reading Font</label>
|
||||
<div className="setting-control">
|
||||
<FontSelector
|
||||
value={settings.readingFont || 'source-serif-4'}
|
||||
onChange={(font) => onUpdate({ readingFont: font })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline" style={{ flex: '0 1 auto' }}>
|
||||
<label>Font Size</label>
|
||||
<div className="setting-buttons">
|
||||
{[16, 18, 21, 24, 28, 32].map(size => (
|
||||
<button
|
||||
key={size}
|
||||
onClick={() => onUpdate({ fontSize: size })}
|
||||
className={`font-size-btn ${(settings.fontSize || 21) === size ? 'active' : ''}`}
|
||||
title={`${size}px`}
|
||||
style={{ fontSize: `${size - 2}px` }}
|
||||
>
|
||||
A
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<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-group setting-inline">
|
||||
<label>Default Highlight Visibility</label>
|
||||
<div className="highlight-level-toggles">
|
||||
<button
|
||||
onClick={() => onUpdate({ defaultHighlightVisibilityNostrverse: !(settings.defaultHighlightVisibilityNostrverse !== false) })}
|
||||
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityNostrverse !== false) ? 'active' : ''}`}
|
||||
title="Nostrverse highlights"
|
||||
aria-label="Toggle nostrverse highlights by default"
|
||||
style={{ color: (settings.defaultHighlightVisibilityNostrverse !== false) ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faNetworkWired} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onUpdate({ defaultHighlightVisibilityFriends: !(settings.defaultHighlightVisibilityFriends !== false) })}
|
||||
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityFriends !== false) ? 'active' : ''}`}
|
||||
title="Friends highlights"
|
||||
aria-label="Toggle friends highlights by default"
|
||||
style={{ color: (settings.defaultHighlightVisibilityFriends !== false) ? 'var(--highlight-color-friends, #f97316)' : undefined }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUserGroup} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onUpdate({ defaultHighlightVisibilityMine: !(settings.defaultHighlightVisibilityMine !== false) })}
|
||||
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityMine !== false) ? 'active' : ''}`}
|
||||
title="My highlights"
|
||||
aria-label="Toggle my highlights by default"
|
||||
style={{ color: (settings.defaultHighlightVisibilityMine !== false) ? 'var(--highlight-color-mine, #eab308)' : undefined }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUser} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="showHighlights" className="checkbox-label">
|
||||
<input
|
||||
id="showHighlights"
|
||||
type="checkbox"
|
||||
checked={settings.showHighlights !== false}
|
||||
onChange={(e) => onUpdate({ showHighlights: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Show highlights</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-preview">
|
||||
<div className="preview-label">Preview</div>
|
||||
<div
|
||||
className="preview-content"
|
||||
style={{
|
||||
fontFamily: previewFontFamily,
|
||||
fontSize: `${settings.fontSize || 21}px`,
|
||||
'--highlight-rgb': hexToRgb(settings.highlightColor || '#ffff00')
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<h3>The Quick Brown Fox</h3>
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityMine !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-mine` : ""}>Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</span> Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
|
||||
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityFriends !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-friends` : ""}>Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</span> Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.</p>
|
||||
<p>Totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityNostrverse !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-nostrverse` : ""}>Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</span> Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit.</p>
|
||||
<p>Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReadingDisplaySettings
|
||||
|
||||
143
src/components/Settings/RelaySettings.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faCheckCircle, faWifi, faClock, faPlane } from '@fortawesome/free-solid-svg-icons'
|
||||
import { RelayStatus } from '../../services/relayStatusService'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { isLocalRelay } from '../../utils/helpers'
|
||||
|
||||
interface RelaySettingsProps {
|
||||
relayStatuses: RelayStatus[]
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const RelaySettings: React.FC<RelaySettingsProps> = ({ relayStatuses }) => {
|
||||
const formatRelayUrl = (url: string) => {
|
||||
return url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
const formatLastSeen = (timestamp: number) => {
|
||||
try {
|
||||
return formatDistanceToNow(timestamp, { addSuffix: true })
|
||||
} catch {
|
||||
return 'just now'
|
||||
}
|
||||
}
|
||||
|
||||
// Sort relays: local relays first, then by connection status, then by URL
|
||||
const sortedRelays = [...relayStatuses].sort((a, b) => {
|
||||
const aIsLocal = isLocalRelay(a.url)
|
||||
const bIsLocal = isLocalRelay(b.url)
|
||||
|
||||
// Local relays always first
|
||||
if (aIsLocal && !bIsLocal) return -1
|
||||
if (!aIsLocal && bIsLocal) return 1
|
||||
|
||||
// Within local or remote groups, connected before disconnected
|
||||
if (a.isInPool !== b.isInPool) return a.isInPool ? -1 : 1
|
||||
|
||||
// Finally sort by URL
|
||||
return a.url.localeCompare(b.url)
|
||||
})
|
||||
|
||||
const getRelayIcon = (relay: RelayStatus) => {
|
||||
const isLocal = isLocalRelay(relay.url)
|
||||
const isConnected = relay.isInPool
|
||||
|
||||
if (isLocal) {
|
||||
return {
|
||||
icon: faPlane,
|
||||
color: isConnected ? '#22c55e' : '#ef4444',
|
||||
size: '1rem'
|
||||
}
|
||||
} else {
|
||||
if (isConnected) {
|
||||
return {
|
||||
icon: faCheckCircle,
|
||||
color: '#22c55e',
|
||||
size: '1rem'
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
icon: faWifi,
|
||||
color: '#ef4444',
|
||||
size: '1rem'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Relays</h3>
|
||||
|
||||
{sortedRelays.length > 0 && (
|
||||
<div className="relay-group">
|
||||
<div className="relay-list">
|
||||
{sortedRelays.map((relay) => {
|
||||
const iconConfig = getRelayIcon(relay)
|
||||
const isDisconnected = !relay.isInPool
|
||||
|
||||
return (
|
||||
<div
|
||||
key={relay.url}
|
||||
className="relay-item"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
padding: '0.75rem',
|
||||
background: 'var(--surface-secondary)',
|
||||
borderRadius: '6px',
|
||||
marginBottom: '0.5rem',
|
||||
opacity: isDisconnected ? 0.7 : 1
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={iconConfig.icon}
|
||||
style={{
|
||||
color: iconConfig.color,
|
||||
fontSize: iconConfig.size
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: '0.9rem',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}>
|
||||
{formatRelayUrl(relay.url)}
|
||||
</div>
|
||||
</div>
|
||||
{isDisconnected && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
fontSize: '0.8rem',
|
||||
color: 'var(--text-tertiary)',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
<FontAwesomeIcon icon={faClock} />
|
||||
{formatLastSeen(relay.lastSeen)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{relayStatuses.length === 0 && (
|
||||
<p style={{ color: 'var(--text-secondary)', fontStyle: 'italic' }}>
|
||||
No relay connections found
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RelaySettings
|
||||
|
||||
70
src/components/Settings/StartupPreferencesSettings.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React from 'react'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
|
||||
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 & Behavior</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">
|
||||
<label htmlFor="rebroadcastToAllRelays" className="checkbox-label">
|
||||
<input
|
||||
id="rebroadcastToAllRelays"
|
||||
type="checkbox"
|
||||
checked={settings.rebroadcastToAllRelays ?? false}
|
||||
onChange={(e) => onUpdate({ rebroadcastToAllRelays: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Rebroadcast events while browsing</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="autoCollapseSidebarOnMobile" className="checkbox-label">
|
||||
<input
|
||||
id="autoCollapseSidebarOnMobile"
|
||||
type="checkbox"
|
||||
checked={settings.autoCollapseSidebarOnMobile !== false}
|
||||
onChange={(e) => onUpdate({ autoCollapseSidebarOnMobile: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Auto-collapse sidebar on small screens</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StartupPreferencesSettings
|
||||
|
||||
143
src/components/Settings/ZapSettings.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import React from 'react'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
|
||||
interface ZapSettingsProps {
|
||||
settings: UserSettings
|
||||
onUpdate: (updates: Partial<UserSettings>) => void
|
||||
}
|
||||
|
||||
const ZapSettings: React.FC<ZapSettingsProps> = ({ settings, onUpdate }) => {
|
||||
const highlighterWeight = settings.zapSplitHighlighterWeight ?? 50
|
||||
const borisWeight = settings.zapSplitBorisWeight ?? 2.1
|
||||
const authorWeight = settings.zapSplitAuthorWeight ?? 50
|
||||
|
||||
// Calculate actual percentages from weights
|
||||
const totalWeight = highlighterWeight + borisWeight + authorWeight
|
||||
const highlighterPercentage = totalWeight > 0 ? (highlighterWeight / totalWeight) * 100 : 0
|
||||
const borisPercentage = totalWeight > 0 ? (borisWeight / totalWeight) * 100 : 0
|
||||
const authorPercentage = totalWeight > 0 ? (authorWeight / totalWeight) * 100 : 0
|
||||
|
||||
const presets = {
|
||||
default: { highlighter: 50, boris: 2.1, author: 50 },
|
||||
generous: { highlighter: 5, boris: 10, author: 75 },
|
||||
selfless: { highlighter: 1, boris: 19, author: 80 },
|
||||
boris: { highlighter: 10, boris: 80, author: 10 },
|
||||
}
|
||||
|
||||
const isPresetActive = (preset: { highlighter: number; boris: number; author: number }) => {
|
||||
return highlighterWeight === preset.highlighter &&
|
||||
borisWeight === preset.boris &&
|
||||
authorWeight === preset.author
|
||||
}
|
||||
|
||||
const applyPreset = (preset: { highlighter: number; boris: number; author: number }) => {
|
||||
onUpdate({
|
||||
zapSplitHighlighterWeight: preset.highlighter,
|
||||
zapSplitBorisWeight: preset.boris,
|
||||
zapSplitAuthorWeight: preset.author,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Zap Splits</h3>
|
||||
|
||||
<div className="setting-group">
|
||||
<label className="setting-label">Presets</label>
|
||||
<div className="zap-preset-buttons">
|
||||
<button
|
||||
onClick={() => applyPreset(presets.default)}
|
||||
className={`zap-preset-btn ${isPresetActive(presets.default) ? 'active' : ''}`}
|
||||
title="You: 49%, Author: 49%, Boris: 2%"
|
||||
>
|
||||
Default
|
||||
</button>
|
||||
<button
|
||||
onClick={() => applyPreset(presets.generous)}
|
||||
className={`zap-preset-btn ${isPresetActive(presets.generous) ? 'active' : ''}`}
|
||||
title="You: 6%, Author: 83%, Boris: 11%"
|
||||
>
|
||||
Generous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => applyPreset(presets.selfless)}
|
||||
className={`zap-preset-btn ${isPresetActive(presets.selfless) ? 'active' : ''}`}
|
||||
title="You: 1%, Author: 80%, Boris: 19%"
|
||||
>
|
||||
Selfless
|
||||
</button>
|
||||
<button
|
||||
onClick={() => applyPreset(presets.boris)}
|
||||
className={`zap-preset-btn ${isPresetActive(presets.boris) ? 'active' : ''}`}
|
||||
title="You: 10%, Author: 10%, Boris: 80%"
|
||||
>
|
||||
Boris 🧡
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label className="setting-label">Your Share</label>
|
||||
<div className="zap-split-container">
|
||||
<div className="zap-split-labels">
|
||||
<span className="zap-split-label">Weight: {highlighterWeight}</span>
|
||||
<span className="zap-split-label">({highlighterPercentage.toFixed(1)}%)</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={highlighterWeight}
|
||||
onChange={(e) => onUpdate({ zapSplitHighlighterWeight: parseInt(e.target.value) })}
|
||||
className="zap-split-slider"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label className="setting-label">Author(s) Share</label>
|
||||
<div className="zap-split-container">
|
||||
<div className="zap-split-labels">
|
||||
<span className="zap-split-label">Weight: {authorWeight}</span>
|
||||
<span className="zap-split-label">({authorPercentage.toFixed(1)}%)</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={authorWeight}
|
||||
onChange={(e) => onUpdate({ zapSplitAuthorWeight: parseInt(e.target.value) })}
|
||||
className="zap-split-slider"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label className="setting-label">Support Boris</label>
|
||||
<div className="zap-split-container">
|
||||
<div className="zap-split-labels">
|
||||
<span className="zap-split-label">Weight: {borisWeight.toFixed(1)}</span>
|
||||
<span className="zap-split-label">({borisPercentage.toFixed(1)}%)</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="10"
|
||||
step="0.1"
|
||||
value={borisWeight}
|
||||
onChange={(e) => onUpdate({ zapSplitBorisWeight: parseFloat(e.target.value) })}
|
||||
className="zap-split-slider"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="zap-split-description">
|
||||
Weights determine zap splits when highlighting nostr-native content.
|
||||
If the content has multiple authors, their share is divided proportionally.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ZapSettings
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faRotate } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faHome, faPlus, faNewspaper, faTimes } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
import { Accounts } from 'applesauce-accounts'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import IconButton from './IconButton'
|
||||
import AddBookmarkModal from './AddBookmarkModal'
|
||||
import { createWebBookmark } from '../services/webBookmarkService'
|
||||
import { RELAYS } from '../config/relays'
|
||||
|
||||
interface SidebarHeaderProps {
|
||||
onToggleCollapse: () => void
|
||||
onLogout: () => void
|
||||
onOpenSettings: () => void
|
||||
onRefresh?: () => void
|
||||
isRefreshing?: boolean
|
||||
relayPool: RelayPool | null
|
||||
isMobile?: boolean
|
||||
}
|
||||
|
||||
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, onRefresh, isRefreshing }) => {
|
||||
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, relayPool, isMobile = false }) => {
|
||||
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)
|
||||
@@ -29,7 +36,7 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
accountManager.setActive(account)
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error)
|
||||
alert('Login failed. Please install a nostr browser extension and try again.')
|
||||
alert('Login failed. Please install a nostr browser extension and try again.\n\nIf you aren\'t on nostr yet, start here: https://nstart.me/')
|
||||
} finally {
|
||||
setIsConnecting(false)
|
||||
}
|
||||
@@ -47,43 +54,48 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
return `${activeAccount.pubkey.slice(0, 8)}...${activeAccount.pubkey.slice(-8)}`
|
||||
}
|
||||
|
||||
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
|
||||
if (!activeAccount || !relayPool) {
|
||||
throw new Error('Please login to create bookmarks')
|
||||
}
|
||||
|
||||
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
|
||||
}
|
||||
|
||||
const profileImage = getProfileImage()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sidebar-header-bar">
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className="toggle-sidebar-btn"
|
||||
title="Collapse bookmarks sidebar"
|
||||
aria-label="Collapse bookmarks sidebar"
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</button>
|
||||
<div className="sidebar-header-right">
|
||||
{onRefresh && (
|
||||
{isMobile ? (
|
||||
<IconButton
|
||||
icon={faRotate}
|
||||
onClick={onRefresh}
|
||||
title="Refresh bookmarks"
|
||||
ariaLabel="Refresh bookmarks"
|
||||
icon={faTimes}
|
||||
onClick={onToggleCollapse}
|
||||
title="Close sidebar"
|
||||
ariaLabel="Close sidebar"
|
||||
variant="ghost"
|
||||
disabled={isRefreshing}
|
||||
spin={isRefreshing}
|
||||
className="mobile-close-btn"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className="toggle-sidebar-btn"
|
||||
title="Collapse bookmarks sidebar"
|
||||
aria-label="Collapse bookmarks sidebar"
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</button>
|
||||
)}
|
||||
<IconButton
|
||||
icon={faGear}
|
||||
onClick={onOpenSettings}
|
||||
title="Settings"
|
||||
ariaLabel="Settings"
|
||||
variant="ghost"
|
||||
/>
|
||||
<div className="sidebar-header-right">
|
||||
<div
|
||||
className="profile-avatar"
|
||||
title={activeAccount ? getUserDisplayName() : "Login"}
|
||||
onClick={!activeAccount ? (isConnecting ? () => {} : handleLogin) : undefined}
|
||||
style={{ cursor: !activeAccount ? 'pointer' : 'default' }}
|
||||
onClick={
|
||||
activeAccount
|
||||
? () => navigate('/me')
|
||||
: (isConnecting ? () => {} : handleLogin)
|
||||
}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{profileImage ? (
|
||||
<img src={profileImage} alt={getUserDisplayName()} />
|
||||
@@ -91,6 +103,36 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
<FontAwesomeIcon icon={faUserCircle} />
|
||||
)}
|
||||
</div>
|
||||
<IconButton
|
||||
icon={faHome}
|
||||
onClick={() => navigate('/')}
|
||||
title="Home"
|
||||
ariaLabel="Home"
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
icon={faNewspaper}
|
||||
onClick={() => navigate('/explore')}
|
||||
title="Explore"
|
||||
ariaLabel="Explore"
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
icon={faGear}
|
||||
onClick={onOpenSettings}
|
||||
title="Settings"
|
||||
ariaLabel="Settings"
|
||||
variant="ghost"
|
||||
/>
|
||||
{activeAccount && (
|
||||
<IconButton
|
||||
icon={faPlus}
|
||||
onClick={() => setShowAddModal(true)}
|
||||
title="Add bookmark"
|
||||
ariaLabel="Add bookmark"
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
{activeAccount ? (
|
||||
<IconButton
|
||||
icon={faRightFromBracket}
|
||||
@@ -110,6 +152,12 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showAddModal && (
|
||||
<AddBookmarkModal
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onSave={handleSaveBookmark}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
377
src/components/ThreePaneLayout.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faBookmark, faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { BookmarkList } from './BookmarkList'
|
||||
import ContentPanel from './ContentPanel'
|
||||
import { HighlightsPanel } from './HighlightsPanel'
|
||||
import Settings from './Settings'
|
||||
import Toast from './Toast'
|
||||
import { HighlightButton } from './HighlightButton'
|
||||
import { RelayStatusIndicator } from './RelayStatusIndicator'
|
||||
import { ViewMode } from './Bookmarks'
|
||||
import { Bookmark } from '../types/bookmarks'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { ReadableContent } from '../services/readerService'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import { HighlightVisibility } from './HighlightsPanel'
|
||||
import { HighlightButtonRef } from './HighlightButton'
|
||||
import { BookmarkReference } from '../utils/contentLoader'
|
||||
import { useIsMobile } from '../hooks/useMediaQuery'
|
||||
import { useScrollDirection } from '../hooks/useScrollDirection'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
|
||||
interface ThreePaneLayoutProps {
|
||||
// Layout state
|
||||
isCollapsed: boolean
|
||||
isHighlightsCollapsed: boolean
|
||||
isSidebarOpen: boolean
|
||||
showSettings: boolean
|
||||
showExplore?: boolean
|
||||
showMe?: boolean
|
||||
|
||||
// Bookmarks pane
|
||||
bookmarks: Bookmark[]
|
||||
bookmarksLoading: boolean
|
||||
viewMode: ViewMode
|
||||
isRefreshing: boolean
|
||||
lastFetchTime?: number | null
|
||||
onToggleSidebar: () => void
|
||||
onLogout: () => void
|
||||
onViewModeChange: (mode: ViewMode) => void
|
||||
onOpenSettings: () => void
|
||||
onRefresh: () => void
|
||||
relayPool: RelayPool | null
|
||||
eventStore: IEventStore | null
|
||||
|
||||
// Content pane
|
||||
readerLoading: boolean
|
||||
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>
|
||||
activeAccount?: IAccount | null
|
||||
currentArticle?: NostrEvent | null
|
||||
|
||||
// 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
|
||||
|
||||
// Optional Explore content
|
||||
explore?: React.ReactNode
|
||||
|
||||
// Optional Me content
|
||||
me?: React.ReactNode
|
||||
}
|
||||
|
||||
const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
const isMobile = useIsMobile()
|
||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||
const highlightsRef = useRef<HTMLDivElement>(null)
|
||||
const mainPaneRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Detect scroll direction to hide/show mobile buttons
|
||||
// On mobile, scroll happens in the main pane, not on window
|
||||
const scrollDirection = useScrollDirection({
|
||||
threshold: 10,
|
||||
enabled: isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed,
|
||||
elementRef: mainPaneRef
|
||||
})
|
||||
const showMobileButtons = scrollDirection !== 'down'
|
||||
|
||||
// Lock body scroll when mobile sidebar or highlights is open
|
||||
useEffect(() => {
|
||||
if (isMobile && (props.isSidebarOpen || !props.isHighlightsCollapsed)) {
|
||||
document.body.classList.add('mobile-sidebar-open')
|
||||
} else {
|
||||
document.body.classList.remove('mobile-sidebar-open')
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove('mobile-sidebar-open')
|
||||
}
|
||||
}, [isMobile, props.isSidebarOpen, props.isHighlightsCollapsed])
|
||||
|
||||
// Handle ESC key to close sidebar or highlights
|
||||
useEffect(() => {
|
||||
const { isSidebarOpen, isHighlightsCollapsed, onToggleSidebar, onToggleHighlightsPanel } = props
|
||||
|
||||
if (!isMobile) return
|
||||
if (!isSidebarOpen && isHighlightsCollapsed) return
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (isSidebarOpen) {
|
||||
onToggleSidebar()
|
||||
} else if (!isHighlightsCollapsed) {
|
||||
onToggleHighlightsPanel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => document.removeEventListener('keydown', handleEscape)
|
||||
}, [isMobile, props])
|
||||
|
||||
// Trap focus in sidebar when open on mobile
|
||||
useEffect(() => {
|
||||
if (!isMobile || !props.isSidebarOpen || !sidebarRef.current) return
|
||||
|
||||
const sidebar = sidebarRef.current
|
||||
const focusableElements = sidebar.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
const firstElement = focusableElements[0]
|
||||
const lastElement = focusableElements[focusableElements.length - 1]
|
||||
|
||||
const handleTab = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') return
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
e.preventDefault()
|
||||
lastElement?.focus()
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastElement) {
|
||||
e.preventDefault()
|
||||
firstElement?.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sidebar.addEventListener('keydown', handleTab)
|
||||
firstElement?.focus()
|
||||
|
||||
return () => {
|
||||
sidebar.removeEventListener('keydown', handleTab)
|
||||
}
|
||||
}, [isMobile, props.isSidebarOpen])
|
||||
|
||||
// Trap focus in highlights panel when open on mobile
|
||||
useEffect(() => {
|
||||
if (!isMobile || props.isHighlightsCollapsed || !highlightsRef.current) return
|
||||
|
||||
const highlights = highlightsRef.current
|
||||
const focusableElements = highlights.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
const firstElement = focusableElements[0]
|
||||
const lastElement = focusableElements[focusableElements.length - 1]
|
||||
|
||||
const handleTab = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') return
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
e.preventDefault()
|
||||
lastElement?.focus()
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastElement) {
|
||||
e.preventDefault()
|
||||
firstElement?.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
highlights.addEventListener('keydown', handleTab)
|
||||
firstElement?.focus()
|
||||
|
||||
return () => {
|
||||
highlights.removeEventListener('keydown', handleTab)
|
||||
}
|
||||
}, [isMobile, props.isHighlightsCollapsed])
|
||||
|
||||
const handleBackdropClick = () => {
|
||||
if (isMobile) {
|
||||
if (props.isSidebarOpen) {
|
||||
props.onToggleSidebar()
|
||||
} else if (!props.isHighlightsCollapsed) {
|
||||
props.onToggleHighlightsPanel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile bookmark button - only show when viewing article */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && (
|
||||
<button
|
||||
className={`mobile-hamburger-btn ${showMobileButtons ? 'visible' : 'hidden'}`}
|
||||
onClick={props.onToggleSidebar}
|
||||
aria-label="Open bookmarks"
|
||||
aria-expanded={props.isSidebarOpen}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBookmark} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Mobile highlights button - only show when viewing article */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && (
|
||||
<button
|
||||
className={`mobile-highlights-btn ${showMobileButtons ? 'visible' : 'hidden'}`}
|
||||
onClick={props.onToggleHighlightsPanel}
|
||||
aria-label="Open highlights"
|
||||
aria-expanded={!props.isHighlightsCollapsed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Mobile backdrop */}
|
||||
{isMobile && (
|
||||
<div
|
||||
className={`mobile-sidebar-backdrop ${(props.isSidebarOpen || !props.isHighlightsCollapsed) ? 'visible' : ''}`}
|
||||
onClick={handleBackdropClick}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`three-pane ${props.isCollapsed ? 'sidebar-collapsed' : ''} ${props.isHighlightsCollapsed ? 'highlights-collapsed' : ''}`}>
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className={`pane sidebar ${isMobile && props.isSidebarOpen ? 'mobile-open' : ''}`}
|
||||
aria-hidden={isMobile && !props.isSidebarOpen}
|
||||
>
|
||||
<BookmarkList
|
||||
bookmarks={props.bookmarks}
|
||||
onSelectUrl={props.onSelectUrl}
|
||||
isCollapsed={isMobile ? false : 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}
|
||||
lastFetchTime={props.lastFetchTime}
|
||||
loading={props.bookmarksLoading}
|
||||
relayPool={props.relayPool}
|
||||
settings={props.settings}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={mainPaneRef}
|
||||
className={`pane main ${isMobile && (props.isSidebarOpen || !props.isHighlightsCollapsed) ? 'mobile-hidden' : ''}`}
|
||||
>
|
||||
{props.showSettings ? (
|
||||
<Settings
|
||||
settings={props.settings}
|
||||
onSave={props.onSaveSettings}
|
||||
onClose={props.onCloseSettings}
|
||||
relayPool={props.relayPool}
|
||||
/>
|
||||
) : props.showExplore && props.explore ? (
|
||||
// Render Explore inside the main pane to keep side panels
|
||||
<>
|
||||
{props.explore}
|
||||
</>
|
||||
) : props.showMe && props.me ? (
|
||||
// Render Me inside the main pane to keep side panels
|
||||
<>
|
||||
{props.me}
|
||||
</>
|
||||
) : (
|
||||
<ContentPanel
|
||||
loading={props.readerLoading}
|
||||
title={props.readerContent?.title}
|
||||
html={props.readerContent?.html}
|
||||
markdown={props.readerContent?.markdown}
|
||||
image={props.readerContent?.image}
|
||||
summary={props.readerContent?.summary}
|
||||
published={props.readerContent?.published}
|
||||
selectedUrl={props.selectedUrl}
|
||||
highlights={props.classifiedHighlights}
|
||||
showHighlights={props.showHighlights}
|
||||
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}
|
||||
settings={props.settings}
|
||||
relayPool={props.relayPool}
|
||||
activeAccount={props.activeAccount}
|
||||
currentArticle={props.currentArticle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
ref={highlightsRef}
|
||||
className={`pane highlights ${isMobile && !props.isHighlightsCollapsed ? 'mobile-open' : ''}`}
|
||||
aria-hidden={isMobile && props.isHighlightsCollapsed}
|
||||
>
|
||||
<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}
|
||||
relayPool={props.relayPool}
|
||||
eventStore={props.eventStore}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{props.hasActiveAccount && (
|
||||
<HighlightButton
|
||||
ref={props.highlightButtonRef}
|
||||
onHighlight={props.onCreateHighlight}
|
||||
highlightColor={props.settings.highlightColor || '#ffff00'}
|
||||
/>
|
||||
)}
|
||||
<RelayStatusIndicator relayPool={props.relayPool} />
|
||||
{props.toastMessage && (
|
||||
<Toast
|
||||
message={props.toastMessage}
|
||||
type={props.toastType}
|
||||
onClose={props.onClearToast}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ThreePaneLayout
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
* Single set of relays used throughout the application
|
||||
*/
|
||||
|
||||
// All relays including local relay
|
||||
// All relays including local relays
|
||||
export const RELAYS = [
|
||||
'ws://localhost:10547',
|
||||
'ws://localhost:4869',
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.nostr.band',
|
||||
|
||||
@@ -5,6 +5,7 @@ import { fetchHighlightsForArticle } from '../services/highlightService'
|
||||
import { ReadableContent } from '../services/readerService'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
|
||||
interface UseArticleLoaderProps {
|
||||
naddr: string | undefined
|
||||
@@ -18,6 +19,7 @@ interface UseArticleLoaderProps {
|
||||
setCurrentArticleCoordinate: (coord: string | undefined) => void
|
||||
setCurrentArticleEventId: (id: string | undefined) => void
|
||||
setCurrentArticle?: (article: NostrEvent) => void
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
export function useArticleLoader({
|
||||
@@ -31,7 +33,8 @@ export function useArticleLoader({
|
||||
setHighlightsLoading,
|
||||
setCurrentArticleCoordinate,
|
||||
setCurrentArticleEventId,
|
||||
setCurrentArticle
|
||||
setCurrentArticle,
|
||||
settings
|
||||
}: UseArticleLoaderProps) {
|
||||
useEffect(() => {
|
||||
if (!relayPool || !naddr) return
|
||||
@@ -44,11 +47,13 @@ export function useArticleLoader({
|
||||
// Keep highlights panel collapsed by default - only open on user interaction
|
||||
|
||||
try {
|
||||
const article = await fetchArticleByNaddr(relayPool, naddr)
|
||||
const article = await fetchArticleByNaddr(relayPool, naddr, false, settings)
|
||||
setReaderContent({
|
||||
title: article.title,
|
||||
markdown: article.markdown,
|
||||
image: article.image,
|
||||
summary: article.summary,
|
||||
published: article.published,
|
||||
url: `nostr:${naddr}`
|
||||
})
|
||||
|
||||
@@ -71,19 +76,23 @@ export function useArticleLoader({
|
||||
try {
|
||||
setHighlightsLoading(true)
|
||||
setHighlights([]) // Clear old highlights
|
||||
const highlightsList: Highlight[] = []
|
||||
const highlightsMap = new Map<string, Highlight>()
|
||||
|
||||
await fetchHighlightsForArticle(
|
||||
relayPool,
|
||||
articleCoordinate,
|
||||
article.event.id,
|
||||
(highlight) => {
|
||||
// Render each highlight immediately as it arrives
|
||||
highlightsList.push(highlight)
|
||||
setHighlights([...highlightsList].sort((a, b) => b.created_at - a.created_at))
|
||||
}
|
||||
// Deduplicate highlights by ID as they arrive
|
||||
if (!highlightsMap.has(highlight.id)) {
|
||||
highlightsMap.set(highlight.id, highlight)
|
||||
const highlightsList = Array.from(highlightsMap.values())
|
||||
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
|
||||
}
|
||||
},
|
||||
settings
|
||||
)
|
||||
console.log(`📌 Found ${highlightsList.length} highlights`)
|
||||
console.log(`📌 Found ${highlightsMap.size} highlights`)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch highlights:', err)
|
||||
} finally {
|
||||
@@ -101,5 +110,5 @@ export function useArticleLoader({
|
||||
}
|
||||
|
||||
loadArticle()
|
||||
}, [naddr, relayPool])
|
||||
}, [naddr, relayPool, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId, setCurrentArticle, settings])
|
||||
}
|
||||
|
||||
130
src/hooks/useBookmarksData.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
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'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
|
||||
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
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
export const useBookmarksData = ({
|
||||
relayPool,
|
||||
activeAccount,
|
||||
accountManager,
|
||||
naddr,
|
||||
currentArticleCoordinate,
|
||||
currentArticleEventId,
|
||||
settings
|
||||
}: 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 [lastFetchTime, setLastFetchTime] = useState<number | null>(null)
|
||||
|
||||
const handleFetchContacts = useCallback(async () => {
|
||||
if (!relayPool || !activeAccount) return
|
||||
const contacts = await fetchContacts(relayPool, activeAccount.pubkey)
|
||||
setFollowedPubkeys(contacts)
|
||||
}, [relayPool, activeAccount])
|
||||
|
||||
const handleFetchBookmarks = useCallback(async () => {
|
||||
if (!relayPool || !activeAccount) return
|
||||
setBookmarksLoading(true)
|
||||
try {
|
||||
const fullAccount = accountManager.getActive()
|
||||
await fetchBookmarks(relayPool, fullAccount || activeAccount, setBookmarks, settings)
|
||||
} finally {
|
||||
setBookmarksLoading(false)
|
||||
}
|
||||
}, [relayPool, activeAccount, accountManager, settings])
|
||||
|
||||
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))
|
||||
}
|
||||
},
|
||||
settings
|
||||
)
|
||||
console.log(`🔄 Refreshed ${highlightsMap.size} highlights for article`)
|
||||
} else if (activeAccount) {
|
||||
const fetchedHighlights = await fetchHighlights(relayPool, activeAccount.pubkey, undefined, settings)
|
||||
setHighlights(fetchedHighlights)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch highlights:', err)
|
||||
} finally {
|
||||
setHighlightsLoading(false)
|
||||
}
|
||||
}, [relayPool, activeAccount, currentArticleCoordinate, currentArticleEventId, settings])
|
||||
|
||||
const handleRefreshAll = useCallback(async () => {
|
||||
if (!relayPool || !activeAccount || isRefreshing) return
|
||||
|
||||
setIsRefreshing(true)
|
||||
try {
|
||||
await handleFetchBookmarks()
|
||||
await handleFetchHighlights()
|
||||
await handleFetchContacts()
|
||||
setLastFetchTime(Date.now())
|
||||
} 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, naddr, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
|
||||
|
||||
return {
|
||||
bookmarks,
|
||||
bookmarksLoading,
|
||||
highlights,
|
||||
setHighlights,
|
||||
highlightsLoading,
|
||||
setHighlightsLoading,
|
||||
followedPubkeys,
|
||||
isRefreshing,
|
||||
lastFetchTime,
|
||||
handleFetchBookmarks,
|
||||
handleFetchHighlights,
|
||||
handleRefreshAll
|
||||
}
|
||||
}
|
||||
|
||||
79
src/hooks/useBookmarksUI.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
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'
|
||||
import { useIsMobile } from './useMediaQuery'
|
||||
|
||||
interface UseBookmarksUIParams {
|
||||
settings: UserSettings
|
||||
}
|
||||
|
||||
export const useBookmarksUI = ({ settings }: UseBookmarksUIParams) => {
|
||||
const isMobile = useIsMobile()
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
|
||||
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 [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
|
||||
})
|
||||
|
||||
// Auto-collapse sidebar on mobile based on settings
|
||||
useEffect(() => {
|
||||
const autoCollapse = settings.autoCollapseSidebarOnMobile !== false
|
||||
if (isMobile && autoCollapse) {
|
||||
setIsSidebarOpen(false)
|
||||
} else if (!isMobile) {
|
||||
setIsSidebarOpen(true)
|
||||
}
|
||||
}, [isMobile, settings.autoCollapseSidebarOnMobile])
|
||||
|
||||
// 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])
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setIsSidebarOpen(prev => !prev)
|
||||
}
|
||||
|
||||
return {
|
||||
isMobile,
|
||||
isSidebarOpen,
|
||||
setIsSidebarOpen,
|
||||
toggleSidebar,
|
||||
isCollapsed,
|
||||
setIsCollapsed,
|
||||
isHighlightsCollapsed,
|
||||
setIsHighlightsCollapsed,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
showHighlights,
|
||||
setShowHighlights,
|
||||
selectedHighlightId,
|
||||
setSelectedHighlightId,
|
||||
currentArticleCoordinate,
|
||||
setCurrentArticleCoordinate,
|
||||
currentArticleEventId,
|
||||
setCurrentArticleEventId,
|
||||
currentArticle,
|
||||
setCurrentArticle,
|
||||
highlightVisibility,
|
||||
setHighlightVisibility
|
||||
}
|
||||
}
|
||||
|
||||
75
src/hooks/useContentSelection.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,6 @@ export function useExternalUrlLoader({
|
||||
}
|
||||
|
||||
loadExternalUrl()
|
||||
}, [url, relayPool])
|
||||
}, [url, relayPool, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId])
|
||||
}
|
||||
|
||||
|
||||
49
src/hooks/useFilteredHighlights.ts
Normal 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])
|
||||
}
|
||||
|
||||
96
src/hooks/useHighlightCreation.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { ReadableContent } from '../services/readerService'
|
||||
import { createHighlight } from '../services/highlightCreationService'
|
||||
import { HighlightButtonRef } from '../components/HighlightButton'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
|
||||
interface UseHighlightCreationParams {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
activeAccount: any
|
||||
relayPool: RelayPool | null
|
||||
eventStore: IEventStore | null
|
||||
currentArticle: NostrEvent | undefined
|
||||
selectedUrl: string | undefined
|
||||
readerContent: ReadableContent | undefined
|
||||
onHighlightCreated: (highlight: Highlight) => void
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
export const useHighlightCreation = ({
|
||||
activeAccount,
|
||||
relayPool,
|
||||
eventStore,
|
||||
currentArticle,
|
||||
selectedUrl,
|
||||
readerContent,
|
||||
onHighlightCreated,
|
||||
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 || !eventStore) {
|
||||
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
|
||||
|
||||
console.log('🎯 Creating highlight...', { text: text.substring(0, 50) + '...' })
|
||||
|
||||
const newHighlight = await createHighlight(
|
||||
text,
|
||||
source,
|
||||
activeAccount,
|
||||
relayPool,
|
||||
eventStore,
|
||||
contentForContext,
|
||||
undefined,
|
||||
settings
|
||||
)
|
||||
|
||||
console.log('✅ Highlight created successfully!', {
|
||||
id: newHighlight.id,
|
||||
isLocalOnly: newHighlight.isLocalOnly,
|
||||
isOfflineCreated: newHighlight.isOfflineCreated,
|
||||
publishedRelays: newHighlight.publishedRelays
|
||||
})
|
||||
|
||||
highlightButtonRef.current?.clearSelection()
|
||||
onHighlightCreated(newHighlight)
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to create highlight:', error)
|
||||
// Re-throw to allow parent to handle
|
||||
throw error
|
||||
}
|
||||
}, [activeAccount, relayPool, eventStore, currentArticle, selectedUrl, readerContent, onHighlightCreated, settings])
|
||||
|
||||
return {
|
||||
highlightButtonRef,
|
||||
handleTextSelection,
|
||||
handleClearSelection,
|
||||
handleCreateHighlight
|
||||
}
|
||||
}
|
||||
|
||||
81
src/hooks/useHighlightInteractions.ts
Normal 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 (works for both mouse and touch)
|
||||
const handleSelectionEnd = 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, handleSelectionEnd }
|
||||
}
|
||||
|
||||
87
src/hooks/useHighlightedContent.ts
Normal 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 }
|
||||
}
|
||||
|
||||
34
src/hooks/useImageCache.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
|
||||
/**
|
||||
* Hook to return image URL for display
|
||||
* Service Worker handles all caching transparently
|
||||
* Images are cached on first load and available offline automatically
|
||||
*
|
||||
* @param imageUrl - The URL of the image to display
|
||||
* @returns The image URL (Service Worker handles caching)
|
||||
*/
|
||||
export function useImageCache(
|
||||
imageUrl: string | undefined,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
_settings?: UserSettings
|
||||
): string | undefined {
|
||||
// Service Worker handles everything - just return the URL as-is
|
||||
return imageUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-load image to ensure it's cached by Service Worker
|
||||
* Triggers a fetch so the SW can cache it even if not visible yet
|
||||
*/
|
||||
export function useCacheImageOnLoad(
|
||||
imageUrl: string | undefined,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
_settings?: UserSettings
|
||||
): void {
|
||||
// Service Worker will cache on first fetch
|
||||
// This hook is now a no-op, kept for API compatibility
|
||||
// The browser will automatically fetch and cache images when they're used in <img> tags
|
||||
void imageUrl
|
||||
}
|
||||
|
||||
87
src/hooks/useMarkdownToHTML.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { extractNaddrUris, replaceNostrUrisInMarkdown, replaceNostrUrisInMarkdownWithTitles } from '../utils/nostrUriResolver'
|
||||
import { fetchArticleTitles } from '../services/articleTitleResolver'
|
||||
|
||||
/**
|
||||
* Hook to convert markdown to HTML using a hidden ReactMarkdown component
|
||||
* Also processes nostr: URIs in the markdown and resolves article titles
|
||||
*/
|
||||
export const useMarkdownToHTML = (
|
||||
markdown?: string,
|
||||
relayPool?: RelayPool | null
|
||||
): {
|
||||
renderedHtml: string
|
||||
previewRef: React.RefObject<HTMLDivElement>
|
||||
processedMarkdown: string
|
||||
} => {
|
||||
const previewRef = useRef<HTMLDivElement>(null)
|
||||
const [renderedHtml, setRenderedHtml] = useState<string>('')
|
||||
const [processedMarkdown, setProcessedMarkdown] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!markdown) {
|
||||
setRenderedHtml('')
|
||||
setProcessedMarkdown('')
|
||||
return
|
||||
}
|
||||
|
||||
let isCancelled = false
|
||||
|
||||
const processMarkdown = async () => {
|
||||
// Extract all naddr references
|
||||
const naddrs = extractNaddrUris(markdown)
|
||||
|
||||
let processed: string
|
||||
|
||||
if (naddrs.length > 0 && relayPool) {
|
||||
// Fetch article titles for all naddrs
|
||||
try {
|
||||
const articleTitles = await fetchArticleTitles(relayPool, naddrs)
|
||||
|
||||
if (isCancelled) return
|
||||
|
||||
// Replace nostr URIs with resolved titles
|
||||
processed = replaceNostrUrisInMarkdownWithTitles(markdown, articleTitles)
|
||||
console.log(`📚 Resolved ${articleTitles.size} article titles`)
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch article titles:', error)
|
||||
// Fall back to basic replacement
|
||||
processed = replaceNostrUrisInMarkdown(markdown)
|
||||
}
|
||||
} else {
|
||||
// No articles to resolve, use basic replacement
|
||||
processed = replaceNostrUrisInMarkdown(markdown)
|
||||
}
|
||||
|
||||
if (isCancelled) return
|
||||
|
||||
setProcessedMarkdown(processed)
|
||||
|
||||
console.log('📝 Converting markdown to HTML...')
|
||||
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
if (previewRef.current && !isCancelled) {
|
||||
const html = previewRef.current.innerHTML
|
||||
console.log('✅ Markdown converted to HTML:', html.length, 'chars')
|
||||
setRenderedHtml(html)
|
||||
} else if (!isCancelled) {
|
||||
console.warn('⚠️ markdownPreviewRef.current is null')
|
||||
}
|
||||
})
|
||||
|
||||
return () => cancelAnimationFrame(rafId)
|
||||
}
|
||||
|
||||
processMarkdown()
|
||||
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [markdown, relayPool])
|
||||
|
||||
return { renderedHtml, previewRef, processedMarkdown }
|
||||
}
|
||||
|
||||
// Removed separate useMarkdownPreviewRef; use useMarkdownToHTML to obtain previewRef
|
||||
|
||||
62
src/hooks/useMediaQuery.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* Hook to detect if a media query matches
|
||||
* @param query The media query string (e.g., '(max-width: 768px)')
|
||||
* @returns true if the media query matches, false otherwise
|
||||
*/
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(() => {
|
||||
if (typeof window === 'undefined') return false
|
||||
return window.matchMedia(query).matches
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const mediaQuery = window.matchMedia(query)
|
||||
|
||||
// Update state if the media query changes
|
||||
const handleChange = (event: MediaQueryListEvent) => {
|
||||
setMatches(event.matches)
|
||||
}
|
||||
|
||||
// Modern browsers
|
||||
if (mediaQuery.addEventListener) {
|
||||
mediaQuery.addEventListener('change', handleChange)
|
||||
return () => mediaQuery.removeEventListener('change', handleChange)
|
||||
}
|
||||
// Legacy browsers
|
||||
else {
|
||||
mediaQuery.addListener(handleChange)
|
||||
return () => mediaQuery.removeListener(handleChange)
|
||||
}
|
||||
}, [query])
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to detect if the user is on a coarse pointer device (touch)
|
||||
* @returns true if the user is using a coarse pointer (touch), false otherwise
|
||||
*/
|
||||
export function useIsCoarsePointer(): boolean {
|
||||
return useMediaQuery('(pointer: coarse)')
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to detect if the viewport is mobile-sized
|
||||
* @returns true if viewport width is <= 768px, false otherwise
|
||||
*/
|
||||
export function useIsMobile(): boolean {
|
||||
return useMediaQuery('(max-width: 768px)')
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to detect if the viewport is tablet-sized
|
||||
* @returns true if viewport width is <= 1024px, false otherwise
|
||||
*/
|
||||
export function useIsTablet(): boolean {
|
||||
return useMediaQuery('(max-width: 1024px)')
|
||||
}
|
||||
|
||||
70
src/hooks/useOfflineSync.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { syncLocalEventsToRemote } from '../services/offlineSyncService'
|
||||
import { isLocalRelay } from '../utils/helpers'
|
||||
import { RelayStatus } from '../services/relayStatusService'
|
||||
|
||||
interface UseOfflineSyncParams {
|
||||
relayPool: RelayPool | null
|
||||
account: IAccount | null
|
||||
eventStore: IEventStore | null
|
||||
relayStatuses: RelayStatus[]
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export function useOfflineSync({
|
||||
relayPool,
|
||||
account: _account,
|
||||
eventStore,
|
||||
relayStatuses,
|
||||
enabled = true
|
||||
}: UseOfflineSyncParams) {
|
||||
const previousStateRef = useRef<{
|
||||
hasRemoteRelays: boolean
|
||||
initialized: boolean
|
||||
}>({
|
||||
hasRemoteRelays: false,
|
||||
initialized: false
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !relayPool || !_account || !eventStore) return
|
||||
|
||||
const connectedRelays = relayStatuses.filter(r => r.isInPool)
|
||||
const hasRemoteRelays = connectedRelays.some(r => !isLocalRelay(r.url))
|
||||
const hasLocalRelays = connectedRelays.some(r => isLocalRelay(r.url))
|
||||
|
||||
// Skip the first check to avoid syncing on initial load
|
||||
if (!previousStateRef.current.initialized) {
|
||||
previousStateRef.current = {
|
||||
hasRemoteRelays,
|
||||
initialized: true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Detect transition: from local-only to having remote relays
|
||||
const wasLocalOnly = !previousStateRef.current.hasRemoteRelays && hasLocalRelays
|
||||
const isNowOnline = hasRemoteRelays
|
||||
|
||||
if (wasLocalOnly && isNowOnline) {
|
||||
console.log('✈️ Detected transition: Flight Mode → Online')
|
||||
console.log('📊 Relay state:', {
|
||||
connectedRelays: connectedRelays.length,
|
||||
remoteRelays: connectedRelays.filter(r => !isLocalRelay(r.url)).length,
|
||||
localRelays: connectedRelays.filter(r => isLocalRelay(r.url)).length
|
||||
})
|
||||
|
||||
// Wait a moment for relays to fully establish connections
|
||||
setTimeout(() => {
|
||||
console.log('🚀 Starting sync after delay...')
|
||||
syncLocalEventsToRemote(relayPool, eventStore)
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
previousStateRef.current.hasRemoteRelays = hasRemoteRelays
|
||||
}, [relayPool, _account, eventStore, relayStatuses, enabled])
|
||||
}
|
||||
|
||||
28
src/hooks/useOnlineStatus.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export function useOnlineStatus() {
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => {
|
||||
console.log('🌐 Back online')
|
||||
setIsOnline(true)
|
||||
}
|
||||
|
||||
const handleOffline = () => {
|
||||
console.log('📴 Gone offline')
|
||||
setIsOnline(false)
|
||||
}
|
||||
|
||||
window.addEventListener('online', handleOnline)
|
||||
window.addEventListener('offline', handleOffline)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline)
|
||||
window.removeEventListener('offline', handleOffline)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return isOnline
|
||||
}
|
||||
|
||||
74
src/hooks/usePWAInstall.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt: () => Promise<void>
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
|
||||
}
|
||||
|
||||
export function usePWAInstall() {
|
||||
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null)
|
||||
const [isInstallable, setIsInstallable] = useState(false)
|
||||
const [isInstalled, setIsInstalled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Check if app is already installed
|
||||
if (window.matchMedia('(display-mode: standalone)').matches) {
|
||||
setIsInstalled(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Listen for the beforeinstallprompt event
|
||||
const handleBeforeInstallPrompt = (e: Event) => {
|
||||
e.preventDefault()
|
||||
const installPromptEvent = e as BeforeInstallPromptEvent
|
||||
setDeferredPrompt(installPromptEvent)
|
||||
setIsInstallable(true)
|
||||
}
|
||||
|
||||
// Listen for successful installation
|
||||
const handleAppInstalled = () => {
|
||||
setIsInstalled(true)
|
||||
setIsInstallable(false)
|
||||
setDeferredPrompt(null)
|
||||
}
|
||||
|
||||
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
|
||||
window.addEventListener('appinstalled', handleAppInstalled)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
|
||||
window.removeEventListener('appinstalled', handleAppInstalled)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const installApp = async () => {
|
||||
if (!deferredPrompt) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
await deferredPrompt.prompt()
|
||||
const choiceResult = await deferredPrompt.userChoice
|
||||
|
||||
if (choiceResult.outcome === 'accepted') {
|
||||
console.log('✅ PWA installed')
|
||||
setIsInstallable(false)
|
||||
setDeferredPrompt(null)
|
||||
return true
|
||||
} else {
|
||||
console.log('❌ PWA installation dismissed')
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error installing PWA:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isInstallable,
|
||||
isInstalled,
|
||||
installApp,
|
||||
}
|
||||
}
|
||||
|
||||
37
src/hooks/useRelayStatus.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { RelayStatus, updateAndGetRelayStatuses } from '../services/relayStatusService'
|
||||
|
||||
interface UseRelayStatusParams {
|
||||
relayPool: RelayPool | null
|
||||
pollingInterval?: number // in milliseconds
|
||||
}
|
||||
|
||||
export function useRelayStatus({
|
||||
relayPool,
|
||||
pollingInterval = 20000
|
||||
}: UseRelayStatusParams) {
|
||||
const [relayStatuses, setRelayStatuses] = useState<RelayStatus[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!relayPool) return
|
||||
|
||||
const updateStatuses = () => {
|
||||
const statuses = updateAndGetRelayStatuses(relayPool)
|
||||
setRelayStatuses(statuses)
|
||||
}
|
||||
|
||||
// Initial update
|
||||
updateStatuses()
|
||||
|
||||
// Poll for updates
|
||||
const interval = setInterval(updateStatuses, pollingInterval)
|
||||
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [relayPool, pollingInterval])
|
||||
|
||||
return relayStatuses
|
||||
}
|
||||
|
||||
70
src/hooks/useScrollDirection.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useState, useEffect, RefObject } from 'react'
|
||||
|
||||
export type ScrollDirection = 'up' | 'down' | 'none'
|
||||
|
||||
interface UseScrollDirectionOptions {
|
||||
threshold?: number
|
||||
enabled?: boolean
|
||||
elementRef?: RefObject<HTMLElement>
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to detect scroll direction on window or a specific element
|
||||
* @param options Configuration options
|
||||
* @param options.threshold Minimum scroll distance to trigger direction change (default: 10)
|
||||
* @param options.enabled Whether scroll detection is enabled (default: true)
|
||||
* @param options.elementRef Optional ref to a scrollable element (uses window if not provided)
|
||||
* @returns Current scroll direction ('up', 'down', or 'none')
|
||||
*/
|
||||
export function useScrollDirection({
|
||||
threshold = 10,
|
||||
enabled = true,
|
||||
elementRef
|
||||
}: UseScrollDirectionOptions = {}): ScrollDirection {
|
||||
const [scrollDirection, setScrollDirection] = useState<ScrollDirection>('none')
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
|
||||
const scrollElement = elementRef?.current || window
|
||||
const getScrollY = () => {
|
||||
if (elementRef?.current) {
|
||||
return elementRef.current.scrollTop
|
||||
}
|
||||
return window.scrollY
|
||||
}
|
||||
|
||||
let lastScrollY = getScrollY()
|
||||
let ticking = false
|
||||
|
||||
const updateScrollDirection = () => {
|
||||
const scrollY = getScrollY()
|
||||
|
||||
// Only update if scroll distance exceeds threshold
|
||||
if (Math.abs(scrollY - lastScrollY) < threshold) {
|
||||
ticking = false
|
||||
return
|
||||
}
|
||||
|
||||
setScrollDirection(scrollY > lastScrollY ? 'down' : 'up')
|
||||
lastScrollY = scrollY > 0 ? scrollY : 0
|
||||
ticking = false
|
||||
}
|
||||
|
||||
const onScroll = () => {
|
||||
if (!ticking) {
|
||||
window.requestAnimationFrame(updateScrollDirection)
|
||||
ticking = true
|
||||
}
|
||||
}
|
||||
|
||||
scrollElement.addEventListener('scroll', onScroll)
|
||||
|
||||
return () => {
|
||||
scrollElement.removeEventListener('scroll', onScroll)
|
||||
}
|
||||
}, [threshold, enabled, elementRef])
|
||||
|
||||
return scrollDirection
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
||||
|
||||
// Apply font settings after font is loaded
|
||||
root.setProperty('--reading-font', getFontFamily(fontKey))
|
||||
root.setProperty('--reading-font-size', `${settings.fontSize || 18}px`)
|
||||
root.setProperty('--reading-font-size', `${settings.fontSize || 21}px`)
|
||||
|
||||
// Set highlight colors for three levels
|
||||
root.setProperty('--highlight-color-mine', settings.highlightColorMine || '#ffff00')
|
||||
|
||||
1534
src/index.css
36
src/main.tsx
@@ -3,6 +3,42 @@ import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
// Register Service Worker for PWA functionality
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js', { type: 'module' })
|
||||
.then(registration => {
|
||||
console.log('✅ Service Worker registered:', registration.scope)
|
||||
|
||||
// Check for updates periodically
|
||||
setInterval(() => {
|
||||
registration.update()
|
||||
}, 60 * 60 * 1000) // Check every hour
|
||||
|
||||
// Handle service worker updates
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const newWorker = registration.installing
|
||||
if (newWorker) {
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
// New service worker available
|
||||
console.log('🔄 New version available! Reload to update.')
|
||||
|
||||
// Optionally show a toast notification
|
||||
const updateAvailable = new CustomEvent('sw-update-available')
|
||||
window.dispatchEvent(updateAvailable)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('❌ Service Worker registration failed:', error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -5,6 +5,8 @@ import { AddressPointer } from 'nostr-tools/nip19'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { UserSettings } from './settingsService'
|
||||
import { rebroadcastEvents } from './rebroadcastService'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
|
||||
@@ -71,11 +73,13 @@ function saveToCache(naddr: string, content: ArticleContent): void {
|
||||
* @param relayPool - The relay pool to query
|
||||
* @param naddr - The article's naddr
|
||||
* @param bypassCache - If true, skip cache and fetch fresh from relays
|
||||
* @param settings - User settings for rebroadcast options
|
||||
*/
|
||||
export async function fetchArticleByNaddr(
|
||||
relayPool: RelayPool,
|
||||
naddr: string,
|
||||
bypassCache = false
|
||||
bypassCache = false,
|
||||
settings?: UserSettings
|
||||
): Promise<ArticleContent> {
|
||||
try {
|
||||
// Check cache first unless bypassed
|
||||
@@ -120,6 +124,9 @@ export async function fetchArticleByNaddr(
|
||||
events.sort((a, b) => b.created_at - a.created_at)
|
||||
const article = events[0]
|
||||
|
||||
// Rebroadcast article to local/all relays based on settings
|
||||
await rebroadcastEvents([article], relayPool, settings)
|
||||
|
||||
const title = getArticleTitle(article) || 'Untitled Article'
|
||||
const image = getArticleImage(article)
|
||||
const published = getArticlePublished(article)
|
||||
@@ -138,6 +145,8 @@ export async function fetchArticleByNaddr(
|
||||
// Save to cache before returning
|
||||
saveToCache(naddr, content)
|
||||
|
||||
// Image caching is handled automatically by Service Worker
|
||||
|
||||
return content
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch article:', err)
|
||||
|
||||
87
src/services/articleTitleResolver.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { RelayPool, completeOnEose } from 'applesauce-relay'
|
||||
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { AddressPointer } from 'nostr-tools/nip19'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { RELAYS } from '../config/relays'
|
||||
|
||||
const { getArticleTitle } = Helpers
|
||||
|
||||
/**
|
||||
* Fetch article title for a single naddr
|
||||
* Returns the title or null if not found/error
|
||||
*/
|
||||
export async function fetchArticleTitle(
|
||||
relayPool: RelayPool,
|
||||
naddr: string
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const decoded = nip19.decode(naddr)
|
||||
|
||||
if (decoded.type !== 'naddr') {
|
||||
return null
|
||||
}
|
||||
|
||||
const pointer = decoded.data as AddressPointer
|
||||
|
||||
// Define relays to query
|
||||
const relays = pointer.relays && pointer.relays.length > 0
|
||||
? pointer.relays
|
||||
: RELAYS
|
||||
|
||||
// Fetch the article event
|
||||
const filter = {
|
||||
kinds: [pointer.kind],
|
||||
authors: [pointer.pubkey],
|
||||
'#d': [pointer.identifier]
|
||||
}
|
||||
|
||||
const events = await lastValueFrom(
|
||||
relayPool
|
||||
.req(relays, filter)
|
||||
.pipe(completeOnEose(), takeUntil(timer(5000)), toArray())
|
||||
)
|
||||
|
||||
if (events.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Sort by created_at and take the most recent
|
||||
events.sort((a, b) => b.created_at - a.created_at)
|
||||
const article = events[0]
|
||||
|
||||
return getArticleTitle(article) || null
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch article title for', naddr, err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch titles for multiple naddrs in parallel
|
||||
* Returns a map of naddr -> title
|
||||
*/
|
||||
export async function fetchArticleTitles(
|
||||
relayPool: RelayPool,
|
||||
naddrs: string[]
|
||||
): Promise<Map<string, string>> {
|
||||
const titleMap = new Map<string, string>()
|
||||
|
||||
// Fetch all titles in parallel
|
||||
const results = await Promise.allSettled(
|
||||
naddrs.map(async (naddr) => {
|
||||
const title = await fetchArticleTitle(relayPool, naddr)
|
||||
return { naddr, title }
|
||||
})
|
||||
)
|
||||
|
||||
// Process results
|
||||
results.forEach((result) => {
|
||||
if (result.status === 'fulfilled' && result.value.title) {
|
||||
titleMap.set(result.value.naddr, result.value.title)
|
||||
}
|
||||
})
|
||||
|
||||
return titleMap
|
||||
}
|
||||
|
||||
@@ -14,13 +14,16 @@ import {
|
||||
} from './bookmarkHelpers'
|
||||
import { Bookmark } from '../types/bookmarks'
|
||||
import { collectBookmarksFromEvents } from './bookmarkProcessing.ts'
|
||||
import { UserSettings } from './settingsService'
|
||||
import { rebroadcastEvents } from './rebroadcastService'
|
||||
|
||||
|
||||
|
||||
export const fetchBookmarks = async (
|
||||
relayPool: RelayPool,
|
||||
activeAccount: unknown, // Full account object with extension capabilities
|
||||
setBookmarks: (bookmarks: Bookmark[]) => void
|
||||
setBookmarks: (bookmarks: Bookmark[]) => void,
|
||||
settings?: UserSettings
|
||||
) => {
|
||||
try {
|
||||
|
||||
@@ -37,6 +40,9 @@ export const fetchBookmarks = async (
|
||||
.pipe(completeOnEose(), takeUntil(timer(20000)), toArray())
|
||||
)
|
||||
console.log('📊 Raw events fetched:', rawEvents.length, 'events')
|
||||
|
||||
// Rebroadcast bookmark events to local/all relays based on settings
|
||||
await rebroadcastEvents(rawEvents, relayPool, settings)
|
||||
|
||||
// Check for events with potentially encrypted content
|
||||
const eventsWithContent = rawEvents.filter(evt => evt.content && evt.content.length > 0)
|
||||
|
||||
48
src/services/deletionService.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { EventFactory } from 'applesauce-factory'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { RELAYS } from '../config/relays'
|
||||
|
||||
/**
|
||||
* Creates a kind:5 event deletion request (NIP-09)
|
||||
* @param eventId The ID of the event to delete
|
||||
* @param eventKind The kind of the event being deleted
|
||||
* @param reason Optional reason for deletion
|
||||
* @param account The user's account for signing
|
||||
* @param relayPool The relay pool for publishing
|
||||
* @returns The signed deletion request event
|
||||
*/
|
||||
export async function createDeletionRequest(
|
||||
eventId: string,
|
||||
eventKind: number,
|
||||
reason: string | undefined,
|
||||
account: IAccount,
|
||||
relayPool: RelayPool
|
||||
): Promise<NostrEvent> {
|
||||
const factory = new EventFactory({ signer: account })
|
||||
|
||||
const tags: string[][] = [
|
||||
['e', eventId],
|
||||
['k', eventKind.toString()]
|
||||
]
|
||||
|
||||
const draft = await factory.create(async () => ({
|
||||
kind: 5, // Event Deletion Request
|
||||
content: reason || '',
|
||||
tags,
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
}))
|
||||
|
||||
const signed = await factory.sign(draft)
|
||||
|
||||
console.log('🗑️ Created kind:5 deletion request for event:', eventId.slice(0, 8))
|
||||
|
||||
// Publish to relays
|
||||
await relayPool.publish(RELAYS, signed)
|
||||
|
||||
console.log('✅ Deletion request published to', RELAYS.length, 'relay(s)')
|
||||
|
||||
return signed
|
||||
}
|
||||
|
||||
87
src/services/exploreService.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { RelayPool, completeOnEose } from 'applesauce-relay'
|
||||
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
|
||||
export interface BlogPostPreview {
|
||||
event: NostrEvent
|
||||
title: string
|
||||
summary?: string
|
||||
image?: string
|
||||
published?: number
|
||||
author: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches blog posts (kind:30023) from a list of pubkeys (friends)
|
||||
* @param relayPool - The relay pool to query
|
||||
* @param pubkeys - Array of pubkeys to fetch posts from
|
||||
* @param relayUrls - Array of relay URLs to query
|
||||
* @returns Array of blog post previews
|
||||
*/
|
||||
export const fetchBlogPostsFromAuthors = async (
|
||||
relayPool: RelayPool,
|
||||
pubkeys: string[],
|
||||
relayUrls: string[]
|
||||
): Promise<BlogPostPreview[]> => {
|
||||
try {
|
||||
if (pubkeys.length === 0) {
|
||||
console.log('⚠️ No pubkeys to fetch blog posts from')
|
||||
return []
|
||||
}
|
||||
|
||||
console.log('📚 Fetching blog posts (kind 30023) from', pubkeys.length, 'authors')
|
||||
|
||||
const events = await lastValueFrom(
|
||||
relayPool
|
||||
.req(relayUrls, {
|
||||
kinds: [30023],
|
||||
authors: pubkeys,
|
||||
limit: 100 // Fetch up to 100 recent posts
|
||||
})
|
||||
.pipe(completeOnEose(), takeUntil(timer(15000)), toArray())
|
||||
)
|
||||
|
||||
console.log('📊 Blog post events fetched:', events.length)
|
||||
|
||||
// Deduplicate replaceable events by keeping the most recent version
|
||||
// Group by author + d-tag identifier
|
||||
const uniqueEvents = new Map<string, NostrEvent>()
|
||||
|
||||
for (const event of events) {
|
||||
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const key = `${event.pubkey}:${dTag}`
|
||||
|
||||
const existing = uniqueEvents.get(key)
|
||||
if (!existing || event.created_at > existing.created_at) {
|
||||
uniqueEvents.set(key, event)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to blog post previews and sort by published date (most recent first)
|
||||
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
|
||||
.map(event => ({
|
||||
event,
|
||||
title: getArticleTitle(event) || 'Untitled',
|
||||
summary: getArticleSummary(event),
|
||||
image: getArticleImage(event),
|
||||
published: getArticlePublished(event),
|
||||
author: event.pubkey
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA // Most recent first
|
||||
})
|
||||
|
||||
console.log('📰 Processed', blogPosts.length, 'unique blog posts')
|
||||
|
||||
return blogPosts
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch blog posts:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,16 @@ import { RelayPool } from 'applesauce-relay'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { AddressPointer } from 'nostr-tools/nip19'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { Helpers, IEventStore } from 'applesauce-core'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { UserSettings } from './settingsService'
|
||||
import { areAllRelaysLocal } from '../utils/helpers'
|
||||
import { markEventAsOfflineCreated } from './offlineSyncService'
|
||||
|
||||
// Boris pubkey for zap splits
|
||||
// npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x
|
||||
const BORIS_PUBKEY = '29dea8672f44ed164bfc83db3da5bd472001af70307f42277674cbc64d33013e'
|
||||
|
||||
const {
|
||||
getHighlightText,
|
||||
@@ -22,16 +29,18 @@ const { HighlightBlueprint } = Blueprints
|
||||
/**
|
||||
* Creates and publishes a highlight event (NIP-84)
|
||||
* Supports both nostr-native articles and external URLs
|
||||
* Returns the signed event for immediate UI updates
|
||||
* Returns a Highlight object with relay tracking info for immediate UI updates
|
||||
*/
|
||||
export async function createHighlight(
|
||||
selectedText: string,
|
||||
source: NostrEvent | string,
|
||||
account: IAccount,
|
||||
relayPool: RelayPool,
|
||||
eventStore: IEventStore,
|
||||
contentForContext?: string,
|
||||
comment?: string
|
||||
): Promise<NostrEvent> {
|
||||
comment?: string,
|
||||
settings?: UserSettings
|
||||
): Promise<Highlight> {
|
||||
if (!selectedText || !source) {
|
||||
throw new Error('Missing required data to create highlight')
|
||||
}
|
||||
@@ -67,21 +76,102 @@ export async function createHighlight(
|
||||
// Update the alt tag to identify Boris as the creator
|
||||
const altTagIndex = highlightEvent.tags.findIndex(tag => tag[0] === 'alt')
|
||||
if (altTagIndex !== -1) {
|
||||
highlightEvent.tags[altTagIndex] = ['alt', 'Highlight created by Boris. readwithboris.com']
|
||||
highlightEvent.tags[altTagIndex] = ['alt', 'Highlight created by Boris. read.withboris.com']
|
||||
} else {
|
||||
highlightEvent.tags.push(['alt', 'Highlight created by Boris. readwithboris.com'])
|
||||
highlightEvent.tags.push(['alt', 'Highlight created by Boris. read.withboris.com'])
|
||||
}
|
||||
|
||||
// Add p tag (author tag) for nostr-native content
|
||||
// This tags the original author so they can see highlights of their work
|
||||
if (typeof source === 'object' && 'kind' in source) {
|
||||
// Only add p tag if it doesn't already exist
|
||||
const hasPTag = highlightEvent.tags.some(tag => tag[0] === 'p' && tag[1] === source.pubkey)
|
||||
if (!hasPTag) {
|
||||
highlightEvent.tags.push(['p', source.pubkey])
|
||||
}
|
||||
}
|
||||
|
||||
// Add zap tags for nostr-native content (NIP-57 Appendix G)
|
||||
if (typeof source === 'object' && 'kind' in source) {
|
||||
// Migrate old settings format to new weight-based format if needed
|
||||
let highlighterWeight = settings?.zapSplitHighlighterWeight
|
||||
let borisWeight = settings?.zapSplitBorisWeight
|
||||
let authorWeight = settings?.zapSplitAuthorWeight
|
||||
|
||||
const anySettings = settings as Record<string, unknown> | undefined
|
||||
if (!highlighterWeight && anySettings && 'zapSplitPercentage' in anySettings) {
|
||||
highlighterWeight = anySettings.zapSplitPercentage as number
|
||||
authorWeight = 100 - (anySettings.zapSplitPercentage as number)
|
||||
}
|
||||
if (!borisWeight && anySettings && 'borisSupportPercentage' in anySettings) {
|
||||
borisWeight = anySettings.borisSupportPercentage as number
|
||||
}
|
||||
|
||||
// Use defaults if still undefined
|
||||
highlighterWeight = highlighterWeight ?? 50
|
||||
borisWeight = borisWeight ?? 2.1
|
||||
authorWeight = authorWeight ?? 50
|
||||
|
||||
addZapTags(highlightEvent, account.pubkey, source, highlighterWeight, borisWeight, authorWeight)
|
||||
}
|
||||
|
||||
// Sign the event
|
||||
const signedEvent = await factory.sign(highlightEvent)
|
||||
|
||||
// Publish to relays (including local relay)
|
||||
await relayPool.publish(RELAYS, signedEvent)
|
||||
// Publish to all configured relays - let the relay pool handle connection state
|
||||
const targetRelays = RELAYS
|
||||
|
||||
console.log('✅ Highlight published to', RELAYS.length, 'relays (including local):', signedEvent)
|
||||
// Store the event in the local EventStore FIRST for immediate UI display
|
||||
eventStore.add(signedEvent)
|
||||
console.log('💾 Stored highlight in EventStore:', signedEvent.id.slice(0, 8))
|
||||
|
||||
// Return the signed event for immediate UI updates
|
||||
return signedEvent
|
||||
// Check current connection status - are we online or in flight mode?
|
||||
const connectedRelays = Array.from(relayPool.relays.values())
|
||||
.filter(relay => relay.connected)
|
||||
.map(relay => relay.url)
|
||||
|
||||
const hasRemoteConnection = connectedRelays.some(url =>
|
||||
!url.includes('localhost') && !url.includes('127.0.0.1')
|
||||
)
|
||||
|
||||
// Determine which relays we expect to succeed
|
||||
const expectedSuccessRelays = hasRemoteConnection
|
||||
? RELAYS
|
||||
: RELAYS.filter(r => r.includes('localhost') || r.includes('127.0.0.1'))
|
||||
|
||||
const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays)
|
||||
|
||||
console.log('📍 Highlight relay status:', {
|
||||
targetRelays: targetRelays.length,
|
||||
expectedSuccessRelays,
|
||||
isLocalOnly,
|
||||
hasRemoteConnection,
|
||||
eventId: signedEvent.id
|
||||
})
|
||||
|
||||
// If we're in local-only mode, mark this event for later sync
|
||||
if (isLocalOnly) {
|
||||
markEventAsOfflineCreated(signedEvent.id)
|
||||
}
|
||||
|
||||
// Convert to Highlight with relay tracking info and return IMMEDIATELY
|
||||
const highlight = eventToHighlight(signedEvent)
|
||||
highlight.publishedRelays = expectedSuccessRelays // Show only relays we expect to succeed
|
||||
highlight.isLocalOnly = isLocalOnly
|
||||
highlight.isOfflineCreated = isLocalOnly // Mark as created offline if local-only
|
||||
|
||||
// Publish to relays in the background (non-blocking)
|
||||
// This allows instant UI updates while publishing happens asynchronously
|
||||
relayPool.publish(targetRelays, signedEvent)
|
||||
.then(() => {
|
||||
console.log('✅ Highlight published to', targetRelays.length, 'relay(s):', targetRelays)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('⚠️ Failed to publish highlight to relays (event still saved locally):', error)
|
||||
})
|
||||
|
||||
// Return the highlight immediately for instant UI updates
|
||||
return highlight
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,6 +266,76 @@ function extractContext(selectedText: string, articleContent: string): string |
|
||||
return contextParts.length > 1 ? contextParts.join(' ') : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds zap tags to a highlight event for split payments (NIP-57 Appendix G)
|
||||
* Respects existing zap tags in the source event (author group)
|
||||
* @param event The highlight event to add zap tags to (can be EventTemplate or NostrEvent)
|
||||
* @param highlighterPubkey The pubkey of the user creating the highlight
|
||||
* @param sourceEvent The source event (may contain existing zap tags)
|
||||
* @param highlighterWeight Weight to give to the highlighter (default 50)
|
||||
* @param borisWeight Weight to give to Boris (default 2.1)
|
||||
* @param authorWeight Weight to give to author(s) (default 50)
|
||||
*/
|
||||
function addZapTags(
|
||||
event: { tags: string[][] },
|
||||
highlighterPubkey: string,
|
||||
sourceEvent: NostrEvent,
|
||||
highlighterWeight: number = 50,
|
||||
borisWeight: number = 2.1,
|
||||
authorWeight: number = 50
|
||||
): void {
|
||||
// Use a reliable relay for zap metadata lookup (first non-local relay)
|
||||
const zapRelay = RELAYS.find(r => !r.includes('localhost')) || RELAYS[0]
|
||||
|
||||
// Extract existing zap tags from source event (the "author group")
|
||||
const existingZapTags = sourceEvent.tags.filter(tag => tag[0] === 'zap')
|
||||
|
||||
// Add zap tag for the highlighter
|
||||
if (highlighterWeight > 0) {
|
||||
event.tags.push(['zap', highlighterPubkey, zapRelay, highlighterWeight.toString()])
|
||||
}
|
||||
|
||||
// Add zap tag for Boris (if weight > 0 and Boris is not the highlighter)
|
||||
if (borisWeight > 0 && BORIS_PUBKEY !== highlighterPubkey) {
|
||||
event.tags.push(['zap', BORIS_PUBKEY, zapRelay, borisWeight.toFixed(1)])
|
||||
}
|
||||
|
||||
if (existingZapTags.length > 0 && authorWeight > 0) {
|
||||
// Calculate total weight from existing zap tags
|
||||
const totalExistingWeight = existingZapTags.reduce((sum, tag) => {
|
||||
const weight = parseFloat(tag[3] || '1')
|
||||
return sum + weight
|
||||
}, 0)
|
||||
|
||||
// Add proportionally adjusted zap tags for each existing author
|
||||
// Don't add the highlighter or Boris again if they're already in the author group
|
||||
for (const zapTag of existingZapTags) {
|
||||
const authorPubkey = zapTag[1]
|
||||
|
||||
// Skip if this is the highlighter or Boris (they already have their shares)
|
||||
if (authorPubkey === highlighterPubkey || authorPubkey === BORIS_PUBKEY) continue
|
||||
|
||||
const originalWeight = parseFloat(zapTag[3] || '1')
|
||||
const originalRelay = zapTag[2] || zapRelay
|
||||
|
||||
// Calculate proportional weight: (original weight / total weight) * author group weight
|
||||
const adjustedWeight = (originalWeight / totalExistingWeight) * authorWeight
|
||||
|
||||
// Only add if weight is greater than 0
|
||||
if (adjustedWeight > 0) {
|
||||
event.tags.push(['zap', authorPubkey, originalRelay, adjustedWeight.toFixed(1)])
|
||||
}
|
||||
}
|
||||
} else if (authorWeight > 0) {
|
||||
// No existing zap tags, give full author weight to source author
|
||||
|
||||
// Add zap tag for the original author (only if different from highlighter and Boris)
|
||||
if (sourceEvent.pubkey !== highlighterPubkey && sourceEvent.pubkey !== BORIS_PUBKEY) {
|
||||
event.tags.push(['zap', sourceEvent.pubkey, zapRelay, authorWeight.toFixed(1)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a NostrEvent to a Highlight object for immediate UI display
|
||||
*/
|
||||
|
||||
66
src/services/highlightEventProcessor.ts
Normal 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)
|
||||
}
|
||||
|
||||
@@ -1,48 +1,26 @@
|
||||
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'
|
||||
import { UserSettings } from './settingsService'
|
||||
import { rebroadcastEvents } from './rebroadcastService'
|
||||
|
||||
/**
|
||||
* Fetches highlights for a specific article by its address coordinate and/or event ID
|
||||
* @param relayPool - The relay pool to query
|
||||
* @param articleCoordinate - The article's address in format "kind:pubkey:identifier" (e.g., "30023:abc...def:my-article")
|
||||
* @param eventId - Optional event ID to also query by 'e' tag
|
||||
* @param onHighlight - Optional callback to receive highlights as they arrive
|
||||
* @param settings - User settings for rebroadcast options
|
||||
*/
|
||||
export const fetchHighlightsForArticle = async (
|
||||
relayPool: RelayPool,
|
||||
articleCoordinate: string,
|
||||
eventId?: string,
|
||||
onHighlight?: (highlight: Highlight) => void
|
||||
onHighlight?: (highlight: Highlight) => void,
|
||||
settings?: UserSettings
|
||||
): Promise<Highlight[]> => {
|
||||
try {
|
||||
console.log('🔍 Fetching highlights (kind 9802) for article:', articleCoordinate)
|
||||
@@ -53,31 +31,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
|
||||
@@ -126,6 +80,9 @@ export const fetchHighlightsForArticle = async (
|
||||
const rawEvents = [...aTagEvents, ...eTagEvents]
|
||||
console.log('📊 Total raw highlight events fetched:', rawEvents.length)
|
||||
|
||||
// Rebroadcast highlight events to local/all relays based on settings
|
||||
await rebroadcastEvents(rawEvents, relayPool, settings)
|
||||
|
||||
if (rawEvents.length > 0) {
|
||||
console.log('📄 Sample highlight tags:', JSON.stringify(rawEvents[0].tags, null, 2))
|
||||
} else {
|
||||
@@ -138,39 +95,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 []
|
||||
@@ -181,10 +107,12 @@ export const fetchHighlightsForArticle = async (
|
||||
* Fetches highlights for a specific URL
|
||||
* @param relayPool - The relay pool to query
|
||||
* @param url - The external URL to find highlights for
|
||||
* @param settings - User settings for rebroadcast options
|
||||
*/
|
||||
export const fetchHighlightsForUrl = async (
|
||||
relayPool: RelayPool,
|
||||
url: string
|
||||
url: string,
|
||||
settings?: UserSettings
|
||||
): Promise<Highlight[]> => {
|
||||
try {
|
||||
console.log('🔍 Fetching highlights (kind 9802) for URL:', url)
|
||||
@@ -206,35 +134,12 @@ 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
|
||||
}
|
||||
})
|
||||
// Rebroadcast highlight events to local/all relays based on settings
|
||||
await rebroadcastEvents(rawEvents, relayPool, settings)
|
||||
|
||||
return highlights.sort((a, b) => b.created_at - a.created_at)
|
||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
|
||||
return sortHighlights(highlights)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch highlights for URL:', error)
|
||||
return []
|
||||
@@ -246,11 +151,13 @@ export const fetchHighlightsForUrl = async (
|
||||
* @param relayPool - The relay pool to query
|
||||
* @param pubkey - The user's public key
|
||||
* @param onHighlight - Optional callback to receive highlights as they arrive
|
||||
* @param settings - User settings for rebroadcast options
|
||||
*/
|
||||
export const fetchHighlights = async (
|
||||
relayPool: RelayPool,
|
||||
pubkey: string,
|
||||
onHighlight?: (highlight: Highlight) => void
|
||||
onHighlight?: (highlight: Highlight) => void,
|
||||
settings?: UserSettings
|
||||
): Promise<Highlight[]> => {
|
||||
try {
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
@@ -266,32 +173,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)
|
||||
}
|
||||
@@ -305,39 +187,15 @@ export const fetchHighlights = async (
|
||||
|
||||
console.log('📊 Raw highlight events fetched:', rawEvents.length)
|
||||
|
||||
// Rebroadcast highlight events to local/all relays based on settings
|
||||
await rebroadcastEvents(rawEvents, relayPool, settings)
|
||||
|
||||
// Deduplicate and process events
|
||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||
console.log('📊 Unique highlight events after deduplication:', uniqueEvents.length)
|
||||
|
||||
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 []
|
||||
|
||||
74
src/services/imageCacheService.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Image Cache Service
|
||||
*
|
||||
* Utility functions for managing the Service Worker's image cache
|
||||
* Service Worker automatically caches images on fetch
|
||||
*/
|
||||
|
||||
const CACHE_NAME = 'boris-image-cache-v1'
|
||||
|
||||
/**
|
||||
* Clear all cached images
|
||||
*/
|
||||
export async function clearImageCache(): Promise<void> {
|
||||
try {
|
||||
await caches.delete(CACHE_NAME)
|
||||
console.log('🗑️ Cleared all cached images')
|
||||
} catch (err) {
|
||||
console.error('Failed to clear image cache:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics by inspecting Cache API directly
|
||||
*/
|
||||
export async function getImageCacheStatsAsync(): Promise<{
|
||||
totalSizeMB: number
|
||||
itemCount: number
|
||||
items: Array<{ url: string, sizeMB: number }>
|
||||
}> {
|
||||
try {
|
||||
const cache = await caches.open(CACHE_NAME)
|
||||
const requests = await cache.keys()
|
||||
|
||||
let totalSize = 0
|
||||
const items: Array<{ url: string, sizeMB: number }> = []
|
||||
|
||||
for (const request of requests) {
|
||||
const response = await cache.match(request)
|
||||
if (response) {
|
||||
const blob = await response.blob()
|
||||
const sizeMB = blob.size / (1024 * 1024)
|
||||
totalSize += blob.size
|
||||
items.push({ url: request.url, sizeMB })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalSizeMB: totalSize / (1024 * 1024),
|
||||
itemCount: requests.length,
|
||||
items
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to get cache stats:', err)
|
||||
return { totalSizeMB: 0, itemCount: 0, items: [] }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous wrapper for cache stats (returns approximate values)
|
||||
* For real-time stats, use getImageCacheStatsAsync
|
||||
*/
|
||||
export function getImageCacheStats(): {
|
||||
totalSizeMB: number
|
||||
itemCount: number
|
||||
items: Array<{ url: string, sizeMB: number, lastAccessed: Date }>
|
||||
} {
|
||||
// Return placeholder - actual stats require async Cache API access
|
||||
// Component should use getImageCacheStatsAsync for real values
|
||||
return {
|
||||
totalSizeMB: 0,
|
||||
itemCount: 0,
|
||||
items: []
|
||||
}
|
||||
}
|
||||
158
src/services/offlineSyncService.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { isLocalRelay } from '../utils/helpers'
|
||||
|
||||
let isSyncing = false
|
||||
|
||||
// Track events created during offline period
|
||||
const offlineCreatedEvents = new Set<string>()
|
||||
|
||||
// Track events currently being synced
|
||||
const syncingEvents = new Set<string>()
|
||||
|
||||
// Callbacks to notify when sync state changes
|
||||
const syncStateListeners: Array<(eventId: string, isSyncing: boolean) => void> = []
|
||||
|
||||
/**
|
||||
* Marks an event as created during offline period
|
||||
*/
|
||||
export function markEventAsOfflineCreated(eventId: string): void {
|
||||
offlineCreatedEvents.add(eventId)
|
||||
console.log(`📝 Marked event ${eventId.slice(0, 8)} as offline-created. Total: ${offlineCreatedEvents.size}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event is currently being synced
|
||||
*/
|
||||
export function isEventSyncing(eventId: string): boolean {
|
||||
return syncingEvents.has(eventId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to sync state changes
|
||||
*/
|
||||
export function onSyncStateChange(callback: (eventId: string, isSyncing: boolean) => void): () => void {
|
||||
syncStateListeners.push(callback)
|
||||
return () => {
|
||||
const index = syncStateListeners.indexOf(callback)
|
||||
if (index > -1) syncStateListeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify listeners of sync state change
|
||||
*/
|
||||
function notifySyncStateChange(eventId: string, isSyncing: boolean): void {
|
||||
syncStateListeners.forEach(listener => listener(eventId, isSyncing))
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs local-only events to remote relays when coming back online
|
||||
* Now uses applesauce EventStore instead of querying relays
|
||||
*/
|
||||
export async function syncLocalEventsToRemote(
|
||||
relayPool: RelayPool,
|
||||
eventStore: IEventStore
|
||||
): Promise<void> {
|
||||
if (isSyncing) {
|
||||
console.log('⏳ Sync already in progress, skipping...')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('🔄 Coming back online - syncing local events to remote relays...')
|
||||
console.log(`📦 Offline events tracked: ${offlineCreatedEvents.size}`)
|
||||
isSyncing = true
|
||||
|
||||
try {
|
||||
const remoteRelays = RELAYS.filter(url => !isLocalRelay(url))
|
||||
|
||||
console.log(`📡 Remote relays: ${remoteRelays.length}`)
|
||||
|
||||
if (remoteRelays.length === 0) {
|
||||
console.log('⚠️ No remote relays available for sync')
|
||||
isSyncing = false
|
||||
return
|
||||
}
|
||||
|
||||
if (offlineCreatedEvents.size === 0) {
|
||||
console.log('✅ No offline events to sync')
|
||||
isSyncing = false
|
||||
return
|
||||
}
|
||||
|
||||
// Get events from EventStore using the tracked IDs
|
||||
const eventsToSync: NostrEvent[] = []
|
||||
console.log(`🔍 Querying EventStore for ${offlineCreatedEvents.size} offline events...`)
|
||||
|
||||
for (const eventId of offlineCreatedEvents) {
|
||||
const event = eventStore.getEvent(eventId)
|
||||
if (event) {
|
||||
console.log(`📥 Found event ${eventId.slice(0, 8)} (kind ${event.kind}) in EventStore`)
|
||||
eventsToSync.push(event)
|
||||
} else {
|
||||
console.warn(`⚠️ Event ${eventId.slice(0, 8)} not found in EventStore`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`📊 Total events to sync: ${eventsToSync.length}`)
|
||||
|
||||
if (eventsToSync.length === 0) {
|
||||
console.log('✅ No events found in EventStore to sync')
|
||||
isSyncing = false
|
||||
offlineCreatedEvents.clear()
|
||||
return
|
||||
}
|
||||
|
||||
// Deduplicate events by id
|
||||
const uniqueEvents = Array.from(
|
||||
new Map(eventsToSync.map(e => [e.id, e])).values()
|
||||
)
|
||||
|
||||
console.log(`📤 Syncing ${uniqueEvents.length} event(s) to remote relays...`)
|
||||
|
||||
// Mark all events as syncing
|
||||
uniqueEvents.forEach(event => {
|
||||
syncingEvents.add(event.id)
|
||||
notifySyncStateChange(event.id, true)
|
||||
})
|
||||
|
||||
// Publish to remote relays
|
||||
let successCount = 0
|
||||
const successfulIds: string[] = []
|
||||
|
||||
for (const event of uniqueEvents) {
|
||||
try {
|
||||
await relayPool.publish(remoteRelays, event)
|
||||
successCount++
|
||||
successfulIds.push(event.id)
|
||||
console.log(`✅ Synced event ${event.id.slice(0, 8)}`)
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ Failed to sync event ${event.id.slice(0, 8)}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ Synced ${successCount}/${uniqueEvents.length} events to remote relays`)
|
||||
|
||||
// Clear syncing state and offline tracking for successful events
|
||||
successfulIds.forEach(eventId => {
|
||||
syncingEvents.delete(eventId)
|
||||
offlineCreatedEvents.delete(eventId)
|
||||
notifySyncStateChange(eventId, false)
|
||||
})
|
||||
|
||||
// Clear syncing state for failed events
|
||||
uniqueEvents.forEach(event => {
|
||||
if (!successfulIds.includes(event.id)) {
|
||||
syncingEvents.delete(event.id)
|
||||
notifySyncStateChange(event.id, false)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('❌ Error during offline sync:', error)
|
||||
} finally {
|
||||
isSyncing = false
|
||||
}
|
||||
}
|
||||
|
||||
103
src/services/reactionService.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { EventFactory } from 'applesauce-factory'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { RELAYS } from '../config/relays'
|
||||
|
||||
const MARK_AS_READ_EMOJI = '📚'
|
||||
|
||||
/**
|
||||
* Creates a kind:7 reaction to a nostr event (for nostr-native articles)
|
||||
* @param eventId The ID of the event being reacted to
|
||||
* @param eventAuthor The pubkey of the event author
|
||||
* @param eventKind The kind of the event being reacted to
|
||||
* @param account The user's account for signing
|
||||
* @param relayPool The relay pool for publishing
|
||||
* @returns The signed reaction event
|
||||
*/
|
||||
export async function createEventReaction(
|
||||
eventId: string,
|
||||
eventAuthor: string,
|
||||
eventKind: number,
|
||||
account: IAccount,
|
||||
relayPool: RelayPool
|
||||
): Promise<NostrEvent> {
|
||||
const factory = new EventFactory({ signer: account })
|
||||
|
||||
const tags: string[][] = [
|
||||
['e', eventId],
|
||||
['p', eventAuthor],
|
||||
['k', eventKind.toString()]
|
||||
]
|
||||
|
||||
const draft = await factory.create(async () => ({
|
||||
kind: 7, // Reaction
|
||||
content: MARK_AS_READ_EMOJI,
|
||||
tags,
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
}))
|
||||
|
||||
const signed = await factory.sign(draft)
|
||||
|
||||
console.log('📚 Created kind:7 reaction (mark as read) for event:', eventId.slice(0, 8))
|
||||
|
||||
// Publish to relays
|
||||
await relayPool.publish(RELAYS, signed)
|
||||
|
||||
console.log('✅ Reaction published to', RELAYS.length, 'relay(s)')
|
||||
|
||||
return signed
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a kind:17 reaction to a website (for external URLs)
|
||||
* @param url The URL being reacted to
|
||||
* @param account The user's account for signing
|
||||
* @param relayPool The relay pool for publishing
|
||||
* @returns The signed reaction event
|
||||
*/
|
||||
export async function createWebsiteReaction(
|
||||
url: string,
|
||||
account: IAccount,
|
||||
relayPool: RelayPool
|
||||
): Promise<NostrEvent> {
|
||||
const factory = new EventFactory({ signer: account })
|
||||
|
||||
// Normalize URL (remove fragments, trailing slashes as per NIP-25)
|
||||
let normalizedUrl = url
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
// Remove fragment
|
||||
parsed.hash = ''
|
||||
normalizedUrl = parsed.toString()
|
||||
// Remove trailing slash if present
|
||||
if (normalizedUrl.endsWith('/')) {
|
||||
normalizedUrl = normalizedUrl.slice(0, -1)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to normalize URL:', error)
|
||||
}
|
||||
|
||||
const tags: string[][] = [
|
||||
['r', normalizedUrl]
|
||||
]
|
||||
|
||||
const draft = await factory.create(async () => ({
|
||||
kind: 17, // Reaction to a website
|
||||
content: MARK_AS_READ_EMOJI,
|
||||
tags,
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
}))
|
||||
|
||||
const signed = await factory.sign(draft)
|
||||
|
||||
console.log('📚 Created kind:17 reaction (mark as read) for URL:', normalizedUrl)
|
||||
|
||||
// Publish to relays
|
||||
await relayPool.publish(RELAYS, signed)
|
||||
|
||||
console.log('✅ Website reaction published to', RELAYS.length, 'relay(s)')
|
||||
|
||||
return signed
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ export interface ReadableContent {
|
||||
html?: string
|
||||
markdown?: string
|
||||
image?: string
|
||||
summary?: string
|
||||
published?: number
|
||||
}
|
||||
|
||||
interface CachedContent {
|
||||
@@ -57,7 +59,7 @@ function saveToCache(url: string, content: ReadableContent): void {
|
||||
function toProxyUrl(url: string): string {
|
||||
// Ensure the target URL has a protocol and build the proxy URL
|
||||
const normalized = /^https?:\/\//i.test(url) ? url : `https://${url}`
|
||||
return `https://r.jina.ai/http://${normalized.replace(/^https?:\/\//, '')}`
|
||||
return `https://r.jina.ai/${normalized}`
|
||||
}
|
||||
|
||||
export async function fetchReadableContent(
|
||||
|
||||
78
src/services/rebroadcastService.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { UserSettings } from './settingsService'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { isLocalRelay } from '../utils/helpers'
|
||||
|
||||
/**
|
||||
* Rebroadcasts events to relays based on user settings
|
||||
* @param events Events to rebroadcast
|
||||
* @param relayPool The relay pool to use for publishing
|
||||
* @param settings User settings to determine which relays to broadcast to
|
||||
*/
|
||||
export async function rebroadcastEvents(
|
||||
events: NostrEvent[],
|
||||
relayPool: RelayPool,
|
||||
settings?: UserSettings
|
||||
): Promise<void> {
|
||||
if (!events || events.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if any rebroadcast is enabled
|
||||
const useLocalCache = settings?.useLocalRelayAsCache ?? true
|
||||
const broadcastToAll = settings?.rebroadcastToAllRelays ?? false
|
||||
|
||||
if (!useLocalCache && !broadcastToAll) {
|
||||
return // No rebroadcast enabled
|
||||
}
|
||||
|
||||
// Check current relay connectivity - don't rebroadcast in flight mode
|
||||
const connectedRelays = Array.from(relayPool.relays.values())
|
||||
const connectedRemoteRelays = connectedRelays.filter(relay => relay.connected && !isLocalRelay(relay.url))
|
||||
const hasRemoteConnection = connectedRemoteRelays.length > 0
|
||||
|
||||
// If we're in flight mode (only local relays connected) and user wants to broadcast to all relays, skip
|
||||
if (broadcastToAll && !hasRemoteConnection) {
|
||||
console.log('✈️ Flight mode: skipping rebroadcast to remote relays')
|
||||
return
|
||||
}
|
||||
|
||||
// Determine target relays based on settings
|
||||
let targetRelays: string[] = []
|
||||
|
||||
if (broadcastToAll) {
|
||||
// Broadcast to all relays (only if we have remote connection)
|
||||
targetRelays = RELAYS
|
||||
} else if (useLocalCache) {
|
||||
// Only broadcast to local relays
|
||||
targetRelays = RELAYS.filter(isLocalRelay)
|
||||
}
|
||||
|
||||
if (targetRelays.length === 0) {
|
||||
console.log('📡 No target relays for rebroadcast')
|
||||
return
|
||||
}
|
||||
|
||||
// Rebroadcast each event
|
||||
const rebroadcastPromises = events.map(async (event) => {
|
||||
try {
|
||||
await relayPool.publish(targetRelays, event)
|
||||
console.log('📡 Rebroadcast event', event.id?.slice(0, 8), 'to', targetRelays.length, 'relay(s)')
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Failed to rebroadcast event', event.id?.slice(0, 8), error)
|
||||
}
|
||||
})
|
||||
|
||||
// Execute all rebroadcasts (don't block on completion)
|
||||
Promise.all(rebroadcastPromises).catch((err) => {
|
||||
console.warn('⚠️ Some rebroadcasts failed:', err)
|
||||
})
|
||||
|
||||
console.log(`📡 Rebroadcasting ${events.length} event(s) to ${targetRelays.length} relay(s)`, {
|
||||
broadcastToAll,
|
||||
useLocalCache,
|
||||
targetRelays
|
||||
})
|
||||
}
|
||||
|
||||
85
src/services/relayStatusService.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
|
||||
export interface RelayStatus {
|
||||
url: string
|
||||
isInPool: boolean
|
||||
lastSeen: number // timestamp
|
||||
}
|
||||
|
||||
// How long to show disconnected relays as "recently seen" before hiding them
|
||||
const RECENT_CONNECTION_WINDOW = 10 * 1000 // 10 seconds
|
||||
|
||||
// In-memory tracking of relay last seen times
|
||||
const relayLastSeen = new Map<string, number>()
|
||||
|
||||
/**
|
||||
* Updates and gets the current status of all relays
|
||||
*/
|
||||
export function updateAndGetRelayStatuses(relayPool: RelayPool): RelayStatus[] {
|
||||
const statuses: RelayStatus[] = []
|
||||
const now = Date.now()
|
||||
const currentlyConnectedUrls = new Set<string>()
|
||||
|
||||
// Check all relays in the pool for their actual connection status
|
||||
for (const relay of relayPool.relays.values()) {
|
||||
const isConnected = relay.connected
|
||||
|
||||
if (isConnected) {
|
||||
currentlyConnectedUrls.add(relay.url)
|
||||
relayLastSeen.set(relay.url, now)
|
||||
}
|
||||
|
||||
statuses.push({
|
||||
url: relay.url,
|
||||
isInPool: isConnected,
|
||||
lastSeen: isConnected ? now : (relayLastSeen.get(relay.url) || now)
|
||||
})
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
const connectedCount = statuses.filter(s => s.isInPool).length
|
||||
const disconnectedCount = statuses.filter(s => !s.isInPool).length
|
||||
if (connectedCount === 0 || disconnectedCount > 0) {
|
||||
console.log(`🔌 Relay status: ${connectedCount} connected, ${disconnectedCount} disconnected`)
|
||||
const connected = statuses.filter(s => s.isInPool).map(s => s.url.replace(/^wss?:\/\//, ''))
|
||||
const disconnected = statuses.filter(s => !s.isInPool).map(s => s.url.replace(/^wss?:\/\//, ''))
|
||||
if (connected.length > 0) console.log('✅ Connected:', connected.join(', '))
|
||||
if (disconnected.length > 0) console.log('❌ Disconnected:', disconnected.join(', '))
|
||||
}
|
||||
|
||||
// Add recently seen relays that are no longer connected
|
||||
const cutoffTime = now - RECENT_CONNECTION_WINDOW
|
||||
for (const [url, lastSeen] of relayLastSeen.entries()) {
|
||||
if (!currentlyConnectedUrls.has(url) && lastSeen >= cutoffTime) {
|
||||
// Check if this relay is already in statuses (might be in pool but not connected)
|
||||
const existingStatus = statuses.find(s => s.url === url)
|
||||
if (!existingStatus) {
|
||||
statuses.push({
|
||||
url,
|
||||
isInPool: false,
|
||||
lastSeen
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up old entries
|
||||
for (const [url, lastSeen] of relayLastSeen.entries()) {
|
||||
if (lastSeen < cutoffTime) {
|
||||
relayLastSeen.delete(url)
|
||||
}
|
||||
}
|
||||
|
||||
return statuses.sort((a, b) => {
|
||||
if (a.isInPool !== b.isInPool) return a.isInPool ? -1 : 1
|
||||
return b.lastSeen - a.lastSeen
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets count of currently active relays
|
||||
*/
|
||||
export function getActiveCount(statuses: RelayStatus[]): number {
|
||||
return statuses.filter(r => r.isInPool).length
|
||||
}
|
||||
|
||||
@@ -35,6 +35,18 @@ export interface UserSettings {
|
||||
defaultHighlightVisibilityNostrverse?: boolean
|
||||
defaultHighlightVisibilityFriends?: boolean
|
||||
defaultHighlightVisibilityMine?: boolean
|
||||
// Zap split weights (treated as relative weights, not strict percentages)
|
||||
zapSplitHighlighterWeight?: number // default 50
|
||||
zapSplitBorisWeight?: number // default 2.1
|
||||
zapSplitAuthorWeight?: number // default 50
|
||||
// Relay rebroadcast settings
|
||||
useLocalRelayAsCache?: boolean // Rebroadcast events to local relays
|
||||
rebroadcastToAllRelays?: boolean // Rebroadcast events to all relays
|
||||
// Image cache settings
|
||||
enableImageCache?: boolean // Enable caching images in localStorage
|
||||
imageCacheSizeMB?: number // Maximum cache size in megabytes (default: 210MB)
|
||||
// Mobile settings
|
||||
autoCollapseSidebarOnMobile?: boolean // Auto-collapse sidebar on mobile (default: true)
|
||||
}
|
||||
|
||||
export async function loadSettings(
|
||||
|
||||
90
src/services/webBookmarkService.ts
Normal 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
|
||||
}
|
||||
|
||||
111
src/sw.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/// <reference lib="webworker" />
|
||||
/* eslint-env worker */
|
||||
/* global ServiceWorkerGlobalScope, ExtendableMessageEvent, FetchEvent */
|
||||
import { clientsClaim } from 'workbox-core'
|
||||
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching'
|
||||
import { registerRoute, NavigationRoute } from 'workbox-routing'
|
||||
import { StaleWhileRevalidate } from 'workbox-strategies'
|
||||
import { ExpirationPlugin } from 'workbox-expiration'
|
||||
import { CacheableResponsePlugin } from 'workbox-cacheable-response'
|
||||
|
||||
// Narrow the global service worker scope for proper typings
|
||||
const sw = self as unknown as ServiceWorkerGlobalScope
|
||||
|
||||
// Precache all build assets (app shell)
|
||||
// @ts-ignore - __WB_MANIFEST is injected by vite-plugin-pwa
|
||||
precacheAndRoute(self.__WB_MANIFEST)
|
||||
|
||||
// Clean up old caches
|
||||
cleanupOutdatedCaches()
|
||||
|
||||
// Take control immediately
|
||||
sw.skipWaiting()
|
||||
clientsClaim()
|
||||
|
||||
console.log('[SW] Boris service worker loaded')
|
||||
|
||||
// Runtime cache: Cross-origin images
|
||||
// This preserves the existing image caching behavior
|
||||
registerRoute(
|
||||
({ request, url }) => {
|
||||
const isImage = request.destination === 'image' ||
|
||||
/\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url.pathname)
|
||||
return isImage && url.origin !== sw.location.origin
|
||||
},
|
||||
new StaleWhileRevalidate({
|
||||
cacheName: 'boris-images',
|
||||
plugins: [
|
||||
new ExpirationPlugin({
|
||||
maxEntries: 300,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days
|
||||
}),
|
||||
new CacheableResponsePlugin({
|
||||
statuses: [0, 200],
|
||||
}),
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
// Runtime cache: Cross-origin article HTML
|
||||
// Cache fetched articles for offline reading
|
||||
registerRoute(
|
||||
({ request, url }) => {
|
||||
const accept = request.headers.get('accept') || ''
|
||||
const isHTML = accept.includes('text/html')
|
||||
const isCrossOrigin = url.origin !== sw.location.origin
|
||||
// Exclude relay connections and local URLs
|
||||
const isNotRelay = !url.protocol.includes('ws')
|
||||
return isHTML && isCrossOrigin && isNotRelay
|
||||
},
|
||||
new StaleWhileRevalidate({
|
||||
cacheName: 'boris-articles',
|
||||
plugins: [
|
||||
new ExpirationPlugin({
|
||||
maxEntries: 100,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 14, // 14 days
|
||||
}),
|
||||
new CacheableResponsePlugin({
|
||||
statuses: [0, 200],
|
||||
}),
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
// SPA navigation fallback - serve app shell for navigation requests
|
||||
// This ensures the app loads offline
|
||||
const navigationRoute = new NavigationRoute(
|
||||
async ({ request }) => {
|
||||
try {
|
||||
// Try to fetch from network first
|
||||
const response = await fetch(request)
|
||||
return response
|
||||
} catch (error) {
|
||||
// If offline, serve the cached app shell
|
||||
const cache = await caches.match('/index.html')
|
||||
if (cache) {
|
||||
return cache
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
registerRoute(navigationRoute)
|
||||
|
||||
// Listen for messages from the app
|
||||
sw.addEventListener('message', (event: ExtendableMessageEvent) => {
|
||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||
sw.skipWaiting()
|
||||
}
|
||||
})
|
||||
|
||||
// Log fetch errors for debugging (doesn't affect functionality)
|
||||
sw.addEventListener('fetch', (event: FetchEvent) => {
|
||||
const url = new URL(event.request.url)
|
||||
|
||||
// Don't interfere with WebSocket connections (relay traffic)
|
||||
if (url.protocol === 'ws:' || url.protocol === 'wss:') {
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
@@ -15,5 +15,11 @@ export interface Highlight {
|
||||
comment?: string // optional comment about the highlight
|
||||
// Level classification (computed based on user's context)
|
||||
level?: HighlightLevel
|
||||
// Relay tracking for offline/local-only highlights
|
||||
publishedRelays?: string[] // URLs of relays where this was published (for user-created highlights)
|
||||
seenOnRelays?: string[] // URLs of relays where this event was fetched from
|
||||
isLocalOnly?: boolean // true if only published to local relays
|
||||
isOfflineCreated?: boolean // true if created while in flight mode (offline)
|
||||
isSyncing?: boolean // true if currently being synced to remote relays
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { formatDistanceToNow, differenceInSeconds, differenceInMinutes, differenceInHours, differenceInDays, differenceInMonths, differenceInYears } from 'date-fns'
|
||||
import { ParsedContent, ParsedNode } from '../types/bookmarks'
|
||||
import ResolvedMention from '../components/ResolvedMention'
|
||||
// Note: ContentWithResolvedProfiles is imported by components directly to keep this file component-only for fast refresh
|
||||
@@ -9,6 +9,26 @@ export const formatDate = (timestamp: number) => {
|
||||
return formatDistanceToNow(date, { addSuffix: true })
|
||||
}
|
||||
|
||||
// Ultra-compact date format for tight spaces (e.g., compact view)
|
||||
export const formatDateCompact = (timestamp: number) => {
|
||||
const date = new Date(timestamp * 1000)
|
||||
const now = new Date()
|
||||
|
||||
const seconds = differenceInSeconds(now, date)
|
||||
const minutes = differenceInMinutes(now, date)
|
||||
const hours = differenceInHours(now, date)
|
||||
const days = differenceInDays(now, date)
|
||||
const months = differenceInMonths(now, date)
|
||||
const years = differenceInYears(now, date)
|
||||
|
||||
if (seconds < 60) return 'now'
|
||||
if (minutes < 60) return `${minutes}m`
|
||||
if (hours < 24) return `${hours}h`
|
||||
if (days < 30) return `${days}d`
|
||||
if (months < 12) return `${months}mo`
|
||||
return `${years}y`
|
||||
}
|
||||
|
||||
// Component to render content with resolved nprofile names
|
||||
// Intentionally no exports except components and render helpers
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ export async function loadContent(
|
||||
title: article.title,
|
||||
markdown: article.markdown,
|
||||
image: article.image,
|
||||
summary: article.summary,
|
||||
url: `nostr:${naddr}`
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -40,3 +40,26 @@ export const classifyUrl = (url: string | undefined): UrlClassification => {
|
||||
return { type: 'article', buttonText: 'READ NOW' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a relay URL is a local relay (localhost or 127.0.0.1)
|
||||
*/
|
||||
export const isLocalRelay = (relayUrl: string): boolean => {
|
||||
return relayUrl.includes('localhost') || relayUrl.includes('127.0.0.1')
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if all relays in the list are local relays
|
||||
*/
|
||||
export const areAllRelaysLocal = (relayUrls: string[]): boolean => {
|
||||
if (!relayUrls || relayUrls.length === 0) return false
|
||||
return relayUrls.every(isLocalRelay)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if at least one relay is a remote (non-local) relay
|
||||
*/
|
||||
export const hasRemoteRelay = (relayUrls: string[]): boolean => {
|
||||
if (!relayUrls || relayUrls.length === 0) return false
|
||||
return relayUrls.some(url => !isLocalRelay(url))
|
||||
}
|
||||
|
||||
|
||||
34
src/utils/highlightClassification.ts
Normal 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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
84
src/utils/highlightMatching/domUtils.ts
Normal 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
|
||||
}
|
||||
|
||||
60
src/utils/highlightMatching/htmlMatching.ts
Normal 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
|
||||
}
|
||||
|
||||
46
src/utils/highlightMatching/textMatching.ts
Normal 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)
|
||||
}
|
||||
|
||||