mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ea03c9042 | ||
|
|
4720416f2c | ||
|
|
8ad9e652fb | ||
|
|
98c72389e2 | ||
|
|
e032f432dd | ||
|
|
852465bee7 | ||
|
|
39d0147cfa | ||
|
|
10cc7ce9b0 | ||
|
|
6b8442ebdd | ||
|
|
5aba283e92 | ||
|
|
59df232e2e | ||
|
|
702c001d46 | ||
|
|
48a9919db8 | ||
|
|
d6d0755b89 | ||
|
|
facdd36145 | ||
|
|
5d379a280b | ||
|
|
22a02d228d | ||
|
|
61fd5bbadc | ||
|
|
d642c87527 | ||
|
|
fea425b5d0 | ||
|
|
1609c6e580 | ||
|
|
270ea94c70 | ||
|
|
83e2f23357 | ||
|
|
9df0261071 | ||
|
|
1dfe66651a | ||
|
|
dcb7933ede | ||
|
|
aa72ac44c8 | ||
|
|
44fb07033b | ||
|
|
7e2d412869 | ||
|
|
19021af49a | ||
|
|
bdbb89c50e | ||
|
|
687f60db3f | ||
|
|
8ee7d347be | ||
|
|
8e9242e6f2 | ||
|
|
1df3962064 | ||
|
|
4edc22cec2 | ||
|
|
82977fa5d4 | ||
|
|
1a84817453 | ||
|
|
a0b98231b7 | ||
|
|
d452f96f79 | ||
|
|
dcf43cfce1 | ||
|
|
815b3cc57d | ||
|
|
7e54a01237 | ||
|
|
ec4692da15 | ||
|
|
f6d2f98eae | ||
|
|
9b97715274 | ||
|
|
fa1e536a26 | ||
|
|
238aac1921 | ||
|
|
29edd159e7 | ||
|
|
a3edb64e4c |
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,8 +1,8 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.2.6",
|
||||
"version": "0.2.10",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://xn--bris-v0b.com/",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -52,6 +52,15 @@ function AppRoutes({
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
|
||||
</Routes>
|
||||
)
|
||||
|
||||
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 ? (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { useParams, useLocation } from 'react-router-dom'
|
||||
import React, { useMemo, useEffect, useRef } from 'react'
|
||||
import { useParams, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { useEventStore } from 'applesauce-react/hooks'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
@@ -23,10 +23,21 @@ interface BookmarksProps {
|
||||
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
const { naddr } = useParams<{ naddr?: string }>()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const previousLocationRef = useRef<string>()
|
||||
|
||||
const externalUrl = location.pathname.startsWith('/r/')
|
||||
? decodeURIComponent(location.pathname.slice(3))
|
||||
: undefined
|
||||
|
||||
const showSettings = location.pathname === '/settings'
|
||||
|
||||
// Track previous location for going back from settings
|
||||
useEffect(() => {
|
||||
if (!showSettings) {
|
||||
previousLocationRef.current = location.pathname
|
||||
}
|
||||
}, [location.pathname, showSettings])
|
||||
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const accountManager = Hooks.useAccountManager()
|
||||
@@ -50,8 +61,6 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
setShowHighlights,
|
||||
selectedHighlightId,
|
||||
setSelectedHighlightId,
|
||||
showSettings,
|
||||
setShowSettings,
|
||||
currentArticleCoordinate,
|
||||
setCurrentArticleCoordinate,
|
||||
currentArticleEventId,
|
||||
@@ -94,7 +103,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
relayPool,
|
||||
settings,
|
||||
setIsCollapsed,
|
||||
setShowSettings,
|
||||
setShowSettings: () => {}, // No-op since we use route-based settings now
|
||||
setCurrentArticle
|
||||
})
|
||||
|
||||
@@ -160,17 +169,22 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
onLogout={onLogout}
|
||||
onViewModeChange={setViewMode}
|
||||
onOpenSettings={() => {
|
||||
setShowSettings(true)
|
||||
navigate('/settings')
|
||||
setIsCollapsed(true)
|
||||
setIsHighlightsCollapsed(true)
|
||||
}}
|
||||
onRefresh={handleRefreshAll}
|
||||
relayPool={relayPool}
|
||||
readerLoading={readerLoading}
|
||||
readerContent={readerContent}
|
||||
selectedUrl={selectedUrl}
|
||||
settings={settings}
|
||||
onSaveSettings={saveSettings}
|
||||
onCloseSettings={() => setShowSettings(false)}
|
||||
onCloseSettings={() => {
|
||||
// Navigate back to previous location or default
|
||||
const backTo = previousLocationRef.current || '/'
|
||||
navigate(backTo)
|
||||
}}
|
||||
classifiedHighlights={classifiedHighlights}
|
||||
showHighlights={showHighlights}
|
||||
selectedHighlightId={selectedHighlightId}
|
||||
|
||||
@@ -19,6 +19,8 @@ interface ContentPanelProps {
|
||||
markdown?: string
|
||||
selectedUrl?: string
|
||||
image?: string
|
||||
summary?: string
|
||||
published?: number
|
||||
highlights?: Highlight[]
|
||||
showHighlights?: boolean
|
||||
highlightStyle?: 'marker' | 'underline'
|
||||
@@ -40,6 +42,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
markdown,
|
||||
selectedUrl,
|
||||
image,
|
||||
summary,
|
||||
published,
|
||||
highlights = [],
|
||||
showHighlights = true,
|
||||
highlightStyle = 'marker',
|
||||
@@ -117,6 +121,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
<ReaderHeader
|
||||
title={title}
|
||||
image={image}
|
||||
summary={summary}
|
||||
published={published}
|
||||
readingTimeText={readingStats ? readingStats.text : null}
|
||||
hasHighlights={hasHighlights}
|
||||
highlightCount={relevantHighlights.length}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHighlighter, faClock } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faHighlighter, faClock, faCalendar } from '@fortawesome/free-solid-svg-icons'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
interface ReaderHeaderProps {
|
||||
title?: string
|
||||
image?: string
|
||||
summary?: string
|
||||
published?: number
|
||||
readingTimeText?: string | null
|
||||
hasHighlights: boolean
|
||||
highlightCount: number
|
||||
@@ -13,21 +16,60 @@ interface ReaderHeaderProps {
|
||||
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
title,
|
||||
image,
|
||||
summary,
|
||||
published,
|
||||
readingTimeText,
|
||||
hasHighlights,
|
||||
highlightCount
|
||||
}) => {
|
||||
const formattedDate = published ? format(new Date(published * 1000), 'MMMM d, yyyy') : null
|
||||
if (image) {
|
||||
return (
|
||||
<div className="reader-hero-image">
|
||||
<img src={image} alt={title || 'Article image'} />
|
||||
{title && (
|
||||
<div className="reader-header-overlay">
|
||||
<h2 className="reader-title">{title}</h2>
|
||||
{summary && <p className="reader-summary">{summary}</p>}
|
||||
<div className="reader-meta">
|
||||
{formattedDate && (
|
||||
<div className="publish-date">
|
||||
<FontAwesomeIcon icon={faCalendar} />
|
||||
<span>{formattedDate}</span>
|
||||
</div>
|
||||
)}
|
||||
{readingTimeText && (
|
||||
<div className="reading-time">
|
||||
<FontAwesomeIcon icon={faClock} />
|
||||
<span>{readingTimeText}</span>
|
||||
</div>
|
||||
)}
|
||||
{hasHighlights && (
|
||||
<div className="highlight-indicator">
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{image && (
|
||||
<div className="reader-hero-image">
|
||||
<img src={image} alt={title || 'Article image'} />
|
||||
</div>
|
||||
)}
|
||||
{title && (
|
||||
<div className="reader-header">
|
||||
<h2 className="reader-title">{title}</h2>
|
||||
{summary && <p className="reader-summary">{summary}</p>}
|
||||
<div className="reader-meta">
|
||||
{formattedDate && (
|
||||
<div className="publish-date">
|
||||
<FontAwesomeIcon icon={faCalendar} />
|
||||
<span>{formattedDate}</span>
|
||||
</div>
|
||||
)}
|
||||
{readingTimeText && (
|
||||
<div className="reading-time">
|
||||
<FontAwesomeIcon icon={faClock} />
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { faTimes, faUndo } from '@fortawesome/free-solid-svg-icons'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import IconButton from './IconButton'
|
||||
import { loadFont } from '../utils/fontLoader'
|
||||
import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
|
||||
import LayoutNavigationSettings from './Settings/LayoutNavigationSettings'
|
||||
import StartupPreferencesSettings from './Settings/StartupPreferencesSettings'
|
||||
import ZapSettings from './Settings/ZapSettings'
|
||||
import RelaySettings from './Settings/RelaySettings'
|
||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||
|
||||
const DEFAULT_SETTINGS: UserSettings = {
|
||||
collapseOnArticleOpen: true,
|
||||
@@ -23,21 +27,54 @@ const DEFAULT_SETTINGS: UserSettings = {
|
||||
defaultHighlightVisibilityNostrverse: true,
|
||||
defaultHighlightVisibilityFriends: true,
|
||||
defaultHighlightVisibilityMine: true,
|
||||
zapSplitPercentage: 50,
|
||||
zapSplitHighlighterWeight: 50,
|
||||
zapSplitBorisWeight: 2.1,
|
||||
zapSplitAuthorWeight: 50,
|
||||
}
|
||||
|
||||
interface SettingsProps {
|
||||
settings: UserSettings
|
||||
onSave: (settings: UserSettings) => Promise<void>
|
||||
onClose: () => void
|
||||
relayPool: RelayPool | null
|
||||
}
|
||||
|
||||
const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
const [localSettings, setLocalSettings] = useState<UserSettings>(settings)
|
||||
const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPool }) => {
|
||||
const [localSettings, setLocalSettings] = useState<UserSettings>(() => {
|
||||
// Migrate old settings format to new weight-based format
|
||||
const migrated = { ...settings }
|
||||
const anySettings = migrated as Record<string, unknown>
|
||||
if ('zapSplitPercentage' in anySettings && !('zapSplitHighlighterWeight' in migrated)) {
|
||||
migrated.zapSplitHighlighterWeight = (anySettings.zapSplitPercentage as number) ?? 50
|
||||
migrated.zapSplitAuthorWeight = 100 - ((anySettings.zapSplitPercentage as number) ?? 50)
|
||||
}
|
||||
if ('borisSupportPercentage' in anySettings && !('zapSplitBorisWeight' in migrated)) {
|
||||
migrated.zapSplitBorisWeight = (anySettings.borisSupportPercentage as number) ?? 2.1
|
||||
}
|
||||
return migrated
|
||||
})
|
||||
const isInitialMount = useRef(true)
|
||||
const saveTimeoutRef = useRef<number | null>(null)
|
||||
const isLocallyUpdating = useRef(false)
|
||||
|
||||
const relayStatuses = useRelayStatus({ relayPool })
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSettings(settings)
|
||||
// Don't update from external settings if we're currently making local changes
|
||||
if (isLocallyUpdating.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const migrated = { ...settings }
|
||||
const anySettings = migrated as Record<string, unknown>
|
||||
if ('zapSplitPercentage' in anySettings && !('zapSplitHighlighterWeight' in migrated)) {
|
||||
migrated.zapSplitHighlighterWeight = (anySettings.zapSplitPercentage as number) ?? 50
|
||||
migrated.zapSplitAuthorWeight = 100 - ((anySettings.zapSplitPercentage as number) ?? 50)
|
||||
}
|
||||
if ('borisSupportPercentage' in anySettings && !('zapSplitBorisWeight' in migrated)) {
|
||||
migrated.zapSplitBorisWeight = (anySettings.borisSupportPercentage as number) ?? 2.1
|
||||
}
|
||||
setLocalSettings(migrated)
|
||||
}, [settings])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -58,7 +95,30 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
isInitialMount.current = false
|
||||
return
|
||||
}
|
||||
onSave(localSettings)
|
||||
|
||||
// Mark that we're making local updates
|
||||
isLocallyUpdating.current = true
|
||||
|
||||
// Clear any pending save
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current)
|
||||
}
|
||||
|
||||
// Debounce the save to avoid rapid updates
|
||||
saveTimeoutRef.current = setTimeout(() => {
|
||||
onSave(localSettings).finally(() => {
|
||||
// Allow external updates again after a short delay
|
||||
setTimeout(() => {
|
||||
isLocallyUpdating.current = false
|
||||
}, 500)
|
||||
})
|
||||
}, 300)
|
||||
|
||||
return () => {
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [localSettings, onSave])
|
||||
|
||||
const handleResetToDefaults = () => {
|
||||
@@ -97,6 +157,8 @@ 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} />
|
||||
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ const LayoutNavigationSettings: React.FC<LayoutNavigationSettingsProps> = ({ set
|
||||
<h3 className="section-title">Layout & Navigation</h3>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Default View Mode</label>
|
||||
<label>Default Bookmark View</label>
|
||||
<div className="setting-buttons">
|
||||
<IconButton
|
||||
icon={faList}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react'
|
||||
import { faHighlighter, faUnderline } from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHighlighter, faUnderline, faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
import IconButton from '../IconButton'
|
||||
import ColorPicker from '../ColorPicker'
|
||||
@@ -21,10 +22,12 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label htmlFor="readingFont">Reading Font</label>
|
||||
<FontSelector
|
||||
value={settings.readingFont || 'source-serif-4'}
|
||||
onChange={(font) => onUpdate({ readingFont: font })}
|
||||
/>
|
||||
<div className="setting-control">
|
||||
<FontSelector
|
||||
value={settings.readingFont || 'source-serif-4'}
|
||||
onChange={(font) => onUpdate({ readingFont: font })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
@@ -44,19 +47,6 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="showHighlights" className="checkbox-label">
|
||||
<input
|
||||
id="showHighlights"
|
||||
type="checkbox"
|
||||
checked={settings.showHighlights !== false}
|
||||
onChange={(e) => onUpdate({ showHighlights: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Show highlights</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Highlight Style</label>
|
||||
<div className="setting-buttons">
|
||||
@@ -107,27 +97,52 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label className="setting-label">Zap Split for Highlights</label>
|
||||
<div className="zap-split-container">
|
||||
<div className="zap-split-labels">
|
||||
<span className="zap-split-label">You: {settings.zapSplitPercentage ?? 50}%</span>
|
||||
<span className="zap-split-label">Author: {100 - (settings.zapSplitPercentage ?? 50)}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={settings.zapSplitPercentage ?? 50}
|
||||
onChange={(e) => onUpdate({ zapSplitPercentage: parseInt(e.target.value) })}
|
||||
className="zap-split-slider"
|
||||
/>
|
||||
<div className="zap-split-description">
|
||||
When highlighting nostr-native content, zaps will be split between you and the author.
|
||||
</div>
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Default Highlight Visibility</label>
|
||||
<div className="highlight-level-toggles">
|
||||
<button
|
||||
onClick={() => onUpdate({ defaultHighlightVisibilityNostrverse: !(settings.defaultHighlightVisibilityNostrverse !== false) })}
|
||||
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityNostrverse !== false) ? 'active' : ''}`}
|
||||
title="Nostrverse highlights"
|
||||
aria-label="Toggle nostrverse highlights by default"
|
||||
style={{ color: (settings.defaultHighlightVisibilityNostrverse !== false) ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faNetworkWired} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onUpdate({ defaultHighlightVisibilityFriends: !(settings.defaultHighlightVisibilityFriends !== false) })}
|
||||
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityFriends !== false) ? 'active' : ''}`}
|
||||
title="Friends highlights"
|
||||
aria-label="Toggle friends highlights by default"
|
||||
style={{ color: (settings.defaultHighlightVisibilityFriends !== false) ? 'var(--highlight-color-friends, #f97316)' : undefined }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUserGroup} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onUpdate({ defaultHighlightVisibilityMine: !(settings.defaultHighlightVisibilityMine !== false) })}
|
||||
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityMine !== false) ? 'active' : ''}`}
|
||||
title="My highlights"
|
||||
aria-label="Toggle my highlights by default"
|
||||
style={{ color: (settings.defaultHighlightVisibilityMine !== false) ? 'var(--highlight-color-mine, #eab308)' : undefined }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUser} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="showHighlights" className="checkbox-label">
|
||||
<input
|
||||
id="showHighlights"
|
||||
type="checkbox"
|
||||
checked={settings.showHighlights !== false}
|
||||
onChange={(e) => onUpdate({ showHighlights: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Show highlights</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-preview">
|
||||
<div className="preview-label">Preview</div>
|
||||
<div
|
||||
@@ -139,9 +154,9 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<h3>The Quick Brown Fox</h3>
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <span className={settings.showHighlights !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-mine` : ""}>Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</span> Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
|
||||
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. <span className={settings.showHighlights !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-friends` : ""}>Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</span> Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.</p>
|
||||
<p>Totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. <span className={settings.showHighlights !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-nostrverse` : ""}>Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</span> Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit.</p>
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityMine !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-mine` : ""}>Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</span> Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
|
||||
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityFriends !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-friends` : ""}>Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</span> Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.</p>
|
||||
<p>Totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. <span className={settings.showHighlights !== false && settings.defaultHighlightVisibilityNostrverse !== false ? `content-highlight-${settings.highlightStyle || 'marker'} level-nostrverse` : ""}>Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</span> Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit.</p>
|
||||
<p>Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
204
src/components/Settings/RelaySettings.tsx
Normal file
204
src/components/Settings/RelaySettings.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faCheckCircle, faCircle, faClock } from '@fortawesome/free-solid-svg-icons'
|
||||
import { RelayStatus } from '../../services/relayStatusService'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
|
||||
interface RelaySettingsProps {
|
||||
relayStatuses: RelayStatus[]
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const RelaySettings: React.FC<RelaySettingsProps> = ({ relayStatuses, onClose }) => {
|
||||
const navigate = useNavigate()
|
||||
const activeRelays = relayStatuses.filter(r => r.isInPool)
|
||||
const recentRelays = relayStatuses.filter(r => !r.isInPool)
|
||||
|
||||
const handleLinkClick = (url: string) => {
|
||||
if (onClose) onClose()
|
||||
navigate(`/r/${encodeURIComponent(url)}`)
|
||||
}
|
||||
|
||||
const formatRelayUrl = (url: string) => {
|
||||
return url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
const formatLastSeen = (timestamp: number) => {
|
||||
try {
|
||||
return formatDistanceToNow(timestamp, { addSuffix: true })
|
||||
} catch {
|
||||
return 'just now'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3>Relays</h3>
|
||||
|
||||
{activeRelays.length > 0 && (
|
||||
<div className="relay-group" style={{ marginBottom: '1.5rem' }}>
|
||||
<div className="relay-list">
|
||||
{activeRelays.map((relay) => (
|
||||
<div
|
||||
key={relay.url}
|
||||
className="relay-item"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
padding: '0.75rem',
|
||||
background: 'var(--surface-secondary)',
|
||||
borderRadius: '6px',
|
||||
marginBottom: '0.5rem'
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faCheckCircle}
|
||||
style={{
|
||||
color: 'var(--success, #22c55e)',
|
||||
fontSize: '1rem'
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: '0.9rem',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}>
|
||||
{formatRelayUrl(relay.url)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recentRelays.length > 0 && (
|
||||
<div className="relay-group">
|
||||
<h4 style={{
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-secondary)',
|
||||
marginBottom: '0.75rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em'
|
||||
}}>
|
||||
Recently Seen
|
||||
</h4>
|
||||
<div className="relay-list">
|
||||
{recentRelays.map((relay) => (
|
||||
<div
|
||||
key={relay.url}
|
||||
className="relay-item"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
padding: '0.75rem',
|
||||
background: 'var(--surface-secondary)',
|
||||
borderRadius: '6px',
|
||||
marginBottom: '0.5rem',
|
||||
opacity: 0.7
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faCircle}
|
||||
style={{
|
||||
color: 'var(--text-tertiary, #6b7280)',
|
||||
fontSize: '0.7rem'
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: '0.9rem',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}>
|
||||
{formatRelayUrl(relay.url)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
fontSize: '0.8rem',
|
||||
color: 'var(--text-tertiary)',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
<FontAwesomeIcon icon={faClock} />
|
||||
{formatLastSeen(relay.lastSeen)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{relayStatuses.length === 0 && (
|
||||
<p style={{ color: 'var(--text-secondary)', fontStyle: 'italic' }}>
|
||||
No relay connections found
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
marginTop: '1.5rem',
|
||||
padding: '1rem',
|
||||
background: 'var(--surface-secondary)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.9rem',
|
||||
lineHeight: '1.6'
|
||||
}}>
|
||||
<p style={{ margin: 0, color: 'var(--text-secondary)' }}>
|
||||
Boris works best with a local relay. Consider running{' '}
|
||||
<a
|
||||
href="https://github.com/greenart7c3/Citrine?tab=readme-ov-file#download"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--accent, #8b5cf6)' }}
|
||||
>
|
||||
Citrine
|
||||
</a>
|
||||
{' or '}
|
||||
<a
|
||||
href="https://github.com/CodyTseng/nostr-relay-tray/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--accent, #8b5cf6)' }}
|
||||
>
|
||||
nostr-relay-tray
|
||||
</a>
|
||||
. Don't know what relays are? Learn more{' '}
|
||||
<a
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleLinkClick('https://nostr.how/en/relays')
|
||||
}}
|
||||
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
|
||||
>
|
||||
here
|
||||
</a>
|
||||
{' and '}
|
||||
<a
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleLinkClick('https://davidebtc186.substack.com/p/the-importance-of-hosting-your-own')
|
||||
}}
|
||||
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
|
||||
>
|
||||
here
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RelaySettings
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import React from 'react'
|
||||
import { faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
import IconButton from '../IconButton'
|
||||
|
||||
interface StartupPreferencesSettingsProps {
|
||||
settings: UserSettings
|
||||
@@ -38,33 +36,6 @@ const StartupPreferencesSettings: React.FC<StartupPreferencesSettingsProps> = ({
|
||||
<span>Start with highlights panel collapsed</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Default Highlight Visibility</label>
|
||||
<div className="setting-buttons">
|
||||
<IconButton
|
||||
icon={faNetworkWired}
|
||||
onClick={() => onUpdate({ defaultHighlightVisibilityNostrverse: !(settings.defaultHighlightVisibilityNostrverse !== false) })}
|
||||
title="Nostrverse highlights"
|
||||
ariaLabel="Toggle nostrverse highlights by default"
|
||||
variant={(settings.defaultHighlightVisibilityNostrverse !== false) ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUserGroup}
|
||||
onClick={() => onUpdate({ defaultHighlightVisibilityFriends: !(settings.defaultHighlightVisibilityFriends !== false) })}
|
||||
title="Friends highlights"
|
||||
ariaLabel="Toggle friends highlights by default"
|
||||
variant={(settings.defaultHighlightVisibilityFriends !== false) ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUser}
|
||||
onClick={() => onUpdate({ defaultHighlightVisibilityMine: !(settings.defaultHighlightVisibilityMine !== false) })}
|
||||
title="My highlights"
|
||||
ariaLabel="Toggle my highlights by default"
|
||||
variant={(settings.defaultHighlightVisibilityMine !== false) ? 'primary' : 'ghost'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
143
src/components/Settings/ZapSettings.tsx
Normal file
143
src/components/Settings/ZapSettings.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import React from 'react'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
|
||||
interface ZapSettingsProps {
|
||||
settings: UserSettings
|
||||
onUpdate: (updates: Partial<UserSettings>) => void
|
||||
}
|
||||
|
||||
const ZapSettings: React.FC<ZapSettingsProps> = ({ settings, onUpdate }) => {
|
||||
const highlighterWeight = settings.zapSplitHighlighterWeight ?? 50
|
||||
const borisWeight = settings.zapSplitBorisWeight ?? 2.1
|
||||
const authorWeight = settings.zapSplitAuthorWeight ?? 50
|
||||
|
||||
// Calculate actual percentages from weights
|
||||
const totalWeight = highlighterWeight + borisWeight + authorWeight
|
||||
const highlighterPercentage = totalWeight > 0 ? (highlighterWeight / totalWeight) * 100 : 0
|
||||
const borisPercentage = totalWeight > 0 ? (borisWeight / totalWeight) * 100 : 0
|
||||
const authorPercentage = totalWeight > 0 ? (authorWeight / totalWeight) * 100 : 0
|
||||
|
||||
const presets = {
|
||||
default: { highlighter: 50, boris: 2.1, author: 50 },
|
||||
generous: { highlighter: 5, boris: 10, author: 75 },
|
||||
selfless: { highlighter: 1, boris: 19, author: 80 },
|
||||
boris: { highlighter: 10, boris: 80, author: 10 },
|
||||
}
|
||||
|
||||
const isPresetActive = (preset: { highlighter: number; boris: number; author: number }) => {
|
||||
return highlighterWeight === preset.highlighter &&
|
||||
borisWeight === preset.boris &&
|
||||
authorWeight === preset.author
|
||||
}
|
||||
|
||||
const applyPreset = (preset: { highlighter: number; boris: number; author: number }) => {
|
||||
onUpdate({
|
||||
zapSplitHighlighterWeight: preset.highlighter,
|
||||
zapSplitBorisWeight: preset.boris,
|
||||
zapSplitAuthorWeight: preset.author,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Zap Splits</h3>
|
||||
|
||||
<div className="setting-group">
|
||||
<label className="setting-label">Presets</label>
|
||||
<div className="zap-preset-buttons">
|
||||
<button
|
||||
onClick={() => applyPreset(presets.default)}
|
||||
className={`zap-preset-btn ${isPresetActive(presets.default) ? 'active' : ''}`}
|
||||
title="You: 49%, Author: 49%, Boris: 2%"
|
||||
>
|
||||
Default
|
||||
</button>
|
||||
<button
|
||||
onClick={() => applyPreset(presets.generous)}
|
||||
className={`zap-preset-btn ${isPresetActive(presets.generous) ? 'active' : ''}`}
|
||||
title="You: 6%, Author: 83%, Boris: 11%"
|
||||
>
|
||||
Generous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => applyPreset(presets.selfless)}
|
||||
className={`zap-preset-btn ${isPresetActive(presets.selfless) ? 'active' : ''}`}
|
||||
title="You: 1%, Author: 80%, Boris: 19%"
|
||||
>
|
||||
Selfless
|
||||
</button>
|
||||
<button
|
||||
onClick={() => applyPreset(presets.boris)}
|
||||
className={`zap-preset-btn ${isPresetActive(presets.boris) ? 'active' : ''}`}
|
||||
title="You: 10%, Author: 10%, Boris: 80%"
|
||||
>
|
||||
Boris 🧡
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label className="setting-label">Your Share</label>
|
||||
<div className="zap-split-container">
|
||||
<div className="zap-split-labels">
|
||||
<span className="zap-split-label">Weight: {highlighterWeight}</span>
|
||||
<span className="zap-split-label">({highlighterPercentage.toFixed(1)}%)</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={highlighterWeight}
|
||||
onChange={(e) => onUpdate({ zapSplitHighlighterWeight: parseInt(e.target.value) })}
|
||||
className="zap-split-slider"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label className="setting-label">Author(s) Share</label>
|
||||
<div className="zap-split-container">
|
||||
<div className="zap-split-labels">
|
||||
<span className="zap-split-label">Weight: {authorWeight}</span>
|
||||
<span className="zap-split-label">({authorPercentage.toFixed(1)}%)</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={authorWeight}
|
||||
onChange={(e) => onUpdate({ zapSplitAuthorWeight: parseInt(e.target.value) })}
|
||||
className="zap-split-slider"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label className="setting-label">Support Boris</label>
|
||||
<div className="zap-split-container">
|
||||
<div className="zap-split-labels">
|
||||
<span className="zap-split-label">Weight: {borisWeight.toFixed(1)}</span>
|
||||
<span className="zap-split-label">({borisPercentage.toFixed(1)}%)</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="10"
|
||||
step="0.1"
|
||||
value={borisWeight}
|
||||
onChange={(e) => onUpdate({ zapSplitBorisWeight: parseFloat(e.target.value) })}
|
||||
className="zap-split-slider"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="zap-split-description">
|
||||
Weights determine zap splits when highlighting nostr-native content.
|
||||
If the content has multiple authors, their share is divided proportionally.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ZapSettings
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faRotate, faHome } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faRotate, faHome, faPlus } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
import { Accounts } from 'applesauce-accounts'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import IconButton from './IconButton'
|
||||
import AddBookmarkModal from './AddBookmarkModal'
|
||||
import { createWebBookmark } from '../services/webBookmarkService'
|
||||
import { RELAYS } from '../config/relays'
|
||||
|
||||
interface SidebarHeaderProps {
|
||||
onToggleCollapse: () => void
|
||||
@@ -14,10 +18,12 @@ interface SidebarHeaderProps {
|
||||
onOpenSettings: () => void
|
||||
onRefresh?: () => void
|
||||
isRefreshing?: boolean
|
||||
relayPool: RelayPool | null
|
||||
}
|
||||
|
||||
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, onRefresh, isRefreshing }) => {
|
||||
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, onRefresh, isRefreshing, relayPool }) => {
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const accountManager = Hooks.useAccountManager()
|
||||
@@ -49,6 +55,19 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
return `${activeAccount.pubkey.slice(0, 8)}...${activeAccount.pubkey.slice(-8)}`
|
||||
}
|
||||
|
||||
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
|
||||
if (!activeAccount || !relayPool) {
|
||||
throw new Error('Please login to create bookmarks')
|
||||
}
|
||||
|
||||
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
|
||||
|
||||
// Refresh bookmarks after creating
|
||||
if (onRefresh) {
|
||||
onRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
const profileImage = getProfileImage()
|
||||
|
||||
return (
|
||||
@@ -63,6 +82,18 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</button>
|
||||
<div className="sidebar-header-right">
|
||||
<div
|
||||
className="profile-avatar"
|
||||
title={activeAccount ? getUserDisplayName() : "Login"}
|
||||
onClick={!activeAccount ? (isConnecting ? () => {} : handleLogin) : undefined}
|
||||
style={{ cursor: !activeAccount ? 'pointer' : 'default' }}
|
||||
>
|
||||
{profileImage ? (
|
||||
<img src={profileImage} alt={getUserDisplayName()} />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faUserCircle} />
|
||||
)}
|
||||
</div>
|
||||
<IconButton
|
||||
icon={faHome}
|
||||
onClick={() => navigate('/')}
|
||||
@@ -70,6 +101,13 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
ariaLabel="Home"
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
icon={faGear}
|
||||
onClick={onOpenSettings}
|
||||
title="Settings"
|
||||
ariaLabel="Settings"
|
||||
variant="ghost"
|
||||
/>
|
||||
{onRefresh && (
|
||||
<IconButton
|
||||
icon={faRotate}
|
||||
@@ -81,25 +119,15 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
spin={isRefreshing}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
icon={faGear}
|
||||
onClick={onOpenSettings}
|
||||
title="Settings"
|
||||
ariaLabel="Settings"
|
||||
variant="ghost"
|
||||
/>
|
||||
<div
|
||||
className="profile-avatar"
|
||||
title={activeAccount ? getUserDisplayName() : "Login"}
|
||||
onClick={!activeAccount ? (isConnecting ? () => {} : handleLogin) : undefined}
|
||||
style={{ cursor: !activeAccount ? 'pointer' : 'default' }}
|
||||
>
|
||||
{profileImage ? (
|
||||
<img src={profileImage} alt={getUserDisplayName()} />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faUserCircle} />
|
||||
)}
|
||||
</div>
|
||||
{activeAccount && (
|
||||
<IconButton
|
||||
icon={faPlus}
|
||||
onClick={() => setShowAddModal(true)}
|
||||
title="Add bookmark"
|
||||
ariaLabel="Add bookmark"
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
{activeAccount ? (
|
||||
<IconButton
|
||||
icon={faRightFromBracket}
|
||||
@@ -119,6 +147,12 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showAddModal && (
|
||||
<AddBookmarkModal
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onSave={handleSaveBookmark}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
@@ -94,6 +97,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
settings={props.settings}
|
||||
onSave={props.onSaveSettings}
|
||||
onClose={props.onCloseSettings}
|
||||
relayPool={props.relayPool}
|
||||
/>
|
||||
) : (
|
||||
<ContentPanel
|
||||
@@ -102,6 +106,8 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
html={props.readerContent?.html}
|
||||
markdown={props.readerContent?.markdown}
|
||||
image={props.readerContent?.image}
|
||||
summary={props.readerContent?.summary}
|
||||
published={props.readerContent?.published}
|
||||
selectedUrl={props.selectedUrl}
|
||||
highlights={props.classifiedHighlights}
|
||||
showHighlights={props.showHighlights}
|
||||
|
||||
@@ -49,6 +49,8 @@ export function useArticleLoader({
|
||||
title: article.title,
|
||||
markdown: article.markdown,
|
||||
image: article.image,
|
||||
summary: article.summary,
|
||||
published: article.published,
|
||||
url: `nostr:${naddr}`
|
||||
})
|
||||
|
||||
@@ -71,19 +73,22 @@ export function useArticleLoader({
|
||||
try {
|
||||
setHighlightsLoading(true)
|
||||
setHighlights([]) // Clear old highlights
|
||||
const highlightsList: Highlight[] = []
|
||||
const highlightsMap = new Map<string, Highlight>()
|
||||
|
||||
await fetchHighlightsForArticle(
|
||||
relayPool,
|
||||
articleCoordinate,
|
||||
article.event.id,
|
||||
(highlight) => {
|
||||
// Render each highlight immediately as it arrives
|
||||
highlightsList.push(highlight)
|
||||
setHighlights([...highlightsList].sort((a, b) => b.created_at - a.created_at))
|
||||
// Deduplicate highlights by ID as they arrive
|
||||
if (!highlightsMap.has(highlight.id)) {
|
||||
highlightsMap.set(highlight.id, highlight)
|
||||
const highlightsList = Array.from(highlightsMap.values())
|
||||
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
|
||||
}
|
||||
}
|
||||
)
|
||||
console.log(`📌 Found ${highlightsList.length} highlights`)
|
||||
console.log(`📌 Found ${highlightsMap.size} highlights`)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch highlights:', err)
|
||||
} finally {
|
||||
|
||||
@@ -55,17 +55,21 @@ export const useBookmarksData = ({
|
||||
setHighlightsLoading(true)
|
||||
try {
|
||||
if (currentArticleCoordinate) {
|
||||
const highlightsList: Highlight[] = []
|
||||
const highlightsMap = new Map<string, Highlight>()
|
||||
await fetchHighlightsForArticle(
|
||||
relayPool,
|
||||
currentArticleCoordinate,
|
||||
currentArticleEventId,
|
||||
(highlight) => {
|
||||
highlightsList.push(highlight)
|
||||
setHighlights([...highlightsList].sort((a, b) => b.created_at - a.created_at))
|
||||
// Deduplicate highlights by ID as they arrive
|
||||
if (!highlightsMap.has(highlight.id)) {
|
||||
highlightsMap.set(highlight.id, highlight)
|
||||
const highlightsList = Array.from(highlightsMap.values())
|
||||
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
|
||||
}
|
||||
}
|
||||
)
|
||||
console.log(`🔄 Refreshed ${highlightsList.length} highlights for article`)
|
||||
console.log(`🔄 Refreshed ${highlightsMap.size} highlights for article`)
|
||||
} else if (activeAccount) {
|
||||
const fetchedHighlights = await fetchHighlights(relayPool, activeAccount.pubkey)
|
||||
setHighlights(fetchedHighlights)
|
||||
|
||||
@@ -14,7 +14,6 @@ export const useBookmarksUI = ({ settings }: UseBookmarksUIParams) => {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('compact')
|
||||
const [showHighlights, setShowHighlights] = useState(true)
|
||||
const [selectedHighlightId, setSelectedHighlightId] = useState<string | undefined>(undefined)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [currentArticleCoordinate, setCurrentArticleCoordinate] = useState<string | undefined>(undefined)
|
||||
const [currentArticleEventId, setCurrentArticleEventId] = useState<string | undefined>(undefined)
|
||||
const [currentArticle, setCurrentArticle] = useState<NostrEvent | undefined>(undefined)
|
||||
@@ -46,8 +45,6 @@ export const useBookmarksUI = ({ settings }: UseBookmarksUIParams) => {
|
||||
setShowHighlights,
|
||||
selectedHighlightId,
|
||||
setSelectedHighlightId,
|
||||
showSettings,
|
||||
setShowSettings,
|
||||
currentArticleCoordinate,
|
||||
setCurrentArticleCoordinate,
|
||||
currentArticleEventId,
|
||||
|
||||
37
src/hooks/useRelayStatus.ts
Normal file
37
src/hooks/useRelayStatus.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { RelayStatus, updateAndGetRelayStatuses } from '../services/relayStatusService'
|
||||
|
||||
interface UseRelayStatusParams {
|
||||
relayPool: RelayPool | null
|
||||
pollingInterval?: number // in milliseconds
|
||||
}
|
||||
|
||||
export function useRelayStatus({
|
||||
relayPool,
|
||||
pollingInterval = 5000
|
||||
}: UseRelayStatusParams) {
|
||||
const [relayStatuses, setRelayStatuses] = useState<RelayStatus[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!relayPool) return
|
||||
|
||||
const updateStatuses = () => {
|
||||
const statuses = updateAndGetRelayStatuses(relayPool)
|
||||
setRelayStatuses(statuses)
|
||||
}
|
||||
|
||||
// Initial update
|
||||
updateStatuses()
|
||||
|
||||
// Poll for updates
|
||||
const interval = setInterval(updateStatuses, pollingInterval)
|
||||
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [relayPool, pollingInterval])
|
||||
|
||||
return relayStatuses
|
||||
}
|
||||
|
||||
260
src/index.css
260
src/index.css
@@ -501,17 +501,20 @@ body {
|
||||
}
|
||||
|
||||
.reader-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.reader-title {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-family: var(--reading-font);
|
||||
}
|
||||
|
||||
.reader-summary {
|
||||
color: #aaa;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 1rem 0;
|
||||
font-family: var(--reading-font);
|
||||
}
|
||||
|
||||
.reader-meta {
|
||||
@@ -1070,6 +1073,8 @@ body {
|
||||
margin: 0 0 2rem 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.reader-hero-image img {
|
||||
@@ -1080,6 +1085,50 @@ body {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.reader-header-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 2rem 2rem 1.5rem;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.85) 0%, rgba(0, 0, 0, 0.6) 60%, rgba(0, 0, 0, 0) 100%);
|
||||
}
|
||||
|
||||
.reader-header-overlay .reader-title {
|
||||
color: #fff;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.reader-header-overlay .reader-summary {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 1rem 0;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
|
||||
font-family: var(--reading-font);
|
||||
}
|
||||
|
||||
.reader-header-overlay .reader-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.reader-header-overlay .reading-time,
|
||||
.reader-header-overlay .highlight-indicator {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.reader-header-overlay .highlight-indicator {
|
||||
background: rgba(100, 108, 255, 0.25);
|
||||
border: 1px solid rgba(100, 108, 255, 0.4);
|
||||
}
|
||||
|
||||
/* Private Bookmark Styles */
|
||||
.private-bookmark {
|
||||
background: #2a2a2a;
|
||||
@@ -2088,6 +2137,36 @@ body {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.zap-preset-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.zap-preset-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
color: #ccc;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.zap-preset-btn:hover {
|
||||
background: #333;
|
||||
border-color: #646cff;
|
||||
color: white;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.zap-preset-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.settings-footer {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
@@ -2163,3 +2242,168 @@ body {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Add Bookmark Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 12px;
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.modal-form {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #ccc;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.fetching-indicator {
|
||||
font-size: 0.8rem;
|
||||
color: #999;
|
||||
font-weight: normal;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #646cff;
|
||||
}
|
||||
|
||||
.form-group input:disabled,
|
||||
.form-group textarea:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.form-helper-text {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: #999;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.modal-error {
|
||||
padding: 0.75rem;
|
||||
background: rgba(220, 53, 69, 0.1);
|
||||
border: 1px solid #dc3545;
|
||||
border-radius: 6px;
|
||||
color: #dc3545;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
color: #ccc;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #333;
|
||||
border-color: #646cff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #646cff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #535bf2;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ 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,
|
||||
getHighlightContext,
|
||||
@@ -76,8 +79,26 @@ export async function createHighlight(
|
||||
|
||||
// Add zap tags for nostr-native content (NIP-57 Appendix G)
|
||||
if (typeof source === 'object' && 'kind' in source) {
|
||||
const zapSplitPercentage = settings?.zapSplitPercentage ?? 50
|
||||
addZapTags(highlightEvent, account.pubkey, source.pubkey, zapSplitPercentage)
|
||||
// Migrate old settings format to new weight-based format if needed
|
||||
let highlighterWeight = settings?.zapSplitHighlighterWeight
|
||||
let borisWeight = settings?.zapSplitBorisWeight
|
||||
let authorWeight = settings?.zapSplitAuthorWeight
|
||||
|
||||
const anySettings = settings as Record<string, unknown> | undefined
|
||||
if (!highlighterWeight && anySettings && 'zapSplitPercentage' in anySettings) {
|
||||
highlighterWeight = anySettings.zapSplitPercentage as number
|
||||
authorWeight = 100 - (anySettings.zapSplitPercentage as number)
|
||||
}
|
||||
if (!borisWeight && anySettings && 'borisSupportPercentage' in anySettings) {
|
||||
borisWeight = anySettings.borisSupportPercentage as number
|
||||
}
|
||||
|
||||
// Use defaults if still undefined
|
||||
highlighterWeight = highlighterWeight ?? 50
|
||||
borisWeight = borisWeight ?? 2.1
|
||||
authorWeight = authorWeight ?? 50
|
||||
|
||||
addZapTags(highlightEvent, account.pubkey, source, highlighterWeight, borisWeight, authorWeight)
|
||||
}
|
||||
|
||||
// Sign the event
|
||||
@@ -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)
|
||||
* @param event The highlight event to add zap tags to
|
||||
* Respects existing zap tags in the source event (author group)
|
||||
* @param event The highlight event to add zap tags to (can be EventTemplate or NostrEvent)
|
||||
* @param highlighterPubkey The pubkey of the user creating the highlight
|
||||
* @param authorPubkey The pubkey of the original article author
|
||||
* @param highlighterPercentage Percentage (0-100) to give to the highlighter (default 50)
|
||||
* @param sourceEvent The source event (may contain existing zap tags)
|
||||
* @param highlighterWeight Weight to give to the highlighter (default 50)
|
||||
* @param borisWeight Weight to give to Boris (default 2.1)
|
||||
* @param authorWeight Weight to give to author(s) (default 50)
|
||||
*/
|
||||
function addZapTags(
|
||||
event: NostrEvent,
|
||||
event: { tags: string[][] },
|
||||
highlighterPubkey: string,
|
||||
authorPubkey: string,
|
||||
highlighterPercentage: number = 50
|
||||
sourceEvent: NostrEvent,
|
||||
highlighterWeight: number = 50,
|
||||
borisWeight: number = 2.1,
|
||||
authorWeight: number = 50
|
||||
): void {
|
||||
// Calculate weights based on percentage
|
||||
// Using simple integer weights where highlighterPercentage:authorPercentage ratio is maintained
|
||||
const highlighterWeight = Math.round(highlighterPercentage)
|
||||
const authorWeight = Math.round(100 - highlighterPercentage)
|
||||
|
||||
// Use a reliable relay for zap metadata lookup (first non-local relay)
|
||||
const zapRelay = RELAYS.find(r => !r.includes('localhost')) || RELAYS[0]
|
||||
|
||||
// Add zap tag for the highlighter
|
||||
event.tags.push(['zap', highlighterPubkey, zapRelay, highlighterWeight.toString()])
|
||||
// Extract existing zap tags from source event (the "author group")
|
||||
const existingZapTags = sourceEvent.tags.filter(tag => tag[0] === 'zap')
|
||||
|
||||
// Add zap tag for the original author (only if different from highlighter)
|
||||
if (authorPubkey !== highlighterPubkey) {
|
||||
event.tags.push(['zap', authorPubkey, zapRelay, authorWeight.toString()])
|
||||
// Add zap tag for the highlighter
|
||||
if (highlighterWeight > 0) {
|
||||
event.tags.push(['zap', highlighterPubkey, zapRelay, highlighterWeight.toString()])
|
||||
}
|
||||
|
||||
// Add zap tag for Boris (if weight > 0 and Boris is not the highlighter)
|
||||
if (borisWeight > 0 && BORIS_PUBKEY !== highlighterPubkey) {
|
||||
event.tags.push(['zap', BORIS_PUBKEY, zapRelay, borisWeight.toFixed(1)])
|
||||
}
|
||||
|
||||
if (existingZapTags.length > 0 && authorWeight > 0) {
|
||||
// Calculate total weight from existing zap tags
|
||||
const totalExistingWeight = existingZapTags.reduce((sum, tag) => {
|
||||
const weight = parseFloat(tag[3] || '1')
|
||||
return sum + weight
|
||||
}, 0)
|
||||
|
||||
// Add proportionally adjusted zap tags for each existing author
|
||||
// Don't add the highlighter or Boris again if they're already in the author group
|
||||
for (const zapTag of existingZapTags) {
|
||||
const authorPubkey = zapTag[1]
|
||||
|
||||
// Skip if this is the highlighter or Boris (they already have their shares)
|
||||
if (authorPubkey === highlighterPubkey || authorPubkey === BORIS_PUBKEY) continue
|
||||
|
||||
const originalWeight = parseFloat(zapTag[3] || '1')
|
||||
const originalRelay = zapTag[2] || zapRelay
|
||||
|
||||
// Calculate proportional weight: (original weight / total weight) * author group weight
|
||||
const adjustedWeight = (originalWeight / totalExistingWeight) * authorWeight
|
||||
|
||||
// Only add if weight is greater than 0
|
||||
if (adjustedWeight > 0) {
|
||||
event.tags.push(['zap', authorPubkey, originalRelay, adjustedWeight.toFixed(1)])
|
||||
}
|
||||
}
|
||||
} else if (authorWeight > 0) {
|
||||
// No existing zap tags, give full author weight to source author
|
||||
|
||||
// Add zap tag for the original author (only if different from highlighter and Boris)
|
||||
if (sourceEvent.pubkey !== highlighterPubkey && sourceEvent.pubkey !== BORIS_PUBKEY) {
|
||||
event.tags.push(['zap', sourceEvent.pubkey, zapRelay, authorWeight.toFixed(1)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ export interface ReadableContent {
|
||||
html?: string
|
||||
markdown?: string
|
||||
image?: string
|
||||
summary?: string
|
||||
published?: number
|
||||
}
|
||||
|
||||
interface CachedContent {
|
||||
@@ -57,7 +59,7 @@ function saveToCache(url: string, content: ReadableContent): void {
|
||||
function toProxyUrl(url: string): string {
|
||||
// Ensure the target URL has a protocol and build the proxy URL
|
||||
const normalized = /^https?:\/\//i.test(url) ? url : `https://${url}`
|
||||
return `https://r.jina.ai/http://${normalized.replace(/^https?:\/\//, '')}`
|
||||
return `https://r.jina.ai/${normalized}`
|
||||
}
|
||||
|
||||
export async function fetchReadableContent(
|
||||
|
||||
65
src/services/relayStatusService.ts
Normal file
65
src/services/relayStatusService.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
|
||||
export interface RelayStatus {
|
||||
url: string
|
||||
isInPool: boolean
|
||||
lastSeen: number // timestamp
|
||||
}
|
||||
|
||||
const RECENT_CONNECTION_WINDOW = 20 * 60 * 1000 // 20 minutes
|
||||
|
||||
// In-memory tracking of relay last seen times
|
||||
const relayLastSeen = new Map<string, number>()
|
||||
|
||||
/**
|
||||
* Updates and gets the current status of all relays
|
||||
*/
|
||||
export function updateAndGetRelayStatuses(relayPool: RelayPool): RelayStatus[] {
|
||||
const statuses: RelayStatus[] = []
|
||||
const now = Date.now()
|
||||
const currentRelayUrls = new Set<string>()
|
||||
|
||||
// Update relays currently in the pool
|
||||
for (const relay of relayPool.relays.values()) {
|
||||
currentRelayUrls.add(relay.url)
|
||||
relayLastSeen.set(relay.url, now)
|
||||
|
||||
statuses.push({
|
||||
url: relay.url,
|
||||
isInPool: true,
|
||||
lastSeen: now
|
||||
})
|
||||
}
|
||||
|
||||
// Add recently seen relays that are no longer in the pool
|
||||
const cutoffTime = now - RECENT_CONNECTION_WINDOW
|
||||
for (const [url, lastSeen] of relayLastSeen.entries()) {
|
||||
if (!currentRelayUrls.has(url) && lastSeen >= cutoffTime) {
|
||||
statuses.push({
|
||||
url,
|
||||
isInPool: false,
|
||||
lastSeen
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up old entries
|
||||
for (const [url, lastSeen] of relayLastSeen.entries()) {
|
||||
if (lastSeen < cutoffTime) {
|
||||
relayLastSeen.delete(url)
|
||||
}
|
||||
}
|
||||
|
||||
return statuses.sort((a, b) => {
|
||||
if (a.isInPool !== b.isInPool) return a.isInPool ? -1 : 1
|
||||
return b.lastSeen - a.lastSeen
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets count of currently active relays
|
||||
*/
|
||||
export function getActiveCount(statuses: RelayStatus[]): number {
|
||||
return statuses.filter(r => r.isInPool).length
|
||||
}
|
||||
|
||||
@@ -35,8 +35,10 @@ export interface UserSettings {
|
||||
defaultHighlightVisibilityNostrverse?: boolean
|
||||
defaultHighlightVisibilityFriends?: boolean
|
||||
defaultHighlightVisibilityMine?: boolean
|
||||
// Zap split percentage for highlights (0-100, default 50)
|
||||
zapSplitPercentage?: number
|
||||
// Zap split weights (treated as relative weights, not strict percentages)
|
||||
zapSplitHighlighterWeight?: number // default 50
|
||||
zapSplitBorisWeight?: number // default 2.1
|
||||
zapSplitAuthorWeight?: number // default 50
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export async function loadContent(
|
||||
title: article.title,
|
||||
markdown: article.markdown,
|
||||
image: article.image,
|
||||
summary: article.summary,
|
||||
url: `nostr:${naddr}`
|
||||
}
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user