mirror of
https://github.com/dergigi/boris.git
synced 2026-02-22 23:44:51 +01:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa72ac44c8 | ||
|
|
44fb07033b | ||
|
|
7e2d412869 | ||
|
|
19021af49a | ||
|
|
bdbb89c50e | ||
|
|
687f60db3f | ||
|
|
8ee7d347be | ||
|
|
8e9242e6f2 | ||
|
|
1df3962064 | ||
|
|
4edc22cec2 | ||
|
|
82977fa5d4 | ||
|
|
1a84817453 | ||
|
|
a0b98231b7 | ||
|
|
d452f96f79 | ||
|
|
dcf43cfce1 | ||
|
|
815b3cc57d | ||
|
|
7e54a01237 | ||
|
|
ec4692da15 | ||
|
|
f6d2f98eae | ||
|
|
9b97715274 | ||
|
|
fa1e536a26 | ||
|
|
238aac1921 | ||
|
|
29edd159e7 | ||
|
|
a3edb64e4c |
20
.cursor/rules/zaps-and-zap-splits.mdc
Normal file
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%
|
||||||
384
CHANGELOG.md
Normal file
384
CHANGELOG.md
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.2.6] - 2025-10-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Home button to bookmark bar
|
||||||
|
- Configurable zap split for highlights on nostr-native content
|
||||||
|
|
||||||
|
## [0.2.5] - 2025-10-07
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Wire preview ref to markdown conversion hook
|
||||||
|
- Add missing useEffect dependencies for article loading
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- DRY up highlight classification and URL normalization
|
||||||
|
- Split highlighting utilities into modules
|
||||||
|
- Extract highlights panel components
|
||||||
|
- Extract content rendering hooks
|
||||||
|
- Split Settings into section components
|
||||||
|
- Extract event processing utilities
|
||||||
|
- Split Bookmarks.tsx into smaller hooks and components
|
||||||
|
|
||||||
|
## [0.2.4] - 2025-10-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Domain configuration for https://xn--bris-v0b.com/
|
||||||
|
- Public assets and deployment configuration
|
||||||
|
- Hide bookmarks without content or URL
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Encode/decode URLs in /r/ route to preserve special characters
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Cleanup after build fixes (remove shims, update locks)
|
||||||
|
- Stop tracking node_modules/dist
|
||||||
|
- Update dependencies and dedupe
|
||||||
|
- Add .gitignore for node_modules and dist
|
||||||
|
|
||||||
|
## [0.2.3] - 2025-10-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Parse and display summary tag for nostr articles
|
||||||
|
- Merge and flatten bookmarks from multiple lists
|
||||||
|
- Update URL path when opening bookmarks from sidebar
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Ensure bookmarks are sorted newest first after merging lists
|
||||||
|
- Hide empty bookmarks without content
|
||||||
|
- Remove encrypted cyphertext display from bookmark list
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Remove created date from bookmark list display
|
||||||
|
|
||||||
|
## [0.2.2] - 2025-10-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Support for web bookmarks (NIP-B0, kind:39701)
|
||||||
|
- Default highlight visibility settings
|
||||||
|
- Proxy.nostr-relay.app relay to configuration
|
||||||
|
- Comprehensive logging to settings service
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Handle web bookmarks with URLs in d tag and prevent crash
|
||||||
|
- Load settings from local cache first to eliminate FOUT
|
||||||
|
- Ensure fonts are fully loaded before applying styles
|
||||||
|
- Improve highlight rendering pipeline with comprehensive debugging
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Use icon toggle buttons for highlight visibility settings
|
||||||
|
- Change nostrverse icon from fa-globe to fa-network-wired
|
||||||
|
|
||||||
|
## [0.2.1] - 2025-10-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Local relay support and centralize relay configuration
|
||||||
|
- Optimistic updates for highlight creation
|
||||||
|
- Enable highlight creation from external URLs
|
||||||
|
- Add routing support for external URLs
|
||||||
|
- Add context to highlights (previous and next sentences)
|
||||||
|
- Boris branding to highlight alt tag
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Properly await account loading from localStorage on refresh
|
||||||
|
- Add protected routes to prevent logout on page refresh
|
||||||
|
- Use undo icon for reset to defaults button
|
||||||
|
- Update local relay port to 10547
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Remove dedicated login page, handle login through main UI
|
||||||
|
- Simplify to single RELAYS constant (DRY)
|
||||||
|
|
||||||
|
## [0.2.0] - 2025-10-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Simple highlight creation feature (FAB style)
|
||||||
|
- Reset to defaults button in settings
|
||||||
|
- Load and apply settings upon login
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Replace any types with proper NostrEvent types
|
||||||
|
- Move FAB to Bookmarks component for proper floating
|
||||||
|
- Highlight button positioning with scroll
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Update color palette to include default friends/nostrverse colors
|
||||||
|
- Show author name in highlight cards
|
||||||
|
- Sync highlight level toggles between sidebar and main article text
|
||||||
|
- Rename 'underlines' to 'highlights' throughout codebase
|
||||||
|
|
||||||
|
## [0.1.11] - 2025-10-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Stream highlights progressively as they arrive from relays
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Display article immediately without waiting for highlights to load
|
||||||
|
- Show highlights immediately when opening panel if already loaded
|
||||||
|
- Prevent bookmark text from being cut off in compact view
|
||||||
|
- Correct default highlight color for 'mine' to yellow (#ffff00)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Reduce padding between bookmark items and panel edge
|
||||||
|
- Update default highlight colors to orange for friends and purple for nostrverse
|
||||||
|
|
||||||
|
## [0.1.10] - 2025-10-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Three-level highlight system (mine/friends/nostrverse)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Ensure highlights always render on markdown content
|
||||||
|
- Classify highlights before passing to ContentPanel
|
||||||
|
- Position toggle buttons directly adjacent to main panel
|
||||||
|
- Remove redundant setReaderLoading call in error handler
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Always show friends and user highlight buttons
|
||||||
|
- Remove Highlights title and count from panel
|
||||||
|
|
||||||
|
## [0.1.9] - 2025-10-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Show markdown content immediately when finalHtml is empty
|
||||||
|
- Prevent highlight bleeding into sidebar
|
||||||
|
|
||||||
|
## [0.1.8] - 2025-10-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Prevent 'No readable content' flash for markdown articles
|
||||||
|
- Enable highlights display and scroll-to for markdown content
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Persist accounts to localStorage
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Simplify login by handling it directly in sidebar
|
||||||
|
|
||||||
|
## [0.1.7] - 2025-10-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Show highlights in article content and add mode toggle
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Show highlights for nostr articles by skipping URL filter
|
||||||
|
- Refresh button now works without login for article highlights
|
||||||
|
- Query highlights using both a-tag and e-tag
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Keep Bookmarks.tsx under 210 lines by extracting logic
|
||||||
|
|
||||||
|
## [0.1.6] - 2025-10-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Native support for rendering Nostr long-form articles (NIP-23)
|
||||||
|
- Display article titles for kind:30023 bookmarks
|
||||||
|
- Enable clicking on kind:30023 articles to open in reader
|
||||||
|
- Display article hero images in bookmark views and reader
|
||||||
|
- Configurable highlight colors
|
||||||
|
- Highlight style setting (marker & underline)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Use bookmark pubkey for article author instead of tag lookup
|
||||||
|
- Ensure highlight color CSS variable inherits from parent
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Integrate long-form article rendering into existing reader view
|
||||||
|
- Extract components to keep files under 210 lines
|
||||||
|
- Make font size and color buttons match icon button size (33px)
|
||||||
|
|
||||||
|
## [0.1.5] - 2025-10-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Settings panel with NIP-78 storage
|
||||||
|
- Auto-save for settings with toast notifications
|
||||||
|
- Reading time estimate to articles
|
||||||
|
- Font size setting
|
||||||
|
- Configurable reading font using Bunny Fonts
|
||||||
|
- Live preview of reading font in settings
|
||||||
|
- Settings subscription to watch for Nostr updates
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Prevent settings from saving unnecessarily
|
||||||
|
- Prevent save settings button from being cut off
|
||||||
|
- Replace custom reading time with reading-time-estimator package
|
||||||
|
- Update originalHtmlRef when content changes
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Reduce file sizes to meet 210 line limit
|
||||||
|
- Extract settings logic into custom hook
|
||||||
|
- Consolidate settings initialization on login
|
||||||
|
- Remove debounce from settings auto-save
|
||||||
|
|
||||||
|
## [0.1.4] - 2025-10-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Inline highlight annotations in content panel
|
||||||
|
- NIP-84 highlights panel with three-pane layout
|
||||||
|
- Toggle button to show/hide highlight underlines
|
||||||
|
- Click-to-scroll for highlights
|
||||||
|
- Pulsing animation when scrolling to highlight
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Apply highlights to markdown content as well as HTML
|
||||||
|
- Use requestAnimationFrame for highlight DOM manipulation
|
||||||
|
- Improve HTML highlight matching with DOM manipulation
|
||||||
|
- Filter highlights panel to show only current article
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Use applesauce helpers for highlight parsing
|
||||||
|
- DRY up highlightMatching to stay under 210 lines
|
||||||
|
- Change highlights to fluorescent marker style
|
||||||
|
- Deduplicate highlight events by ID
|
||||||
|
|
||||||
|
## [0.1.3] - 2025-10-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- View mode switching for bookmarks with compact list view
|
||||||
|
- Large preview view mode
|
||||||
|
- Image preview for large view cards
|
||||||
|
- Hero images using free CORS proxy
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Make entire compact list row clickable to open reader
|
||||||
|
- Make card view timestamp clickable to open event
|
||||||
|
- Enhance card view design with modern styling
|
||||||
|
|
||||||
|
## [0.1.2] - 2025-10-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Open bookmark URLs in reader instead of new window
|
||||||
|
- localStorage caching for fetched articles
|
||||||
|
- Collapsible bookmarks sidebar
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Make sidebar and reader scroll independently
|
||||||
|
- Replace relative-time with date-fns for timestamp formatting
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Display timestamps as relative time
|
||||||
|
- Replace user text with profile image in sidebar header
|
||||||
|
- Move user info and logout to sidebar header bar
|
||||||
|
- Reduce IconButton size by 25%
|
||||||
|
|
||||||
|
## [0.1.1] - 2025-10-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Classify URLs by type and adjust action buttons
|
||||||
|
- Collapse/expand functionality for bookmarks sidebar
|
||||||
|
- IconButton component with square styling
|
||||||
|
- Resolve nprofile/npub mentions to names in content
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Enforce 210-char truncation for both plain and parsed content
|
||||||
|
- Use Rules of Hooks correctly
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Use IconButton for all icon-only actions to enforce square sizing
|
||||||
|
- Sort bookmarks by added_at (recently added first)
|
||||||
|
- Make kind icon square to match IconButton sizing
|
||||||
|
- Remove colored borders and gradients; keep neutral cards
|
||||||
|
|
||||||
|
## [0.1.0] - 2025-10-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Two-pane layout and content fetching pipeline
|
||||||
|
- ContentPanel component to render readable HTML
|
||||||
|
- Lightweight readability fetcher via r.jina.ai proxy
|
||||||
|
- Markdown rendering support with react-markdown and remark-gfm
|
||||||
|
- READ NOW button to bookmark cards
|
||||||
|
- Spinner to content loading state
|
||||||
|
- FontAwesome icons for event kinds
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Show bookmarked event author instead of list signer
|
||||||
|
- Enable reactive profile fetch via address loader
|
||||||
|
- Left-align content and constrain images in content panel
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Resolve author names using applesauce ProfileModel
|
||||||
|
- Propagate URL selection through BookmarkList to parent
|
||||||
|
- Display URLs clearly in individual bookmarks
|
||||||
|
|
||||||
|
## [0.0.3] - 2025-10-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Manual decryption for unrecognized event kinds
|
||||||
|
- Try NIP-44 then NIP-04 for manual decryption
|
||||||
|
- Detailed debugging for decryption process
|
||||||
|
- Support for hidden bookmarks decryption
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Surface manually decrypted hidden tags in UI
|
||||||
|
- Dedupe individual bookmarks by id
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Sort individual bookmarks by timestamp (newest first)
|
||||||
|
- Increase bookmark loading timeout by 2x
|
||||||
|
- Extract helpers and event processing
|
||||||
|
|
||||||
|
## [0.0.2] - 2025-10-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Fetch all NIP-51 events
|
||||||
|
- Unlock private bookmarks via applesauce helpers
|
||||||
|
- Copy-to-clipboard icons for event id and author pubkey
|
||||||
|
- FontAwesome globe/lock icons
|
||||||
|
- Display content identically for private/public bookmarks
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Properly configure browser extension signer
|
||||||
|
- Aggregate list(10003) + set(30001)
|
||||||
|
- Handle applesauce bookmark structure correctly
|
||||||
|
- Resolve loading state stuck issue
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Change bookmarks display from grid to social feed list layout
|
||||||
|
- Simplify bookmark service using applesauce helpers
|
||||||
|
- Extract components and utilities to keep files under 210 lines
|
||||||
|
|
||||||
|
## [0.0.1] - 2025-10-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial release
|
||||||
|
- Browser extension login support
|
||||||
|
- NIP-51 bookmark fetching from nostr relays
|
||||||
|
- User profile display
|
||||||
|
- Relay pool configuration
|
||||||
|
- Basic UI with profile resolution
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Migrate to applesauce-accounts for proper account management
|
||||||
|
- Use proper applesauce-loaders for NIP-51 bookmark fetching
|
||||||
|
- Optimize relay usage following applesauce-relay best practices
|
||||||
|
- Use applesauce-react event models for better profile handling
|
||||||
|
|
||||||
|
[0.2.6]: https://github.com/dergigi/boris/compare/v0.2.5...v0.2.6
|
||||||
|
[0.2.5]: https://github.com/dergigi/boris/compare/v0.2.4...v0.2.5
|
||||||
|
[0.2.4]: https://github.com/dergigi/boris/compare/v0.2.3...v0.2.4
|
||||||
|
[0.2.3]: https://github.com/dergigi/boris/compare/v0.2.2...v0.2.3
|
||||||
|
[0.2.2]: https://github.com/dergigi/boris/compare/v0.2.1...v0.2.2
|
||||||
|
[0.2.1]: https://github.com/dergigi/boris/compare/v0.2.0...v0.2.1
|
||||||
|
[0.2.0]: https://github.com/dergigi/boris/compare/v0.1.11...v0.2.0
|
||||||
|
[0.1.11]: https://github.com/dergigi/boris/compare/v0.1.10...v0.1.11
|
||||||
|
[0.1.10]: https://github.com/dergigi/boris/compare/v0.1.9...v0.1.10
|
||||||
|
[0.1.9]: https://github.com/dergigi/boris/compare/v0.1.8...v0.1.9
|
||||||
|
[0.1.8]: https://github.com/dergigi/boris/compare/v0.1.7...v0.1.8
|
||||||
|
[0.1.7]: https://github.com/dergigi/boris/compare/v0.1.6...v0.1.7
|
||||||
|
[0.1.6]: https://github.com/dergigi/boris/compare/v0.1.5...v0.1.6
|
||||||
|
[0.1.5]: https://github.com/dergigi/boris/compare/v0.1.4...v0.1.5
|
||||||
|
[0.1.4]: https://github.com/dergigi/boris/compare/v0.1.3...v0.1.4
|
||||||
|
[0.1.3]: https://github.com/dergigi/boris/compare/v0.1.2...v0.1.3
|
||||||
|
[0.1.2]: https://github.com/dergigi/boris/compare/v0.1.1...v0.1.2
|
||||||
|
[0.1.1]: https://github.com/dergigi/boris/compare/v0.1.0...v0.1.1
|
||||||
|
[0.1.0]: https://github.com/dergigi/boris/compare/v0.0.3...v0.1.0
|
||||||
|
[0.0.3]: https://github.com/dergigi/boris/compare/v0.0.2...v0.0.3
|
||||||
|
[0.0.2]: https://github.com/dergigi/boris/compare/v0.0.1...v0.0.2
|
||||||
|
[0.0.1]: https://github.com/dergigi/boris/releases/tag/v0.0.1
|
||||||
|
|
||||||
@@ -35,6 +35,7 @@ If you bookmark something on nostr, Boris will show it in the bookmarks bar. If
|
|||||||
- Collects your saved links from Nostr and shows them as a tidy reading list
|
- Collects your saved links from Nostr and shows them as a tidy reading list
|
||||||
- Opens articles in a distraction‑free reader with clear typography
|
- Opens articles in a distraction‑free reader with clear typography
|
||||||
- Shows community highlights layered on the article (yours, friends, everyone)
|
- 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
|
- Lets you collapse sidebars anytime for full‑focus reading
|
||||||
- Remembers simple preferences like view mode, fonts, and highlight style
|
- Remembers simple preferences like view mode, fonts, and highlight style
|
||||||
|
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.2.5",
|
"version": "0.2.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.2.5",
|
"version": "0.2.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.2.6",
|
"version": "0.2.7",
|
||||||
"description": "A minimal nostr client for bookmark management",
|
"description": "A minimal nostr client for bookmark management",
|
||||||
"homepage": "https://xn--bris-v0b.com/",
|
"homepage": "https://xn--bris-v0b.com/",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
287
src/components/AddBookmarkModal.tsx
Normal file
287
src/components/AddBookmarkModal.tsx
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faTimes, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import IconButton from './IconButton'
|
||||||
|
import { fetchReadableContent } from '../services/readerService'
|
||||||
|
|
||||||
|
interface AddBookmarkModalProps {
|
||||||
|
onClose: () => void
|
||||||
|
onSave: (url: string, title?: string, description?: string, tags?: string[]) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to extract metadata from HTML
|
||||||
|
function extractMetaTag(html: string, patterns: string[]): string | null {
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = html.match(new RegExp(pattern, 'i'))
|
||||||
|
if (match) return match[1]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTags(html: string): string[] {
|
||||||
|
const tags: string[] = []
|
||||||
|
|
||||||
|
// Extract keywords meta tag
|
||||||
|
const keywords = extractMetaTag(html, [
|
||||||
|
'<meta\\s+name=["\'"]keywords["\'"]\\s+content=["\'"]([^"\']+)["\']'
|
||||||
|
])
|
||||||
|
if (keywords) {
|
||||||
|
keywords.split(/[,;]/)
|
||||||
|
.map(k => k.trim().toLowerCase())
|
||||||
|
.filter(k => k.length > 0 && k.length < 30)
|
||||||
|
.forEach(k => tags.push(k))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract article:tag (multiple possible)
|
||||||
|
const articleTagRegex = /<meta\s+property=["']article:tag["']\s+content=["']([^"']+)["']/gi
|
||||||
|
let match
|
||||||
|
while ((match = articleTagRegex.exec(html)) !== null) {
|
||||||
|
const tag = match[1].trim().toLowerCase()
|
||||||
|
if (tag && tag.length < 30) tags.push(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(new Set(tags)).slice(0, 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave }) => {
|
||||||
|
const [url, setUrl] = useState('')
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [tagsInput, setTagsInput] = useState('')
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [isFetchingMetadata, setIsFetchingMetadata] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const fetchTimeoutRef = useRef<number | null>(null)
|
||||||
|
const lastFetchedUrlRef = useRef<string>('')
|
||||||
|
|
||||||
|
// Fetch metadata when URL changes
|
||||||
|
useEffect(() => {
|
||||||
|
// Clear any pending fetch
|
||||||
|
if (fetchTimeoutRef.current) {
|
||||||
|
clearTimeout(fetchTimeoutRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't fetch if URL is empty or invalid
|
||||||
|
if (!url.trim()) return
|
||||||
|
|
||||||
|
// Validate URL format first
|
||||||
|
let parsedUrl: URL
|
||||||
|
try {
|
||||||
|
parsedUrl = new URL(url.trim())
|
||||||
|
} catch {
|
||||||
|
return // Invalid URL, don't fetch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if we've already fetched this URL
|
||||||
|
const normalizedUrl = parsedUrl.toString()
|
||||||
|
if (lastFetchedUrlRef.current === normalizedUrl) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce the fetch to avoid spamming the API
|
||||||
|
fetchTimeoutRef.current = window.setTimeout(async () => {
|
||||||
|
setIsFetchingMetadata(true)
|
||||||
|
try {
|
||||||
|
const content = await fetchReadableContent(normalizedUrl)
|
||||||
|
lastFetchedUrlRef.current = normalizedUrl
|
||||||
|
|
||||||
|
let extractedAnything = false
|
||||||
|
|
||||||
|
// Extract title: prioritize og:title > twitter:title > <title>
|
||||||
|
if (!title && content.html) {
|
||||||
|
const extractedTitle = extractMetaTag(content.html, [
|
||||||
|
'<meta\\s+property=["\'"]og:title["\'"]\\s+content=["\'"]([^"\']+)["\']',
|
||||||
|
'<meta\\s+name=["\'"]twitter:title["\'"]\\s+content=["\'"]([^"\']+)["\']'
|
||||||
|
]) || content.title
|
||||||
|
|
||||||
|
if (extractedTitle) {
|
||||||
|
setTitle(extractedTitle)
|
||||||
|
extractedAnything = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract description: prioritize og:description > twitter:description > meta description
|
||||||
|
if (!description && content.html) {
|
||||||
|
const extractedDesc = extractMetaTag(content.html, [
|
||||||
|
'<meta\\s+property=["\'"]og:description["\'"]\\s+content=["\'"]([^"\']+)["\']',
|
||||||
|
'<meta\\s+name=["\'"]twitter:description["\'"]\\s+content=["\'"]([^"\']+)["\']',
|
||||||
|
'<meta\\s+name=["\'"]description["\'"]\\s+content=["\'"]([^"\']+)["\']'
|
||||||
|
])
|
||||||
|
|
||||||
|
if (extractedDesc) {
|
||||||
|
setDescription(extractedDesc)
|
||||||
|
extractedAnything = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract tags from keywords and article:tag (only if user hasn't modified tags)
|
||||||
|
if (!tagsInput && content.html) {
|
||||||
|
const extractedTags = extractTags(content.html)
|
||||||
|
|
||||||
|
// Only add boris tag if we extracted something
|
||||||
|
if (extractedAnything || extractedTags.length > 0) {
|
||||||
|
const allTags = extractedTags.length > 0
|
||||||
|
? ['boris', ...extractedTags]
|
||||||
|
: ['boris']
|
||||||
|
setTagsInput(allTags.join(', '))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to fetch metadata:', err)
|
||||||
|
// Don't show error to user, just skip auto-fill
|
||||||
|
} finally {
|
||||||
|
setIsFetchingMetadata(false)
|
||||||
|
}
|
||||||
|
}, 800) // Wait 800ms after user stops typing
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (fetchTimeoutRef.current) {
|
||||||
|
clearTimeout(fetchTimeoutRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [url]) // Only depend on url
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
if (!url.trim()) {
|
||||||
|
setError('URL is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate URL
|
||||||
|
try {
|
||||||
|
new URL(url)
|
||||||
|
} catch {
|
||||||
|
setError('Please enter a valid URL')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSaving(true)
|
||||||
|
|
||||||
|
// Parse tags from comma-separated input
|
||||||
|
const tags = tagsInput
|
||||||
|
.split(',')
|
||||||
|
.map(tag => tag.trim())
|
||||||
|
.filter(tag => tag.length > 0)
|
||||||
|
|
||||||
|
await onSave(
|
||||||
|
url.trim(),
|
||||||
|
title.trim() || undefined,
|
||||||
|
description.trim() || undefined,
|
||||||
|
tags.length > 0 ? tags : undefined
|
||||||
|
)
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save bookmark:', err)
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to save bookmark')
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2>Add Bookmark</h2>
|
||||||
|
<IconButton
|
||||||
|
icon={faTimes}
|
||||||
|
onClick={onClose}
|
||||||
|
title="Close"
|
||||||
|
ariaLabel="Close modal"
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="modal-form">
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="bookmark-url">
|
||||||
|
URL *
|
||||||
|
{isFetchingMetadata && (
|
||||||
|
<span className="fetching-indicator">
|
||||||
|
<FontAwesomeIcon icon={faSpinner} spin /> Fetching details...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="bookmark-url"
|
||||||
|
type="text"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
disabled={isSaving}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="bookmark-title">Title</label>
|
||||||
|
<input
|
||||||
|
id="bookmark-title"
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Optional title"
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="bookmark-description">Description</label>
|
||||||
|
<textarea
|
||||||
|
id="bookmark-description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Optional description"
|
||||||
|
disabled={isSaving}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="bookmark-tags">Tags</label>
|
||||||
|
<input
|
||||||
|
id="bookmark-tags"
|
||||||
|
type="text"
|
||||||
|
value={tagsInput}
|
||||||
|
onChange={(e) => setTagsInput(e.target.value)}
|
||||||
|
placeholder="comma, separated, tags"
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
<div className="form-helper-text">
|
||||||
|
Separate tags with commas (e.g., "nostr, web3, article")
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="modal-error">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="btn-secondary"
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving...' : 'Save Bookmark'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AddBookmarkModal
|
||||||
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
|
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||||
import { BookmarkItem } from './BookmarkItem'
|
import { BookmarkItem } from './BookmarkItem'
|
||||||
import SidebarHeader from './SidebarHeader'
|
import SidebarHeader from './SidebarHeader'
|
||||||
@@ -21,6 +22,7 @@ interface BookmarkListProps {
|
|||||||
onRefresh?: () => void
|
onRefresh?: () => void
|
||||||
isRefreshing?: boolean
|
isRefreshing?: boolean
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
|
relayPool: RelayPool | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BookmarkList: React.FC<BookmarkListProps> = ({
|
export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||||
@@ -35,7 +37,8 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
loading = false
|
loading = false,
|
||||||
|
relayPool
|
||||||
}) => {
|
}) => {
|
||||||
// Helper to check if a bookmark has either content or a URL
|
// Helper to check if a bookmark has either content or a URL
|
||||||
const hasContentOrUrl = (ib: IndividualBookmark) => {
|
const hasContentOrUrl = (ib: IndividualBookmark) => {
|
||||||
@@ -98,6 +101,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
onOpenSettings={onOpenSettings}
|
onOpenSettings={onOpenSettings}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isRefreshing}
|
||||||
|
relayPool={relayPool}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
setIsHighlightsCollapsed(true)
|
setIsHighlightsCollapsed(true)
|
||||||
}}
|
}}
|
||||||
onRefresh={handleRefreshAll}
|
onRefresh={handleRefreshAll}
|
||||||
|
relayPool={relayPool}
|
||||||
readerLoading={readerLoading}
|
readerLoading={readerLoading}
|
||||||
readerContent={readerContent}
|
readerContent={readerContent}
|
||||||
selectedUrl={selectedUrl}
|
selectedUrl={selectedUrl}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { loadFont } from '../utils/fontLoader'
|
|||||||
import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
|
import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
|
||||||
import LayoutNavigationSettings from './Settings/LayoutNavigationSettings'
|
import LayoutNavigationSettings from './Settings/LayoutNavigationSettings'
|
||||||
import StartupPreferencesSettings from './Settings/StartupPreferencesSettings'
|
import StartupPreferencesSettings from './Settings/StartupPreferencesSettings'
|
||||||
|
import ZapSettings from './Settings/ZapSettings'
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: UserSettings = {
|
const DEFAULT_SETTINGS: UserSettings = {
|
||||||
collapseOnArticleOpen: true,
|
collapseOnArticleOpen: true,
|
||||||
@@ -23,7 +24,9 @@ const DEFAULT_SETTINGS: UserSettings = {
|
|||||||
defaultHighlightVisibilityNostrverse: true,
|
defaultHighlightVisibilityNostrverse: true,
|
||||||
defaultHighlightVisibilityFriends: true,
|
defaultHighlightVisibilityFriends: true,
|
||||||
defaultHighlightVisibilityMine: true,
|
defaultHighlightVisibilityMine: true,
|
||||||
zapSplitPercentage: 50,
|
zapSplitHighlighterWeight: 50,
|
||||||
|
zapSplitBorisWeight: 2.1,
|
||||||
|
zapSplitAuthorWeight: 50,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SettingsProps {
|
interface SettingsProps {
|
||||||
@@ -33,11 +36,39 @@ interface SettingsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||||
const [localSettings, setLocalSettings] = useState<UserSettings>(settings)
|
const [localSettings, setLocalSettings] = useState<UserSettings>(() => {
|
||||||
|
// Migrate old settings format to new weight-based format
|
||||||
|
const migrated = { ...settings }
|
||||||
|
const anySettings = migrated as Record<string, unknown>
|
||||||
|
if ('zapSplitPercentage' in anySettings && !('zapSplitHighlighterWeight' in migrated)) {
|
||||||
|
migrated.zapSplitHighlighterWeight = (anySettings.zapSplitPercentage as number) ?? 50
|
||||||
|
migrated.zapSplitAuthorWeight = 100 - ((anySettings.zapSplitPercentage as number) ?? 50)
|
||||||
|
}
|
||||||
|
if ('borisSupportPercentage' in anySettings && !('zapSplitBorisWeight' in migrated)) {
|
||||||
|
migrated.zapSplitBorisWeight = (anySettings.borisSupportPercentage as number) ?? 2.1
|
||||||
|
}
|
||||||
|
return migrated
|
||||||
|
})
|
||||||
const isInitialMount = useRef(true)
|
const isInitialMount = useRef(true)
|
||||||
|
const saveTimeoutRef = useRef<number | null>(null)
|
||||||
|
const isLocallyUpdating = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
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])
|
}, [settings])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -58,7 +89,30 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
|||||||
isInitialMount.current = false
|
isInitialMount.current = false
|
||||||
return
|
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])
|
}, [localSettings, onSave])
|
||||||
|
|
||||||
const handleResetToDefaults = () => {
|
const handleResetToDefaults = () => {
|
||||||
@@ -97,6 +151,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
|||||||
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
|
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
<LayoutNavigationSettings settings={localSettings} onUpdate={handleUpdate} />
|
<LayoutNavigationSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
<StartupPreferencesSettings settings={localSettings} onUpdate={handleUpdate} />
|
<StartupPreferencesSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
|
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -107,27 +107,6 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="setting-group">
|
|
||||||
<label className="setting-label">Zap Split for Highlights</label>
|
|
||||||
<div className="zap-split-container">
|
|
||||||
<div className="zap-split-labels">
|
|
||||||
<span className="zap-split-label">You: {settings.zapSplitPercentage ?? 50}%</span>
|
|
||||||
<span className="zap-split-label">Author: {100 - (settings.zapSplitPercentage ?? 50)}%</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
value={settings.zapSplitPercentage ?? 50}
|
|
||||||
onChange={(e) => onUpdate({ zapSplitPercentage: parseInt(e.target.value) })}
|
|
||||||
className="zap-split-slider"
|
|
||||||
/>
|
|
||||||
<div className="zap-split-description">
|
|
||||||
When highlighting nostr-native content, zaps will be split between you and the author.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="setting-preview">
|
<div className="setting-preview">
|
||||||
<div className="preview-label">Preview</div>
|
<div className="preview-label">Preview</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
130
src/components/Settings/ZapSettings.tsx
Normal file
130
src/components/Settings/ZapSettings.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { UserSettings } from '../../services/settingsService'
|
||||||
|
|
||||||
|
interface ZapSettingsProps {
|
||||||
|
settings: UserSettings
|
||||||
|
onUpdate: (updates: Partial<UserSettings>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZapSettings: React.FC<ZapSettingsProps> = ({ settings, onUpdate }) => {
|
||||||
|
const highlighterWeight = settings.zapSplitHighlighterWeight ?? 50
|
||||||
|
const borisWeight = settings.zapSplitBorisWeight ?? 2.1
|
||||||
|
const authorWeight = settings.zapSplitAuthorWeight ?? 50
|
||||||
|
|
||||||
|
// Calculate actual percentages from weights
|
||||||
|
const totalWeight = highlighterWeight + borisWeight + authorWeight
|
||||||
|
const highlighterPercentage = totalWeight > 0 ? (highlighterWeight / totalWeight) * 100 : 0
|
||||||
|
const borisPercentage = totalWeight > 0 ? (borisWeight / totalWeight) * 100 : 0
|
||||||
|
const authorPercentage = totalWeight > 0 ? (authorWeight / totalWeight) * 100 : 0
|
||||||
|
|
||||||
|
const applyPreset = (preset: { highlighter: number; boris: number; author: number }) => {
|
||||||
|
onUpdate({
|
||||||
|
zapSplitHighlighterWeight: preset.highlighter,
|
||||||
|
zapSplitBorisWeight: preset.boris,
|
||||||
|
zapSplitAuthorWeight: preset.author,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Zap Splits</h3>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label className="setting-label">Presets</label>
|
||||||
|
<div className="zap-preset-buttons">
|
||||||
|
<button
|
||||||
|
onClick={() => applyPreset({ highlighter: 50, boris: 2.1, author: 50 })}
|
||||||
|
className="zap-preset-btn"
|
||||||
|
title="You: 49%, Author: 49%, Boris: 2%"
|
||||||
|
>
|
||||||
|
Default
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => applyPreset({ highlighter: 5, boris: 10, author: 75 })}
|
||||||
|
className="zap-preset-btn"
|
||||||
|
title="You: 6%, Author: 83%, Boris: 11%"
|
||||||
|
>
|
||||||
|
Generous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => applyPreset({ highlighter: 1, boris: 19, author: 80 })}
|
||||||
|
className="zap-preset-btn"
|
||||||
|
title="You: 1%, Author: 80%, Boris: 19%"
|
||||||
|
>
|
||||||
|
Selfless
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => applyPreset({ highlighter: 10, boris: 80, author: 10 })}
|
||||||
|
className="zap-preset-btn"
|
||||||
|
title="You: 10%, Author: 10%, Boris: 80%"
|
||||||
|
>
|
||||||
|
Boris 🧡
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label className="setting-label">Your Share</label>
|
||||||
|
<div className="zap-split-container">
|
||||||
|
<div className="zap-split-labels">
|
||||||
|
<span className="zap-split-label">Weight: {highlighterWeight}</span>
|
||||||
|
<span className="zap-split-label">({highlighterPercentage.toFixed(1)}%)</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={highlighterWeight}
|
||||||
|
onChange={(e) => onUpdate({ zapSplitHighlighterWeight: parseInt(e.target.value) })}
|
||||||
|
className="zap-split-slider"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label className="setting-label">Author(s) Share</label>
|
||||||
|
<div className="zap-split-container">
|
||||||
|
<div className="zap-split-labels">
|
||||||
|
<span className="zap-split-label">Weight: {authorWeight}</span>
|
||||||
|
<span className="zap-split-label">({authorPercentage.toFixed(1)}%)</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={authorWeight}
|
||||||
|
onChange={(e) => onUpdate({ zapSplitAuthorWeight: parseInt(e.target.value) })}
|
||||||
|
className="zap-split-slider"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label className="setting-label">Support Boris</label>
|
||||||
|
<div className="zap-split-container">
|
||||||
|
<div className="zap-split-labels">
|
||||||
|
<span className="zap-split-label">Weight: {borisWeight.toFixed(1)}</span>
|
||||||
|
<span className="zap-split-label">({borisPercentage.toFixed(1)}%)</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="10"
|
||||||
|
step="0.1"
|
||||||
|
value={borisWeight}
|
||||||
|
onChange={(e) => onUpdate({ zapSplitBorisWeight: parseFloat(e.target.value) })}
|
||||||
|
className="zap-split-slider"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="zap-split-description">
|
||||||
|
Weights determine zap splits when highlighting nostr-native content.
|
||||||
|
If the content has multiple authors, their share is divided proportionally.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ZapSettings
|
||||||
|
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faRotate, faHome } from '@fortawesome/free-solid-svg-icons'
|
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faRotate, faHome, faPlus } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
import { Models } from 'applesauce-core'
|
import { Models } from 'applesauce-core'
|
||||||
import { Accounts } from 'applesauce-accounts'
|
import { Accounts } from 'applesauce-accounts'
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
|
import AddBookmarkModal from './AddBookmarkModal'
|
||||||
|
import { createWebBookmark } from '../services/webBookmarkService'
|
||||||
|
import { RELAYS } from '../config/relays'
|
||||||
|
|
||||||
interface SidebarHeaderProps {
|
interface SidebarHeaderProps {
|
||||||
onToggleCollapse: () => void
|
onToggleCollapse: () => void
|
||||||
@@ -14,10 +18,12 @@ interface SidebarHeaderProps {
|
|||||||
onOpenSettings: () => void
|
onOpenSettings: () => void
|
||||||
onRefresh?: () => void
|
onRefresh?: () => void
|
||||||
isRefreshing?: boolean
|
isRefreshing?: boolean
|
||||||
|
relayPool: RelayPool | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, onRefresh, isRefreshing }) => {
|
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, onRefresh, isRefreshing, relayPool }) => {
|
||||||
const [isConnecting, setIsConnecting] = useState(false)
|
const [isConnecting, setIsConnecting] = useState(false)
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
const accountManager = Hooks.useAccountManager()
|
const accountManager = Hooks.useAccountManager()
|
||||||
@@ -49,6 +55,19 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
|||||||
return `${activeAccount.pubkey.slice(0, 8)}...${activeAccount.pubkey.slice(-8)}`
|
return `${activeAccount.pubkey.slice(0, 8)}...${activeAccount.pubkey.slice(-8)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
|
||||||
|
if (!activeAccount || !relayPool) {
|
||||||
|
throw new Error('Please login to create bookmarks')
|
||||||
|
}
|
||||||
|
|
||||||
|
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
|
||||||
|
|
||||||
|
// Refresh bookmarks after creating
|
||||||
|
if (onRefresh) {
|
||||||
|
onRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const profileImage = getProfileImage()
|
const profileImage = getProfileImage()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -63,31 +82,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
|||||||
<FontAwesomeIcon icon={faChevronRight} />
|
<FontAwesomeIcon icon={faChevronRight} />
|
||||||
</button>
|
</button>
|
||||||
<div className="sidebar-header-right">
|
<div className="sidebar-header-right">
|
||||||
<IconButton
|
|
||||||
icon={faHome}
|
|
||||||
onClick={() => navigate('/')}
|
|
||||||
title="Home"
|
|
||||||
ariaLabel="Home"
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
{onRefresh && (
|
|
||||||
<IconButton
|
|
||||||
icon={faRotate}
|
|
||||||
onClick={onRefresh}
|
|
||||||
title="Refresh bookmarks"
|
|
||||||
ariaLabel="Refresh bookmarks"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={isRefreshing}
|
|
||||||
spin={isRefreshing}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<IconButton
|
|
||||||
icon={faGear}
|
|
||||||
onClick={onOpenSettings}
|
|
||||||
title="Settings"
|
|
||||||
ariaLabel="Settings"
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
<div
|
<div
|
||||||
className="profile-avatar"
|
className="profile-avatar"
|
||||||
title={activeAccount ? getUserDisplayName() : "Login"}
|
title={activeAccount ? getUserDisplayName() : "Login"}
|
||||||
@@ -100,6 +94,40 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
|||||||
<FontAwesomeIcon icon={faUserCircle} />
|
<FontAwesomeIcon icon={faUserCircle} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<IconButton
|
||||||
|
icon={faHome}
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
title="Home"
|
||||||
|
ariaLabel="Home"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{onRefresh && (
|
||||||
|
<IconButton
|
||||||
|
icon={faRotate}
|
||||||
|
onClick={onRefresh}
|
||||||
|
title="Refresh bookmarks"
|
||||||
|
ariaLabel="Refresh bookmarks"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={isRefreshing}
|
||||||
|
spin={isRefreshing}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{activeAccount ? (
|
{activeAccount ? (
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={faRightFromBracket}
|
icon={faRightFromBracket}
|
||||||
@@ -119,6 +147,12 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{showAddModal && (
|
||||||
|
<AddBookmarkModal
|
||||||
|
onClose={() => setShowAddModal(false)}
|
||||||
|
onSave={handleSaveBookmark}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { BookmarkList } from './BookmarkList'
|
import { BookmarkList } from './BookmarkList'
|
||||||
import ContentPanel from './ContentPanel'
|
import ContentPanel from './ContentPanel'
|
||||||
import { HighlightsPanel } from './HighlightsPanel'
|
import { HighlightsPanel } from './HighlightsPanel'
|
||||||
@@ -30,6 +31,7 @@ interface ThreePaneLayoutProps {
|
|||||||
onViewModeChange: (mode: ViewMode) => void
|
onViewModeChange: (mode: ViewMode) => void
|
||||||
onOpenSettings: () => void
|
onOpenSettings: () => void
|
||||||
onRefresh: () => void
|
onRefresh: () => void
|
||||||
|
relayPool: RelayPool | null
|
||||||
|
|
||||||
// Content pane
|
// Content pane
|
||||||
readerLoading: boolean
|
readerLoading: boolean
|
||||||
@@ -86,6 +88,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
onRefresh={props.onRefresh}
|
onRefresh={props.onRefresh}
|
||||||
isRefreshing={props.isRefreshing}
|
isRefreshing={props.isRefreshing}
|
||||||
loading={props.bookmarksLoading}
|
loading={props.bookmarksLoading}
|
||||||
|
relayPool={props.relayPool}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="pane main">
|
<div className="pane main">
|
||||||
|
|||||||
195
src/index.css
195
src/index.css
@@ -2088,6 +2088,36 @@ body {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.zap-preset-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zap-preset-btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #2a2a2a;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zap-preset-btn:hover {
|
||||||
|
background: #333;
|
||||||
|
border-color: #646cff;
|
||||||
|
color: white;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zap-preset-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
.settings-footer {
|
.settings-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
@@ -2163,3 +2193,168 @@ body {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Add Bookmark Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10000;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 12px;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-form {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fetching-indicator {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #999;
|
||||||
|
font-weight: normal;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #2a2a2a;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #646cff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:disabled,
|
||||||
|
.form-group textarea:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-helper-text {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #999;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-error {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: rgba(220, 53, 69, 0.1);
|
||||||
|
border: 1px solid #dc3545;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #dc3545;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: #2a2a2a;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background: #333;
|
||||||
|
border-color: #646cff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: #646cff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: white;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: #535bf2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import { RELAYS } from '../config/relays'
|
|||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { UserSettings } from './settingsService'
|
import { UserSettings } from './settingsService'
|
||||||
|
|
||||||
|
// Boris pubkey for zap splits
|
||||||
|
const BORIS_PUBKEY = '6e468422dfb74a5738702a8823b9b28168fc6cfb119d613e49ca0ec5a0bbd0c3'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
getHighlightText,
|
getHighlightText,
|
||||||
getHighlightContext,
|
getHighlightContext,
|
||||||
@@ -76,8 +79,26 @@ export async function createHighlight(
|
|||||||
|
|
||||||
// Add zap tags for nostr-native content (NIP-57 Appendix G)
|
// Add zap tags for nostr-native content (NIP-57 Appendix G)
|
||||||
if (typeof source === 'object' && 'kind' in source) {
|
if (typeof source === 'object' && 'kind' in source) {
|
||||||
const zapSplitPercentage = settings?.zapSplitPercentage ?? 50
|
// Migrate old settings format to new weight-based format if needed
|
||||||
addZapTags(highlightEvent, account.pubkey, source.pubkey, zapSplitPercentage)
|
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
|
// Sign the event
|
||||||
@@ -186,31 +207,71 @@ function extractContext(selectedText: string, articleContent: string): string |
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds zap tags to a highlight event for split payments (NIP-57 Appendix G)
|
* Adds zap tags to a highlight event for split payments (NIP-57 Appendix G)
|
||||||
* @param event The highlight event to add zap tags to
|
* Respects existing zap tags in the source event (author group)
|
||||||
|
* @param event The highlight event to add zap tags to (can be EventTemplate or NostrEvent)
|
||||||
* @param highlighterPubkey The pubkey of the user creating the highlight
|
* @param highlighterPubkey The pubkey of the user creating the highlight
|
||||||
* @param authorPubkey The pubkey of the original article author
|
* @param sourceEvent The source event (may contain existing zap tags)
|
||||||
* @param highlighterPercentage Percentage (0-100) to give to the highlighter (default 50)
|
* @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(
|
function addZapTags(
|
||||||
event: NostrEvent,
|
event: { tags: string[][] },
|
||||||
highlighterPubkey: string,
|
highlighterPubkey: string,
|
||||||
authorPubkey: string,
|
sourceEvent: NostrEvent,
|
||||||
highlighterPercentage: number = 50
|
highlighterWeight: number = 50,
|
||||||
|
borisWeight: number = 2.1,
|
||||||
|
authorWeight: number = 50
|
||||||
): void {
|
): void {
|
||||||
// Calculate weights based on percentage
|
|
||||||
// Using simple integer weights where highlighterPercentage:authorPercentage ratio is maintained
|
|
||||||
const highlighterWeight = Math.round(highlighterPercentage)
|
|
||||||
const authorWeight = Math.round(100 - highlighterPercentage)
|
|
||||||
|
|
||||||
// Use a reliable relay for zap metadata lookup (first non-local relay)
|
// Use a reliable relay for zap metadata lookup (first non-local relay)
|
||||||
const zapRelay = RELAYS.find(r => !r.includes('localhost')) || RELAYS[0]
|
const zapRelay = RELAYS.find(r => !r.includes('localhost')) || RELAYS[0]
|
||||||
|
|
||||||
// Add zap tag for the highlighter
|
// Extract existing zap tags from source event (the "author group")
|
||||||
event.tags.push(['zap', highlighterPubkey, zapRelay, highlighterWeight.toString()])
|
const existingZapTags = sourceEvent.tags.filter(tag => tag[0] === 'zap')
|
||||||
|
|
||||||
// Add zap tag for the original author (only if different from highlighter)
|
// Add zap tag for the highlighter
|
||||||
if (authorPubkey !== highlighterPubkey) {
|
if (highlighterWeight > 0) {
|
||||||
event.tags.push(['zap', authorPubkey, zapRelay, authorWeight.toString()])
|
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)])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,8 +35,10 @@ export interface UserSettings {
|
|||||||
defaultHighlightVisibilityNostrverse?: boolean
|
defaultHighlightVisibilityNostrverse?: boolean
|
||||||
defaultHighlightVisibilityFriends?: boolean
|
defaultHighlightVisibilityFriends?: boolean
|
||||||
defaultHighlightVisibilityMine?: boolean
|
defaultHighlightVisibilityMine?: boolean
|
||||||
// Zap split percentage for highlights (0-100, default 50)
|
// Zap split weights (treated as relative weights, not strict percentages)
|
||||||
zapSplitPercentage?: number
|
zapSplitHighlighterWeight?: number // default 50
|
||||||
|
zapSplitBorisWeight?: number // default 2.1
|
||||||
|
zapSplitAuthorWeight?: number // default 50
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadSettings(
|
export async function loadSettings(
|
||||||
|
|||||||
90
src/services/webBookmarkService.ts
Normal file
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
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user