mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
27 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 | ||
|
|
1609416e1e | ||
|
|
00d9fbdbab | ||
|
|
239ab5763d |
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
|
||||
- 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
|
||||
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.2.5",
|
||||
"version": "0.2.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "boris",
|
||||
"version": "0.2.5",
|
||||
"version": "0.2.6",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.2.5",
|
||||
"version": "0.2.7",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://xn--bris-v0b.com/",
|
||||
"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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||
import { BookmarkItem } from './BookmarkItem'
|
||||
import SidebarHeader from './SidebarHeader'
|
||||
@@ -21,6 +22,7 @@ interface BookmarkListProps {
|
||||
onRefresh?: () => void
|
||||
isRefreshing?: boolean
|
||||
loading?: boolean
|
||||
relayPool: RelayPool | null
|
||||
}
|
||||
|
||||
export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
@@ -35,7 +37,8 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
onOpenSettings,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
loading = false
|
||||
loading = false,
|
||||
relayPool
|
||||
}) => {
|
||||
// Helper to check if a bookmark has either content or a URL
|
||||
const hasContentOrUrl = (ib: IndividualBookmark) => {
|
||||
@@ -98,6 +101,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
onOpenSettings={onOpenSettings}
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
relayPool={relayPool}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
|
||||
@@ -109,7 +109,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
currentArticle,
|
||||
selectedUrl,
|
||||
readerContent,
|
||||
onHighlightCreated: (highlight) => setHighlights(prev => [highlight, ...prev])
|
||||
onHighlightCreated: (highlight) => setHighlights(prev => [highlight, ...prev]),
|
||||
settings
|
||||
})
|
||||
|
||||
// Load nostr-native article if naddr is in URL
|
||||
@@ -164,6 +165,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
setIsHighlightsCollapsed(true)
|
||||
}}
|
||||
onRefresh={handleRefreshAll}
|
||||
relayPool={relayPool}
|
||||
readerLoading={readerLoading}
|
||||
readerContent={readerContent}
|
||||
selectedUrl={selectedUrl}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { loadFont } from '../utils/fontLoader'
|
||||
import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
|
||||
import LayoutNavigationSettings from './Settings/LayoutNavigationSettings'
|
||||
import StartupPreferencesSettings from './Settings/StartupPreferencesSettings'
|
||||
import ZapSettings from './Settings/ZapSettings'
|
||||
|
||||
const DEFAULT_SETTINGS: UserSettings = {
|
||||
collapseOnArticleOpen: true,
|
||||
@@ -23,6 +24,9 @@ const DEFAULT_SETTINGS: UserSettings = {
|
||||
defaultHighlightVisibilityNostrverse: true,
|
||||
defaultHighlightVisibilityFriends: true,
|
||||
defaultHighlightVisibilityMine: true,
|
||||
zapSplitHighlighterWeight: 50,
|
||||
zapSplitBorisWeight: 2.1,
|
||||
zapSplitAuthorWeight: 50,
|
||||
}
|
||||
|
||||
interface SettingsProps {
|
||||
@@ -32,11 +36,39 @@ interface SettingsProps {
|
||||
}
|
||||
|
||||
const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
const [localSettings, setLocalSettings] = useState<UserSettings>(settings)
|
||||
const [localSettings, setLocalSettings] = useState<UserSettings>(() => {
|
||||
// Migrate old settings format to new weight-based format
|
||||
const migrated = { ...settings }
|
||||
const anySettings = migrated as Record<string, unknown>
|
||||
if ('zapSplitPercentage' in anySettings && !('zapSplitHighlighterWeight' in migrated)) {
|
||||
migrated.zapSplitHighlighterWeight = (anySettings.zapSplitPercentage as number) ?? 50
|
||||
migrated.zapSplitAuthorWeight = 100 - ((anySettings.zapSplitPercentage as number) ?? 50)
|
||||
}
|
||||
if ('borisSupportPercentage' in anySettings && !('zapSplitBorisWeight' in migrated)) {
|
||||
migrated.zapSplitBorisWeight = (anySettings.borisSupportPercentage as number) ?? 2.1
|
||||
}
|
||||
return migrated
|
||||
})
|
||||
const isInitialMount = useRef(true)
|
||||
const saveTimeoutRef = useRef<number | null>(null)
|
||||
const isLocallyUpdating = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSettings(settings)
|
||||
// Don't update from external settings if we're currently making local changes
|
||||
if (isLocallyUpdating.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const migrated = { ...settings }
|
||||
const anySettings = migrated as Record<string, unknown>
|
||||
if ('zapSplitPercentage' in anySettings && !('zapSplitHighlighterWeight' in migrated)) {
|
||||
migrated.zapSplitHighlighterWeight = (anySettings.zapSplitPercentage as number) ?? 50
|
||||
migrated.zapSplitAuthorWeight = 100 - ((anySettings.zapSplitPercentage as number) ?? 50)
|
||||
}
|
||||
if ('borisSupportPercentage' in anySettings && !('zapSplitBorisWeight' in migrated)) {
|
||||
migrated.zapSplitBorisWeight = (anySettings.borisSupportPercentage as number) ?? 2.1
|
||||
}
|
||||
setLocalSettings(migrated)
|
||||
}, [settings])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -57,7 +89,30 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
isInitialMount.current = false
|
||||
return
|
||||
}
|
||||
onSave(localSettings)
|
||||
|
||||
// Mark that we're making local updates
|
||||
isLocallyUpdating.current = true
|
||||
|
||||
// Clear any pending save
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current)
|
||||
}
|
||||
|
||||
// Debounce the save to avoid rapid updates
|
||||
saveTimeoutRef.current = setTimeout(() => {
|
||||
onSave(localSettings).finally(() => {
|
||||
// Allow external updates again after a short delay
|
||||
setTimeout(() => {
|
||||
isLocallyUpdating.current = false
|
||||
}, 500)
|
||||
})
|
||||
}, 300)
|
||||
|
||||
return () => {
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [localSettings, onSave])
|
||||
|
||||
const handleResetToDefaults = () => {
|
||||
@@ -96,6 +151,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<LayoutNavigationSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<StartupPreferencesSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
</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,11 +1,16 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faRotate } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faRotate, faHome, faPlus } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
import { Accounts } from 'applesauce-accounts'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import IconButton from './IconButton'
|
||||
import AddBookmarkModal from './AddBookmarkModal'
|
||||
import { createWebBookmark } from '../services/webBookmarkService'
|
||||
import { RELAYS } from '../config/relays'
|
||||
|
||||
interface SidebarHeaderProps {
|
||||
onToggleCollapse: () => void
|
||||
@@ -13,10 +18,13 @@ interface SidebarHeaderProps {
|
||||
onOpenSettings: () => void
|
||||
onRefresh?: () => void
|
||||
isRefreshing?: boolean
|
||||
relayPool: RelayPool | null
|
||||
}
|
||||
|
||||
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, onRefresh, isRefreshing }) => {
|
||||
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, onRefresh, isRefreshing, relayPool }) => {
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const accountManager = Hooks.useAccountManager()
|
||||
const profile = useEventModel(Models.ProfileModel, activeAccount ? [activeAccount.pubkey] : null)
|
||||
@@ -47,6 +55,19 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
return `${activeAccount.pubkey.slice(0, 8)}...${activeAccount.pubkey.slice(-8)}`
|
||||
}
|
||||
|
||||
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
|
||||
if (!activeAccount || !relayPool) {
|
||||
throw new Error('Please login to create bookmarks')
|
||||
}
|
||||
|
||||
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
|
||||
|
||||
// Refresh bookmarks after creating
|
||||
if (onRefresh) {
|
||||
onRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
const profileImage = getProfileImage()
|
||||
|
||||
return (
|
||||
@@ -61,24 +82,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</button>
|
||||
<div className="sidebar-header-right">
|
||||
{onRefresh && (
|
||||
<IconButton
|
||||
icon={faRotate}
|
||||
onClick={onRefresh}
|
||||
title="Refresh bookmarks"
|
||||
ariaLabel="Refresh bookmarks"
|
||||
variant="ghost"
|
||||
disabled={isRefreshing}
|
||||
spin={isRefreshing}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
icon={faGear}
|
||||
onClick={onOpenSettings}
|
||||
title="Settings"
|
||||
ariaLabel="Settings"
|
||||
variant="ghost"
|
||||
/>
|
||||
<div
|
||||
className="profile-avatar"
|
||||
title={activeAccount ? getUserDisplayName() : "Login"}
|
||||
@@ -91,6 +94,40 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
<FontAwesomeIcon icon={faUserCircle} />
|
||||
)}
|
||||
</div>
|
||||
<IconButton
|
||||
icon={faHome}
|
||||
onClick={() => navigate('/')}
|
||||
title="Home"
|
||||
ariaLabel="Home"
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
icon={faGear}
|
||||
onClick={onOpenSettings}
|
||||
title="Settings"
|
||||
ariaLabel="Settings"
|
||||
variant="ghost"
|
||||
/>
|
||||
{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 ? (
|
||||
<IconButton
|
||||
icon={faRightFromBracket}
|
||||
@@ -110,6 +147,12 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showAddModal && (
|
||||
<AddBookmarkModal
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onSave={handleSaveBookmark}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { BookmarkList } from './BookmarkList'
|
||||
import ContentPanel from './ContentPanel'
|
||||
import { HighlightsPanel } from './HighlightsPanel'
|
||||
@@ -30,6 +31,7 @@ interface ThreePaneLayoutProps {
|
||||
onViewModeChange: (mode: ViewMode) => void
|
||||
onOpenSettings: () => void
|
||||
onRefresh: () => void
|
||||
relayPool: RelayPool | null
|
||||
|
||||
// Content pane
|
||||
readerLoading: boolean
|
||||
@@ -86,6 +88,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
onRefresh={props.onRefresh}
|
||||
isRefreshing={props.isRefreshing}
|
||||
loading={props.bookmarksLoading}
|
||||
relayPool={props.relayPool}
|
||||
/>
|
||||
</div>
|
||||
<div className="pane main">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Highlight } from '../types/highlights'
|
||||
import { ReadableContent } from '../services/readerService'
|
||||
import { createHighlight, eventToHighlight } from '../services/highlightCreationService'
|
||||
import { HighlightButtonRef } from '../components/HighlightButton'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
|
||||
interface UseHighlightCreationParams {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -14,6 +15,7 @@ interface UseHighlightCreationParams {
|
||||
selectedUrl: string | undefined
|
||||
readerContent: ReadableContent | undefined
|
||||
onHighlightCreated: (highlight: Highlight) => void
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
export const useHighlightCreation = ({
|
||||
@@ -22,7 +24,8 @@ export const useHighlightCreation = ({
|
||||
currentArticle,
|
||||
selectedUrl,
|
||||
readerContent,
|
||||
onHighlightCreated
|
||||
onHighlightCreated,
|
||||
settings
|
||||
}: UseHighlightCreationParams) => {
|
||||
const highlightButtonRef = useRef<HighlightButtonRef>(null)
|
||||
|
||||
@@ -56,7 +59,9 @@ export const useHighlightCreation = ({
|
||||
source,
|
||||
activeAccount,
|
||||
relayPool,
|
||||
contentForContext
|
||||
contentForContext,
|
||||
undefined,
|
||||
settings
|
||||
)
|
||||
|
||||
console.log('✅ Highlight created successfully!')
|
||||
@@ -67,7 +72,7 @@ export const useHighlightCreation = ({
|
||||
} catch (error) {
|
||||
console.error('Failed to create highlight:', error)
|
||||
}
|
||||
}, [activeAccount, relayPool, currentArticle, selectedUrl, readerContent, onHighlightCreated])
|
||||
}, [activeAccount, relayPool, currentArticle, selectedUrl, readerContent, onHighlightCreated, settings])
|
||||
|
||||
return {
|
||||
highlightButtonRef,
|
||||
|
||||
263
src/index.css
263
src/index.css
@@ -2020,6 +2020,104 @@ body {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.zap-split-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.zap-split-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.zap-split-label {
|
||||
font-size: 0.9rem;
|
||||
color: #ccc;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.zap-split-slider {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: linear-gradient(to right, #646cff 0%, #646cff 50%, #f97316 50%, #f97316 100%);
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.zap-split-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: white;
|
||||
border: 2px solid #646cff;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.zap-split-slider::-moz-range-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: white;
|
||||
border: 2px solid #646cff;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.zap-split-slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 0 8px rgba(100, 108, 255, 0.5);
|
||||
}
|
||||
|
||||
.zap-split-slider::-moz-range-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 0 8px rgba(100, 108, 255, 0.5);
|
||||
}
|
||||
|
||||
.zap-split-description {
|
||||
font-size: 0.8rem;
|
||||
color: #999;
|
||||
line-height: 1.4;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.zap-preset-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.zap-preset-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
color: #ccc;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.zap-preset-btn:hover {
|
||||
background: #333;
|
||||
border-color: #646cff;
|
||||
color: white;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.zap-preset-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.settings-footer {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
@@ -2095,3 +2193,168 @@ body {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Add Bookmark Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 12px;
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.modal-form {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #ccc;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.fetching-indicator {
|
||||
font-size: 0.8rem;
|
||||
color: #999;
|
||||
font-weight: normal;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #646cff;
|
||||
}
|
||||
|
||||
.form-group input:disabled,
|
||||
.form-group textarea:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.form-helper-text {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: #999;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.modal-error {
|
||||
padding: 0.75rem;
|
||||
background: rgba(220, 53, 69, 0.1);
|
||||
border: 1px solid #dc3545;
|
||||
border-radius: 6px;
|
||||
color: #dc3545;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
color: #ccc;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #333;
|
||||
border-color: #646cff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #646cff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #535bf2;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ import { NostrEvent } from 'nostr-tools'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { UserSettings } from './settingsService'
|
||||
|
||||
// Boris pubkey for zap splits
|
||||
const BORIS_PUBKEY = '6e468422dfb74a5738702a8823b9b28168fc6cfb119d613e49ca0ec5a0bbd0c3'
|
||||
|
||||
const {
|
||||
getHighlightText,
|
||||
@@ -30,7 +34,8 @@ export async function createHighlight(
|
||||
account: IAccount,
|
||||
relayPool: RelayPool,
|
||||
contentForContext?: string,
|
||||
comment?: string
|
||||
comment?: string,
|
||||
settings?: UserSettings
|
||||
): Promise<NostrEvent> {
|
||||
if (!selectedText || !source) {
|
||||
throw new Error('Missing required data to create highlight')
|
||||
@@ -72,6 +77,30 @@ export async function createHighlight(
|
||||
highlightEvent.tags.push(['alt', 'Highlight created by Boris. readwithboris.com'])
|
||||
}
|
||||
|
||||
// Add zap tags for nostr-native content (NIP-57 Appendix G)
|
||||
if (typeof source === 'object' && 'kind' in source) {
|
||||
// Migrate old settings format to new weight-based format if needed
|
||||
let highlighterWeight = settings?.zapSplitHighlighterWeight
|
||||
let borisWeight = settings?.zapSplitBorisWeight
|
||||
let authorWeight = settings?.zapSplitAuthorWeight
|
||||
|
||||
const anySettings = settings as Record<string, unknown> | undefined
|
||||
if (!highlighterWeight && anySettings && 'zapSplitPercentage' in anySettings) {
|
||||
highlighterWeight = anySettings.zapSplitPercentage as number
|
||||
authorWeight = 100 - (anySettings.zapSplitPercentage as number)
|
||||
}
|
||||
if (!borisWeight && anySettings && 'borisSupportPercentage' in anySettings) {
|
||||
borisWeight = anySettings.borisSupportPercentage as number
|
||||
}
|
||||
|
||||
// Use defaults if still undefined
|
||||
highlighterWeight = highlighterWeight ?? 50
|
||||
borisWeight = borisWeight ?? 2.1
|
||||
authorWeight = authorWeight ?? 50
|
||||
|
||||
addZapTags(highlightEvent, account.pubkey, source, highlighterWeight, borisWeight, authorWeight)
|
||||
}
|
||||
|
||||
// Sign the event
|
||||
const signedEvent = await factory.sign(highlightEvent)
|
||||
|
||||
@@ -176,6 +205,76 @@ function extractContext(selectedText: string, articleContent: string): string |
|
||||
return contextParts.length > 1 ? contextParts.join(' ') : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds zap tags to a highlight event for split payments (NIP-57 Appendix G)
|
||||
* Respects existing zap tags in the source event (author group)
|
||||
* @param event The highlight event to add zap tags to (can be EventTemplate or NostrEvent)
|
||||
* @param highlighterPubkey The pubkey of the user creating the highlight
|
||||
* @param sourceEvent The source event (may contain existing zap tags)
|
||||
* @param highlighterWeight Weight to give to the highlighter (default 50)
|
||||
* @param borisWeight Weight to give to Boris (default 2.1)
|
||||
* @param authorWeight Weight to give to author(s) (default 50)
|
||||
*/
|
||||
function addZapTags(
|
||||
event: { tags: string[][] },
|
||||
highlighterPubkey: string,
|
||||
sourceEvent: NostrEvent,
|
||||
highlighterWeight: number = 50,
|
||||
borisWeight: number = 2.1,
|
||||
authorWeight: number = 50
|
||||
): void {
|
||||
// Use a reliable relay for zap metadata lookup (first non-local relay)
|
||||
const zapRelay = RELAYS.find(r => !r.includes('localhost')) || RELAYS[0]
|
||||
|
||||
// Extract existing zap tags from source event (the "author group")
|
||||
const existingZapTags = sourceEvent.tags.filter(tag => tag[0] === 'zap')
|
||||
|
||||
// Add zap tag for the highlighter
|
||||
if (highlighterWeight > 0) {
|
||||
event.tags.push(['zap', highlighterPubkey, zapRelay, highlighterWeight.toString()])
|
||||
}
|
||||
|
||||
// Add zap tag for Boris (if weight > 0 and Boris is not the highlighter)
|
||||
if (borisWeight > 0 && BORIS_PUBKEY !== highlighterPubkey) {
|
||||
event.tags.push(['zap', BORIS_PUBKEY, zapRelay, borisWeight.toFixed(1)])
|
||||
}
|
||||
|
||||
if (existingZapTags.length > 0 && authorWeight > 0) {
|
||||
// Calculate total weight from existing zap tags
|
||||
const totalExistingWeight = existingZapTags.reduce((sum, tag) => {
|
||||
const weight = parseFloat(tag[3] || '1')
|
||||
return sum + weight
|
||||
}, 0)
|
||||
|
||||
// Add proportionally adjusted zap tags for each existing author
|
||||
// Don't add the highlighter or Boris again if they're already in the author group
|
||||
for (const zapTag of existingZapTags) {
|
||||
const authorPubkey = zapTag[1]
|
||||
|
||||
// Skip if this is the highlighter or Boris (they already have their shares)
|
||||
if (authorPubkey === highlighterPubkey || authorPubkey === BORIS_PUBKEY) continue
|
||||
|
||||
const originalWeight = parseFloat(zapTag[3] || '1')
|
||||
const originalRelay = zapTag[2] || zapRelay
|
||||
|
||||
// Calculate proportional weight: (original weight / total weight) * author group weight
|
||||
const adjustedWeight = (originalWeight / totalExistingWeight) * authorWeight
|
||||
|
||||
// Only add if weight is greater than 0
|
||||
if (adjustedWeight > 0) {
|
||||
event.tags.push(['zap', authorPubkey, originalRelay, adjustedWeight.toFixed(1)])
|
||||
}
|
||||
}
|
||||
} else if (authorWeight > 0) {
|
||||
// No existing zap tags, give full author weight to source author
|
||||
|
||||
// Add zap tag for the original author (only if different from highlighter and Boris)
|
||||
if (sourceEvent.pubkey !== highlighterPubkey && sourceEvent.pubkey !== BORIS_PUBKEY) {
|
||||
event.tags.push(['zap', sourceEvent.pubkey, zapRelay, authorWeight.toFixed(1)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a NostrEvent to a Highlight object for immediate UI display
|
||||
*/
|
||||
|
||||
@@ -35,6 +35,10 @@ export interface UserSettings {
|
||||
defaultHighlightVisibilityNostrverse?: boolean
|
||||
defaultHighlightVisibilityFriends?: boolean
|
||||
defaultHighlightVisibilityMine?: boolean
|
||||
// Zap split weights (treated as relative weights, not strict percentages)
|
||||
zapSplitHighlighterWeight?: number // default 50
|
||||
zapSplitBorisWeight?: number // default 2.1
|
||||
zapSplitAuthorWeight?: number // default 50
|
||||
}
|
||||
|
||||
export async function loadSettings(
|
||||
|
||||
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