mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7de8c49b01 | ||
|
|
c3aece1722 | ||
|
|
7a4cb77aa3 | ||
|
|
9065501043 | ||
|
|
c9ace72d4d | ||
|
|
be6ad79f60 | ||
|
|
0473ba71fb | ||
|
|
7e575ea617 | ||
|
|
c3a2dd5603 | ||
|
|
ad54f2aaa5 | ||
|
|
a6ea97b731 | ||
|
|
2f2e19fdf9 | ||
|
|
ce99600aa9 | ||
|
|
77bcc481b5 | ||
|
|
8bb97b3e4e | ||
|
|
2bbfa82eec | ||
|
|
cc68e67726 | ||
|
|
f3a8cf1c23 | ||
|
|
290d9303b5 | ||
|
|
0ca62c4797 | ||
|
|
1441d8d998 | ||
|
|
9252078fb7 | ||
|
|
d5ab88082f | ||
|
|
a8e48ba280 | ||
|
|
dbccb28113 | ||
|
|
b1f6ac88a6 | ||
|
|
c07797ff7c | ||
|
|
41fb51c357 | ||
|
|
5e2abfa8c7 | ||
|
|
7cf2b7d35d | ||
|
|
66f0b2bc3f | ||
|
|
647cf1caf7 | ||
|
|
d4e8e465b4 | ||
|
|
fa52d61c20 | ||
|
|
c407663c2b | ||
|
|
e931f36dee | ||
|
|
ba34e51803 | ||
|
|
c67d831efd | ||
|
|
c1dedb248d | ||
|
|
b177907eb9 | ||
|
|
518c6d9714 | ||
|
|
89b14ce5b7 | ||
|
|
5f7aab90a7 | ||
|
|
6d41d95627 | ||
|
|
9aea1f9a70 | ||
|
|
8594b733ef | ||
|
|
be42203944 | ||
|
|
c51c1810c4 | ||
|
|
6bbc5eb1fc | ||
|
|
ff5c974557 | ||
|
|
61bc64ea26 | ||
|
|
73da428cd7 | ||
|
|
ce2ccd54b3 | ||
|
|
4f8bc0c641 | ||
|
|
d6edddc572 | ||
|
|
d275cb37ab | ||
|
|
959e83699a | ||
|
|
6e0a88fbd9 | ||
|
|
ba682dde1d | ||
|
|
5e788b0026 | ||
|
|
256540bf60 | ||
|
|
e710391962 | ||
|
|
29906397db | ||
|
|
aac4adeda6 | ||
|
|
008c14c14a | ||
|
|
0798267084 | ||
|
|
6088dcc395 | ||
|
|
7425121746 | ||
|
|
7735508c77 | ||
|
|
f2422e9601 | ||
|
|
336f2b62ab | ||
|
|
d3ad08dd61 | ||
|
|
d148433fcc |
9
.cursor/rules/app-settings-and-nostr.mdc
Normal file
9
.cursor/rules/app-settings-and-nostr.mdc
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
description: when dealing with user and app settings
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
We use nostr to load/save/sync our settings.
|
||||
|
||||
- https://nostrbook.dev/kinds/30078
|
||||
- https://github.com/nostr-protocol/nips/blob/master/78.md
|
||||
@@ -3,4 +3,6 @@ description: when creating or modifying UI elements, especially related to icons
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
We use FontAwesome. If you can use a fa-icon (instead of text) use a fa-icon. Always strive to keep the UI modern, beautiful, and minimalistic. Shy away from using too many colors, borders, glow, and animations.
|
||||
We use FontAwesome. If you can use a fa-icon (instead of text) use a fa-icon. Always strive to keep the UI modern, beautiful, and minimalistic. Shy away from using too many colors, borders, glow, and animations.
|
||||
|
||||
Never write "Loading" - always show a spinner, and just a spinner.
|
||||
212
README.md
212
README.md
@@ -1,187 +1,77 @@
|
||||
# Boris
|
||||
|
||||
A minimal nostr client for bookmark management, built with [applesauce](https://github.com/hzrd149/applesauce).
|
||||
Your reading list for the Nostr world.
|
||||
|
||||
## Features
|
||||
Boris turns your Nostr bookmarks into a calm, fast, and focused reading experience. Connect your Nostr account and you'll get a clean three‑pane reader: bookmarks on the left, the article in the middle, and highlights on the right.
|
||||
|
||||
- **Nostr Authentication**: Connect using your nostr account via browser extension
|
||||
- **Bookmark Display**: View your nostr bookmarks as per [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md)
|
||||
- **Content Classification**: Automatically detect and classify URLs (articles, videos, YouTube, images)
|
||||
- **Reader Mode**: View article content inline with readable formatting
|
||||
- **Collapsible Sidebar**: Expand/collapse bookmark list for focused reading
|
||||
- **Profile Integration**: Display user profile images using applesauce ProfileModel
|
||||
- **Relative Timestamps**: Human-friendly time display (e.g., "2 hours ago")
|
||||
- **Event Links**: Quick access to view bookmarks on search.dergigi.com
|
||||
- **Private Bookmarks**: Support for Amethyst-style hidden/encrypted bookmarks
|
||||
- **Highlights Panel**: View and manage your NIP-84 highlights in a dedicated collapsible panel
|
||||
- **Three-Pane Layout**: Bookmarks sidebar, content viewer, and highlights panel working together
|
||||
- **Minimal UI**: Clean, modern interface focused on bookmark management
|
||||
## The Vision
|
||||
|
||||
## Getting Started
|
||||
When I wrote "Purple Text, Orange Highlights" 2.5 years ago, I had a certain interface in mind that would allow the reader to curate, discover, highlight, and provide value to writers and other readers alike. Boris is my attempt to build this interface.
|
||||
|
||||
### Prerequisites
|
||||
Boris has three "levels" of highlights for each article:
|
||||
- user = yellow
|
||||
- friends = orange
|
||||
- nostrverse = purple
|
||||
|
||||
- Node.js 18+
|
||||
- npm, pnpm, or yarn
|
||||
In case it's not self-explanatory:
|
||||
- **your highlights** = highlights that the logged-in npub made
|
||||
- **friends** = highlights that your friends made, i.e. highlights of the npubs that the logged-in user follows
|
||||
- **nostrverse** = all the highlights we can find on all the relays we're connected to
|
||||
|
||||
### Installation
|
||||
The user can toggle hide/show any of these "levels".
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone <your-repo-url>
|
||||
cd boris
|
||||
```
|
||||
In addition to rendering articles from nostr and the legacy web, Boris can act as a "read it later" app, thanks to the power of nostr bookmarks.
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
# or
|
||||
pnpm install
|
||||
# or
|
||||
yarn install
|
||||
```
|
||||
If you bookmark something on nostr, Boris will show it in the bookmarks bar. If said something contains a URL, Boris will extract and render it in a distraction-free and reader-friendly way.
|
||||
|
||||
3. Start the development server:
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
yarn dev
|
||||
```
|
||||
## What Boris does
|
||||
|
||||
4. Open your browser and navigate to `http://localhost:3000`
|
||||
- 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)
|
||||
- Lets you collapse sidebars anytime for full‑focus reading
|
||||
- Remembers simple preferences like view mode, fonts, and highlight style
|
||||
|
||||
## Usage
|
||||
## How it works
|
||||
|
||||
1. **Connect**: Click "Connect with Nostr" to authenticate using your nostr account
|
||||
2. **View Bookmarks**: Once connected, you'll see all your nostr bookmarks in the left sidebar
|
||||
3. **View Highlights**: Your NIP-84 highlights appear in the right panel
|
||||
4. **Navigate**: Click on bookmark URLs to view content in the center panel
|
||||
5. **Collapse Panels**: Use the collapse buttons to hide/show sidebars for focused viewing
|
||||
1. Connect your Nostr account.
|
||||
- Click “Connect” and approve with your usual Nostr signer.
|
||||
2. Browse your bookmarks.
|
||||
- Your lists and items appear on the left. Pick anything to read.
|
||||
3. Read in comfort.
|
||||
- The center panel renders a readable article view with images and headings.
|
||||
4. See what people highlighted.
|
||||
- The right panel shows highlights by level:
|
||||
- Mine (your highlights)
|
||||
- Friends (people you follow)
|
||||
- Nostrverse (everyone else)
|
||||
- Each level has its own color. Click any highlight to jump to that spot.
|
||||
5. Focus when you want.
|
||||
- Collapse one or both side panels. The layout adapts without wasting space.
|
||||
|
||||
## Technical Details
|
||||
## Why people like Boris
|
||||
|
||||
- Built with React and TypeScript
|
||||
- Uses [applesauce-core](https://github.com/hzrd149/applesauce) for nostr functionality
|
||||
- Implements [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md) for bookmark management
|
||||
- Supports both individual bookmarks and bookmark lists
|
||||
- No noise: Just your saved links and the best excerpts others found
|
||||
- Fast by default: Opens instantly in your browser
|
||||
- Portable: Works with any Nostr account; your data travels with you
|
||||
- Designed for reading: Smooth navigation and instant scroll‑to‑highlight
|
||||
|
||||
## Development
|
||||
## Tips
|
||||
|
||||
### Project Structure
|
||||
- Hover icons and counters to see what they do — most controls are discoverable.
|
||||
- Lots of highlights? Scan the right panel and click to jump between them.
|
||||
- Open Settings to switch fonts, tweak highlight styles, and change the list view.
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/
|
||||
│ ├── Login.tsx # Authentication component
|
||||
│ ├── Bookmarks.tsx # Main bookmarks view with layout
|
||||
│ ├── BookmarkList.tsx # Bookmark list sidebar
|
||||
│ ├── BookmarkItem.tsx # Individual bookmark card
|
||||
│ ├── SidebarHeader.tsx # Header bar with collapse, profile, logout
|
||||
│ ├── ContentPanel.tsx # Content viewer panel
|
||||
│ ├── HighlightsPanel.tsx # Highlights sidebar panel (NIP-84)
|
||||
│ ├── HighlightItem.tsx # Individual highlight display
|
||||
│ ├── IconButton.tsx # Reusable icon button component
|
||||
│ ├── ContentWithResolvedProfiles.tsx # Profile mention resolver
|
||||
│ ├── ResolvedMention.tsx # Nostr mention component
|
||||
│ └── kindIcon.ts # Kind-specific icon mapping
|
||||
├── services/
|
||||
│ ├── bookmarkService.ts # Main bookmark fetching orchestration
|
||||
│ ├── bookmarkProcessing.ts # Decryption and processing pipeline
|
||||
│ ├── bookmarkHelpers.ts # Shared types, guards, and utilities
|
||||
│ ├── bookmarkEvents.ts # Event type handling and deduplication
|
||||
│ ├── highlightService.ts # Highlight fetching (NIP-84)
|
||||
│ └── readerService.ts # Content extraction via reader API
|
||||
├── types/
|
||||
│ ├── bookmarks.ts # Bookmark type definitions
|
||||
│ ├── highlights.ts # Highlight type definitions (NIP-84)
|
||||
│ ├── nostr.d.ts # Nostr type augmentations
|
||||
│ └── relative-time.d.ts # relative-time package types
|
||||
├── utils/
|
||||
│ ├── bookmarkUtils.tsx # Bookmark rendering utilities
|
||||
│ └── helpers.ts # General helper functions
|
||||
├── App.tsx # Main application component
|
||||
├── main.tsx # Application entry point
|
||||
└── index.css # Global styles
|
||||
```
|
||||
## Privacy and data
|
||||
|
||||
### Private (hidden) bookmarks (Amethyst-style)
|
||||
- Boris doesn’t ask for an email or create a new account — it connects to your existing Nostr identity.
|
||||
- Your bookmarks and highlights live on Nostr. Boris reads from the network and renders everything locally in your browser.
|
||||
|
||||
We support Amethyst-style private (hidden) bookmark lists alongside public ones (NIP‑51):
|
||||
## Troubleshooting
|
||||
|
||||
- **Detection and unlock**
|
||||
- Use `Helpers.hasHiddenTags(evt)` and `Helpers.isHiddenTagsLocked(evt)` to detect hidden tags.
|
||||
- First try `Helpers.unlockHiddenTags(evt, signer)`; if that fails, try with `'nip44'`.
|
||||
- For events with encrypted `content` that aren’t recognized as supporting hidden tags (e.g. kind 30001), manually decrypt:
|
||||
- Prefer `signer.nip44.decrypt(evt.pubkey, evt.content)`, fallback to `signer.nip04.decrypt(evt.pubkey, evt.content)`.
|
||||
|
||||
- **Parsing and rendering**
|
||||
- Decrypted `content` is JSON `string[][]` (tags). Convert with `Helpers.parseBookmarkTags(hiddenTags)`.
|
||||
- Map to `IndividualBookmark[]` via our `processApplesauceBookmarks(..., isPrivate=true)` and append to the private list so they render immediately alongside public items.
|
||||
|
||||
- **Caching for downstream helpers**
|
||||
- Cache manual results on the event with `BookmarkHiddenSymbol` and also store the decrypted blob under `EncryptedContentSymbol` to aid debugging and hydration.
|
||||
|
||||
- **Structure**
|
||||
- `src/services/bookmarkService.ts`: orchestrates fetching, hydration, and assembling the final bookmark payload.
|
||||
- `src/services/bookmarkProcessing.ts`: decryption/collection pipeline (unlock, manual decrypt, parse, merge).
|
||||
- `src/services/bookmarkHelpers.ts`: shared types, guards, mapping, hydration, and symbols.
|
||||
- `src/services/bookmarkEvents.ts`: event type and de‑duplication for NIP‑51 lists/sets.
|
||||
|
||||
- **Notes**
|
||||
- We avoid `any` via narrow type guards for `nip44`/`nip04` decrypt functions.
|
||||
- Files are kept small and DRY per project rules.
|
||||
- Built on applesauce helpers (`Helpers.getPublicBookmarks`, `Helpers.getHiddenBookmarks`, etc.). See applesauce docs: https://hzrd149.github.io/applesauce/typedoc/modules.html
|
||||
|
||||
### Building for Production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
# or
|
||||
pnpm build
|
||||
# or
|
||||
yarn build
|
||||
```
|
||||
|
||||
## TODO
|
||||
|
||||
### High Priority
|
||||
- [ ] **Mobile Responsive Design**: Optimize sidebar and content panel for mobile devices
|
||||
- [ ] **Keyboard Shortcuts**: Add keyboard navigation (collapse sidebar, navigate bookmarks)
|
||||
- [ ] **Search & Filter**: Add ability to search bookmarks by title, URL, or content
|
||||
- [ ] **Error Handling**: Improve error states and retry logic for failed fetches
|
||||
- [ ] **Loading States**: Better skeleton screens and loading indicators
|
||||
|
||||
### Medium Priority
|
||||
- [ ] **Bookmark Creation**: Add ability to create new bookmarks
|
||||
- [ ] **Bookmark Editing**: Edit existing bookmark metadata and tags
|
||||
- [ ] **Bookmark Deletion**: Remove bookmarks from lists
|
||||
- [ ] **Sorting Options**: Sort by date, title, kind, or custom order
|
||||
- [ ] **Bulk Actions**: Select and perform actions on multiple bookmarks
|
||||
- [ ] **Video Embeds**: Inline YouTube and video playback for video bookmarks
|
||||
|
||||
### Nice to Have
|
||||
- [ ] **Dark/Light Mode Toggle**: User preference for color scheme
|
||||
- [ ] **Export Functionality**: Export bookmarks as JSON, CSV, or HTML
|
||||
- [ ] **Import Bookmarks**: Import from browser bookmarks or other formats
|
||||
- [ ] **Tags & Categories**: Better organization with custom tags
|
||||
- [ ] **Bookmark Collections**: Create and manage custom bookmark collections
|
||||
- [ ] **Offline Support**: Cache bookmarks for offline viewing
|
||||
- [ ] **Share Bookmarks**: Generate shareable links to bookmark lists
|
||||
- [ ] **Performance Optimization**: Virtual scrolling for large bookmark lists
|
||||
- [ ] **Browser Extension**: Quick bookmark saving from any page
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request. Make sure to:
|
||||
|
||||
- Follow the existing code style
|
||||
- Keep files under 210 lines
|
||||
- Use conventional commits
|
||||
- Run linter and type checks before submitting
|
||||
- If something looks empty, try opening another article and coming back — network data can arrive in bursts.
|
||||
- Not every article has highlights yet; they grow as the community reads.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
|
||||
4
dist/index.html
vendored
4
dist/index.html
vendored
@@ -5,8 +5,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Boris - Nostr Bookmarks</title>
|
||||
<script type="module" crossorigin src="/assets/index-8PiwZoBK.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Dljx1pJR.css">
|
||||
<script type="module" crossorigin src="/assets/index--wClm1wz.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bj-Uhit8.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.1.10",
|
||||
"version": "0.2.1",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
200
src/App.tsx
200
src/App.tsx
@@ -1,32 +1,68 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { EventStoreProvider, AccountsProvider } from 'applesauce-react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||
import { EventStoreProvider, AccountsProvider, Hooks } from 'applesauce-react'
|
||||
import { EventStore } from 'applesauce-core'
|
||||
import { AccountManager } from 'applesauce-accounts'
|
||||
import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
||||
import Login from './components/Login'
|
||||
import Bookmarks from './components/Bookmarks'
|
||||
import Toast from './components/Toast'
|
||||
import { useToast } from './hooks/useToast'
|
||||
|
||||
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
||||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
||||
|
||||
// AppRoutes component that has access to hooks
|
||||
function AppRoutes({
|
||||
relayPool,
|
||||
showToast
|
||||
}: {
|
||||
relayPool: RelayPool
|
||||
showToast: (message: string) => void
|
||||
}) {
|
||||
const accountManager = Hooks.useAccountManager()
|
||||
|
||||
const handleLogout = () => {
|
||||
accountManager.setActive(undefined as never)
|
||||
localStorage.removeItem('active')
|
||||
showToast('Logged out successfully')
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/a/:naddr"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [eventStore, setEventStore] = useState<EventStore | null>(null)
|
||||
const [accountManager, setAccountManager] = useState<AccountManager | null>(null)
|
||||
const [relayPool, setRelayPool] = useState<RelayPool | null>(null)
|
||||
const { toastMessage, toastType, showToast, clearToast } = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize event store, account manager, and relay pool
|
||||
const store = new EventStore()
|
||||
const accounts = new AccountManager()
|
||||
|
||||
// Register common account types (needed for deserialization)
|
||||
registerCommonAccountTypes(accounts)
|
||||
|
||||
// Load persisted accounts from localStorage
|
||||
const loadAccounts = async () => {
|
||||
const initializeApp = async () => {
|
||||
// Initialize event store, account manager, and relay pool
|
||||
const store = new EventStore()
|
||||
const accounts = new AccountManager()
|
||||
|
||||
// Register common account types (needed for deserialization)
|
||||
registerCommonAccountTypes(accounts)
|
||||
|
||||
// Load persisted accounts from localStorage
|
||||
try {
|
||||
const json = JSON.parse(localStorage.getItem('accounts') || '[]')
|
||||
await accounts.fromJSON(json)
|
||||
@@ -41,70 +77,81 @@ function App() {
|
||||
} catch (err) {
|
||||
console.error('Failed to load accounts from storage:', err)
|
||||
}
|
||||
|
||||
// Subscribe to accounts changes and persist to localStorage
|
||||
const accountsSub = accounts.accounts$.subscribe(() => {
|
||||
localStorage.setItem('accounts', JSON.stringify(accounts.toJSON()))
|
||||
})
|
||||
|
||||
// Subscribe to active account changes and persist to localStorage
|
||||
const activeSub = accounts.active$.subscribe((account) => {
|
||||
if (account) {
|
||||
localStorage.setItem('active', account.id)
|
||||
} else {
|
||||
localStorage.removeItem('active')
|
||||
}
|
||||
})
|
||||
|
||||
const pool = new RelayPool()
|
||||
|
||||
// Define relay URLs for bookmark fetching
|
||||
const relayUrls = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.nostr.band',
|
||||
'wss://relay.dergigi.com',
|
||||
'wss://wot.dergigi.com',
|
||||
'wss://relay.snort.social',
|
||||
'wss://relay.current.fyi',
|
||||
'wss://nostr-pub.wellorder.net'
|
||||
]
|
||||
|
||||
// Create a relay group for better event deduplication and management
|
||||
// This follows the applesauce-relay documentation pattern
|
||||
// Note: We could use pool.group(relayUrls) for direct requests in the future
|
||||
pool.group(relayUrls)
|
||||
console.log('Created relay group with', relayUrls.length, 'relays')
|
||||
console.log('Relay URLs:', relayUrls)
|
||||
|
||||
// Attach address/replaceable loaders so ProfileModel can fetch profiles
|
||||
const addressLoader = createAddressLoader(pool, {
|
||||
eventStore: store,
|
||||
lookupRelays: [
|
||||
'wss://purplepag.es',
|
||||
'wss://relay.primal.net',
|
||||
'wss://relay.nostr.band'
|
||||
]
|
||||
})
|
||||
store.addressableLoader = addressLoader
|
||||
store.replaceableLoader = addressLoader
|
||||
|
||||
setEventStore(store)
|
||||
setAccountManager(accounts)
|
||||
setRelayPool(pool)
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
accountsSub.unsubscribe()
|
||||
activeSub.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
loadAccounts()
|
||||
|
||||
// Subscribe to accounts changes and persist to localStorage
|
||||
const accountsSub = accounts.accounts$.subscribe(() => {
|
||||
localStorage.setItem('accounts', JSON.stringify(accounts.toJSON()))
|
||||
let cleanup: (() => void) | undefined
|
||||
initializeApp().then((fn) => {
|
||||
cleanup = fn
|
||||
})
|
||||
|
||||
// Subscribe to active account changes and persist to localStorage
|
||||
const activeSub = accounts.active$.subscribe((account) => {
|
||||
if (account) {
|
||||
localStorage.setItem('active', account.id)
|
||||
} else {
|
||||
localStorage.removeItem('active')
|
||||
}
|
||||
})
|
||||
|
||||
const pool = new RelayPool()
|
||||
|
||||
// Define relay URLs for bookmark fetching
|
||||
const relayUrls = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.nostr.band',
|
||||
'wss://relay.dergigi.com',
|
||||
'wss://wot.dergigi.com',
|
||||
'wss://relay.snort.social',
|
||||
'wss://relay.current.fyi',
|
||||
'wss://nostr-pub.wellorder.net'
|
||||
]
|
||||
|
||||
// Create a relay group for better event deduplication and management
|
||||
// This follows the applesauce-relay documentation pattern
|
||||
// Note: We could use pool.group(relayUrls) for direct requests in the future
|
||||
pool.group(relayUrls)
|
||||
console.log('Created relay group with', relayUrls.length, 'relays')
|
||||
console.log('Relay URLs:', relayUrls)
|
||||
|
||||
// Attach address/replaceable loaders so ProfileModel can fetch profiles
|
||||
const addressLoader = createAddressLoader(pool, {
|
||||
eventStore: store,
|
||||
lookupRelays: [
|
||||
'wss://purplepag.es',
|
||||
'wss://relay.primal.net',
|
||||
'wss://relay.nostr.band'
|
||||
]
|
||||
})
|
||||
store.addressableLoader = addressLoader
|
||||
store.replaceableLoader = addressLoader
|
||||
|
||||
setEventStore(store)
|
||||
setAccountManager(accounts)
|
||||
setRelayPool(pool)
|
||||
|
||||
// Cleanup subscriptions on unmount
|
||||
return () => {
|
||||
accountsSub.unsubscribe()
|
||||
activeSub.unsubscribe()
|
||||
if (cleanup) cleanup()
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!eventStore || !accountManager || !relayPool) {
|
||||
return <div>Loading...</div>
|
||||
return (
|
||||
<div className="loading">
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -112,21 +159,16 @@ function App() {
|
||||
<AccountsProvider manager={accountManager}>
|
||||
<BrowserRouter>
|
||||
<div className="app">
|
||||
<Routes>
|
||||
<Route
|
||||
path="/a/:naddr"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={() => {}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
|
||||
<Route path="/login" element={<Login onLogin={() => {}} />} />
|
||||
</Routes>
|
||||
<AppRoutes relayPool={relayPool} showToast={showToast} />
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
{toastMessage && (
|
||||
<Toast
|
||||
message={toastMessage}
|
||||
type={toastType}
|
||||
onClose={clearToast}
|
||||
/>
|
||||
)}
|
||||
</AccountsProvider>
|
||||
</EventStoreProvider>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronLeft, faBookmark } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Bookmark } from '../types/bookmarks'
|
||||
import { BookmarkItem } from './BookmarkItem'
|
||||
import { formatDate, renderParsedContent } from '../utils/bookmarkUtils'
|
||||
import SidebarHeader from './SidebarHeader'
|
||||
import IconButton from './IconButton'
|
||||
import { ViewMode } from './Bookmarks'
|
||||
|
||||
interface BookmarkListProps {
|
||||
@@ -17,6 +18,9 @@ interface BookmarkListProps {
|
||||
onViewModeChange: (mode: ViewMode) => void
|
||||
selectedUrl?: string
|
||||
onOpenSettings: () => void
|
||||
onRefresh?: () => void
|
||||
isRefreshing?: boolean
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
@@ -28,7 +32,10 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
selectedUrl,
|
||||
onOpenSettings
|
||||
onOpenSettings,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
loading = false
|
||||
}) => {
|
||||
if (isCollapsed) {
|
||||
// Check if the selected URL is in bookmarks
|
||||
@@ -57,12 +64,16 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
<SidebarHeader
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
onLogout={onLogout}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={onViewModeChange}
|
||||
onOpenSettings={onOpenSettings}
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
/>
|
||||
|
||||
{bookmarks.length === 0 ? (
|
||||
{loading ? (
|
||||
<div className="loading">
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
</div>
|
||||
) : bookmarks.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No bookmarks found.</p>
|
||||
<p>Add bookmarks using your nostr client to see them here.</p>
|
||||
@@ -139,6 +150,29 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="view-mode-controls">
|
||||
<IconButton
|
||||
icon={faList}
|
||||
onClick={() => onViewModeChange('compact')}
|
||||
title="Compact list view"
|
||||
ariaLabel="Compact list view"
|
||||
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faThLarge}
|
||||
onClick={() => onViewModeChange('cards')}
|
||||
title="Cards view"
|
||||
ariaLabel="Cards view"
|
||||
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faImage}
|
||||
onClick={() => onViewModeChange('large')}
|
||||
title="Large preview view"
|
||||
ariaLabel="Large preview view"
|
||||
variant={viewMode === 'large' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ import { useSettings } from '../hooks/useSettings'
|
||||
import { useArticleLoader } from '../hooks/useArticleLoader'
|
||||
import { loadContent, BookmarkReference } from '../utils/contentLoader'
|
||||
import { HighlightVisibility } from './HighlightsPanel'
|
||||
import { HighlightButton, HighlightButtonRef } from './HighlightButton'
|
||||
import { createHighlight } from '../services/highlightCreationService'
|
||||
import { useRef, useCallback } from 'react'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
export type ViewMode = 'compact' | 'cards' | 'large'
|
||||
|
||||
interface BookmarksProps {
|
||||
@@ -28,6 +32,7 @@ interface BookmarksProps {
|
||||
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
const { naddr } = useParams<{ naddr?: string }>()
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||
const [bookmarksLoading, setBookmarksLoading] = useState(true)
|
||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||
const [highlightsLoading, setHighlightsLoading] = useState(true)
|
||||
const [selectedUrl, setSelectedUrl] = useState<string | undefined>(undefined)
|
||||
@@ -36,20 +41,23 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState(true) // Start collapsed
|
||||
const [isHighlightsCollapsed, setIsHighlightsCollapsed] = useState(true) // Start collapsed
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('compact')
|
||||
const [showUnderlines, setShowUnderlines] = useState(true)
|
||||
const [showHighlights, setShowHighlights] = useState(true)
|
||||
const [selectedHighlightId, setSelectedHighlightId] = useState<string | undefined>(undefined)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [currentArticleCoordinate, setCurrentArticleCoordinate] = useState<string | undefined>(undefined)
|
||||
const [currentArticleEventId, setCurrentArticleEventId] = useState<string | undefined>(undefined)
|
||||
const [currentArticle, setCurrentArticle] = useState<NostrEvent | undefined>(undefined) // Store the current article event
|
||||
const [highlightVisibility, setHighlightVisibility] = useState<HighlightVisibility>({
|
||||
nostrverse: true,
|
||||
friends: true,
|
||||
mine: true
|
||||
})
|
||||
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const accountManager = Hooks.useAccountManager()
|
||||
const eventStore = useEventStore()
|
||||
const highlightButtonRef = useRef<HighlightButtonRef>(null)
|
||||
|
||||
const { settings, saveSettings, toastMessage, toastType, clearToast } = useSettings({
|
||||
relayPool,
|
||||
@@ -66,18 +74,22 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
setIsCollapsed,
|
||||
setIsHighlightsCollapsed,
|
||||
setHighlights,
|
||||
setHighlightsLoading,
|
||||
setCurrentArticleCoordinate,
|
||||
setCurrentArticleEventId
|
||||
setCurrentArticleEventId,
|
||||
setCurrentArticle
|
||||
})
|
||||
|
||||
// Load initial data on login
|
||||
useEffect(() => {
|
||||
if (!relayPool || !activeAccount) return
|
||||
handleFetchBookmarks()
|
||||
handleFetchHighlights()
|
||||
// Avoid overwriting article-specific highlights during initial article load
|
||||
// If an article is being viewed (naddr present), let useArticleLoader own the first highlights set
|
||||
if (!naddr) {
|
||||
handleFetchHighlights()
|
||||
}
|
||||
handleFetchContacts()
|
||||
}, [relayPool, activeAccount?.pubkey])
|
||||
|
||||
@@ -90,15 +102,20 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
// Apply UI settings
|
||||
useEffect(() => {
|
||||
if (settings.defaultViewMode) setViewMode(settings.defaultViewMode)
|
||||
if (settings.showUnderlines !== undefined) setShowUnderlines(settings.showUnderlines)
|
||||
if (settings.sidebarCollapsed !== undefined) setIsCollapsed(settings.sidebarCollapsed)
|
||||
if (settings.highlightsCollapsed !== undefined) setIsHighlightsCollapsed(settings.highlightsCollapsed)
|
||||
if (settings.showHighlights !== undefined) setShowHighlights(settings.showHighlights)
|
||||
// Always start with both panels collapsed on initial load
|
||||
// Don't apply saved collapse settings on initial load - let user control them
|
||||
}, [settings])
|
||||
|
||||
const handleFetchBookmarks = async () => {
|
||||
if (!relayPool || !activeAccount) return
|
||||
const fullAccount = accountManager.getActive()
|
||||
await fetchBookmarks(relayPool, fullAccount || activeAccount, setBookmarks)
|
||||
setBookmarksLoading(true)
|
||||
try {
|
||||
const fullAccount = accountManager.getActive()
|
||||
await fetchBookmarks(relayPool, fullAccount || activeAccount, setBookmarks)
|
||||
} finally {
|
||||
setBookmarksLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFetchHighlights = async () => {
|
||||
@@ -108,13 +125,18 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
try {
|
||||
// If we're viewing an article, fetch highlights for that article
|
||||
if (currentArticleCoordinate) {
|
||||
const fetchedHighlights = await fetchHighlightsForArticle(
|
||||
const highlightsList: Highlight[] = []
|
||||
await fetchHighlightsForArticle(
|
||||
relayPool,
|
||||
currentArticleCoordinate,
|
||||
currentArticleEventId
|
||||
currentArticleEventId,
|
||||
(highlight) => {
|
||||
// Render each highlight immediately as it arrives
|
||||
highlightsList.push(highlight)
|
||||
setHighlights([...highlightsList].sort((a, b) => b.created_at - a.created_at))
|
||||
}
|
||||
)
|
||||
console.log(`🔄 Refreshed ${fetchedHighlights.length} highlights for article`)
|
||||
setHighlights(fetchedHighlights)
|
||||
console.log(`🔄 Refreshed ${highlightsList.length} highlights for article`)
|
||||
}
|
||||
// Otherwise, if logged in, fetch user's own highlights
|
||||
else if (activeAccount) {
|
||||
@@ -128,6 +150,21 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefreshBookmarks = async () => {
|
||||
if (!relayPool || !activeAccount || isRefreshing) return
|
||||
|
||||
setIsRefreshing(true)
|
||||
try {
|
||||
await handleFetchBookmarks()
|
||||
await handleFetchHighlights()
|
||||
await handleFetchContacts()
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh bookmarks:', err)
|
||||
} finally {
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Classify highlights with levels based on user context
|
||||
const classifiedHighlights = useMemo(() => {
|
||||
return highlights.map(h => {
|
||||
@@ -147,12 +184,16 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
setSelectedUrl(url)
|
||||
setReaderLoading(true)
|
||||
setReaderContent(undefined)
|
||||
setCurrentArticle(undefined) // Clear previous article
|
||||
setShowSettings(false)
|
||||
if (settings.collapseOnArticleOpen !== false) setIsCollapsed(true)
|
||||
|
||||
try {
|
||||
const content = await loadContent(url, relayPool, bookmark)
|
||||
setReaderContent(content)
|
||||
|
||||
// Note: currentArticle is set by useArticleLoader when loading Nostr articles
|
||||
// For web bookmarks, there's no article event to set
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch content:', err)
|
||||
} finally {
|
||||
@@ -160,6 +201,54 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleHighlightCreated = async () => {
|
||||
// Refresh highlights after creating a new one
|
||||
if (!relayPool || !currentArticleCoordinate) return
|
||||
|
||||
try {
|
||||
const newHighlights = await fetchHighlightsForArticle(
|
||||
relayPool,
|
||||
currentArticleCoordinate,
|
||||
currentArticleEventId
|
||||
)
|
||||
setHighlights(newHighlights)
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh highlights:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTextSelection = useCallback((text: string) => {
|
||||
highlightButtonRef.current?.updateSelection(text)
|
||||
}, [])
|
||||
|
||||
const handleClearSelection = useCallback(() => {
|
||||
highlightButtonRef.current?.clearSelection()
|
||||
}, [])
|
||||
|
||||
const handleCreateHighlight = useCallback(async (text: string) => {
|
||||
if (!activeAccount || !relayPool || !currentArticle) {
|
||||
console.error('Missing requirements for highlight creation')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await createHighlight(
|
||||
text,
|
||||
currentArticle,
|
||||
activeAccount,
|
||||
relayPool
|
||||
)
|
||||
|
||||
console.log('✅ Highlight created successfully!')
|
||||
highlightButtonRef.current?.clearSelection()
|
||||
|
||||
// Trigger refresh of highlights
|
||||
handleHighlightCreated()
|
||||
} catch (error) {
|
||||
console.error('Failed to create highlight:', error)
|
||||
}
|
||||
}, [activeAccount, relayPool, currentArticle, handleHighlightCreated])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`three-pane ${isCollapsed ? 'sidebar-collapsed' : ''} ${isHighlightsCollapsed ? 'highlights-collapsed' : ''}`}>
|
||||
@@ -178,6 +267,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
setIsCollapsed(true)
|
||||
setIsHighlightsCollapsed(true)
|
||||
}}
|
||||
onRefresh={handleRefreshBookmarks}
|
||||
isRefreshing={isRefreshing}
|
||||
loading={bookmarksLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="pane main">
|
||||
@@ -196,7 +288,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
image={readerContent?.image}
|
||||
selectedUrl={selectedUrl}
|
||||
highlights={classifiedHighlights}
|
||||
showUnderlines={showUnderlines}
|
||||
showHighlights={showHighlights}
|
||||
highlightStyle={settings.highlightStyle || 'marker'}
|
||||
highlightColor={settings.highlightColor || '#ffff00'}
|
||||
onHighlightClick={(id) => {
|
||||
@@ -204,6 +296,11 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
if (isHighlightsCollapsed) setIsHighlightsCollapsed(false)
|
||||
}}
|
||||
selectedHighlightId={selectedHighlightId}
|
||||
highlightVisibility={highlightVisibility}
|
||||
onTextSelection={handleTextSelection}
|
||||
onClearSelection={handleClearSelection}
|
||||
currentUserPubkey={activeAccount?.pubkey}
|
||||
followedPubkeys={followedPubkeys}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -215,7 +312,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
onToggleCollapse={() => setIsHighlightsCollapsed(!isHighlightsCollapsed)}
|
||||
onSelectUrl={handleSelectUrl}
|
||||
selectedUrl={selectedUrl}
|
||||
onToggleUnderlines={setShowUnderlines}
|
||||
onToggleHighlights={setShowHighlights}
|
||||
selectedHighlightId={selectedHighlightId}
|
||||
onRefresh={handleFetchHighlights}
|
||||
onHighlightClick={setSelectedHighlightId}
|
||||
@@ -226,6 +323,13 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{activeAccount && relayPool && (
|
||||
<HighlightButton
|
||||
ref={highlightButtonRef}
|
||||
onHighlight={handleCreateHighlight}
|
||||
highlightColor={settings.highlightColor || '#ffff00'}
|
||||
/>
|
||||
)}
|
||||
{toastMessage && (
|
||||
<Toast
|
||||
message={toastMessage}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import React, { useMemo, useEffect, useRef, useState } from 'react'
|
||||
import React, { useMemo, useEffect, useRef, useState, useCallback } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner, faHighlighter, faClock } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { applyHighlightsToHTML } from '../utils/highlightMatching'
|
||||
import { readingTime } from 'reading-time-estimator'
|
||||
import { filterHighlightsByUrl } from '../utils/urlHelpers'
|
||||
import { hexToRgb } from '../utils/colorHelpers'
|
||||
import ReaderHeader from './ReaderHeader'
|
||||
import { HighlightVisibility } from './HighlightsPanel'
|
||||
|
||||
interface ContentPanelProps {
|
||||
loading: boolean
|
||||
@@ -17,11 +19,17 @@ interface ContentPanelProps {
|
||||
selectedUrl?: string
|
||||
image?: string
|
||||
highlights?: Highlight[]
|
||||
showUnderlines?: boolean
|
||||
showHighlights?: boolean
|
||||
highlightStyle?: 'marker' | 'underline'
|
||||
highlightColor?: string
|
||||
onHighlightClick?: (highlightId: string) => void
|
||||
selectedHighlightId?: string
|
||||
highlightVisibility?: HighlightVisibility
|
||||
currentUserPubkey?: string
|
||||
followedPubkeys?: Set<string>
|
||||
// For highlight creation
|
||||
onTextSelection?: (text: string) => void
|
||||
onClearSelection?: () => void
|
||||
}
|
||||
|
||||
const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
@@ -32,17 +40,45 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
selectedUrl,
|
||||
image,
|
||||
highlights = [],
|
||||
showUnderlines = true,
|
||||
showHighlights = true,
|
||||
highlightStyle = 'marker',
|
||||
highlightColor = '#ffff00',
|
||||
onHighlightClick,
|
||||
selectedHighlightId
|
||||
selectedHighlightId,
|
||||
highlightVisibility = { nostrverse: true, friends: true, mine: true },
|
||||
currentUserPubkey,
|
||||
followedPubkeys = new Set(),
|
||||
// For highlight creation
|
||||
onTextSelection,
|
||||
onClearSelection
|
||||
}) => {
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const markdownPreviewRef = useRef<HTMLDivElement>(null)
|
||||
const [renderedHtml, setRenderedHtml] = useState<string>('')
|
||||
|
||||
const relevantHighlights = useMemo(() => filterHighlightsByUrl(highlights, selectedUrl), [selectedUrl, highlights])
|
||||
// Filter highlights by URL and visibility settings
|
||||
const relevantHighlights = useMemo(() => {
|
||||
const urlFiltered = filterHighlightsByUrl(highlights, selectedUrl)
|
||||
|
||||
// Apply visibility filtering
|
||||
return urlFiltered
|
||||
.map(h => {
|
||||
// Classify highlight level
|
||||
let level: 'mine' | 'friends' | 'nostrverse' = 'nostrverse'
|
||||
if (h.pubkey === currentUserPubkey) {
|
||||
level = 'mine'
|
||||
} else if (followedPubkeys.has(h.pubkey)) {
|
||||
level = 'friends'
|
||||
}
|
||||
return { ...h, level }
|
||||
})
|
||||
.filter(h => {
|
||||
// Filter by visibility settings
|
||||
if (h.level === 'mine') return highlightVisibility.mine
|
||||
if (h.level === 'friends') return highlightVisibility.friends
|
||||
return highlightVisibility.nostrverse
|
||||
})
|
||||
}, [selectedUrl, highlights, highlightVisibility, currentUserPubkey, followedPubkeys])
|
||||
|
||||
// Convert markdown to HTML when markdown content changes
|
||||
useEffect(() => {
|
||||
@@ -66,17 +102,14 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
const sourceHtml = markdown ? renderedHtml : html
|
||||
if (!sourceHtml) return ''
|
||||
|
||||
// Apply highlights if we have them and underlines are shown
|
||||
if (showUnderlines && relevantHighlights.length > 0) {
|
||||
// Apply highlights if we have them and highlights are enabled
|
||||
if (showHighlights && relevantHighlights.length > 0) {
|
||||
return applyHighlightsToHTML(sourceHtml, relevantHighlights, highlightStyle)
|
||||
}
|
||||
|
||||
return sourceHtml
|
||||
}, [html, renderedHtml, markdown, relevantHighlights, showUnderlines, highlightStyle])
|
||||
}, [html, renderedHtml, markdown, relevantHighlights, showHighlights, highlightStyle])
|
||||
|
||||
// Check if we need to wait for HTML conversion
|
||||
const needsHtmlConversion = markdown && !renderedHtml
|
||||
const shouldShowContent = !needsHtmlConversion || relevantHighlights.length === 0
|
||||
|
||||
// Attach click handlers to highlight marks
|
||||
useEffect(() => {
|
||||
@@ -131,6 +164,26 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
|
||||
const hasHighlights = relevantHighlights.length > 0
|
||||
|
||||
// Handle text selection for highlight creation
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setTimeout(() => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
onClearSelection?.()
|
||||
return
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
const text = selection.toString().trim()
|
||||
|
||||
if (text.length > 0 && contentRef.current?.contains(range.commonAncestorContainer)) {
|
||||
onTextSelection?.(text)
|
||||
} else {
|
||||
onClearSelection?.()
|
||||
}
|
||||
}, 10)
|
||||
}, [onTextSelection, onClearSelection])
|
||||
|
||||
if (!selectedUrl) {
|
||||
return (
|
||||
<div className="reader empty">
|
||||
@@ -144,7 +197,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
<div className="reader loading">
|
||||
<div className="loading-spinner">
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
<span>Loading content…</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -163,49 +215,41 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
<div className="reader-meta">
|
||||
{readingStats && (
|
||||
<div className="reading-time">
|
||||
<FontAwesomeIcon icon={faClock} />
|
||||
<span>{readingStats.text}</span>
|
||||
</div>
|
||||
)}
|
||||
{hasHighlights && (
|
||||
<div className="highlight-indicator">
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span>{relevantHighlights.length} highlight{relevantHighlights.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ReaderHeader
|
||||
title={title}
|
||||
image={image}
|
||||
readingTimeText={readingStats ? readingStats.text : null}
|
||||
hasHighlights={hasHighlights}
|
||||
highlightCount={relevantHighlights.length}
|
||||
/>
|
||||
{markdown || html ? (
|
||||
finalHtml || (markdown && shouldShowContent) ? (
|
||||
markdown ? (
|
||||
finalHtml ? (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={markdown ? "reader-markdown" : "reader-html"}
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml }}
|
||||
className="reader-markdown"
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml }}
|
||||
onMouseUp={handleMouseUp}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="reader-markdown"
|
||||
onMouseUp={handleMouseUp}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{markdown}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
) : null
|
||||
) : (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="reader-html"
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
|
||||
onMouseUp={handleMouseUp}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="reader empty">
|
||||
<p>No readable content found for this URL.</p>
|
||||
|
||||
79
src/components/HighlightButton.tsx
Normal file
79
src/components/HighlightButton.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React, { useCallback, useImperativeHandle, useRef, useState } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
interface HighlightButtonProps {
|
||||
onHighlight: (text: string) => void
|
||||
highlightColor?: string
|
||||
}
|
||||
|
||||
export interface HighlightButtonRef {
|
||||
updateSelection: (text: string) => void
|
||||
clearSelection: () => void
|
||||
}
|
||||
|
||||
export const HighlightButton = React.forwardRef<HighlightButtonRef, HighlightButtonProps>(
|
||||
({ onHighlight, highlightColor = '#ffff00' }, ref) => {
|
||||
const currentSelectionRef = useRef<string>('')
|
||||
const [hasSelection, setHasSelection] = useState(false)
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (currentSelectionRef.current) {
|
||||
onHighlight(currentSelectionRef.current)
|
||||
}
|
||||
},
|
||||
[onHighlight]
|
||||
)
|
||||
|
||||
// Expose methods to update selection
|
||||
useImperativeHandle(ref, () => ({
|
||||
updateSelection: (text: string) => {
|
||||
currentSelectionRef.current = text
|
||||
setHasSelection(!!text)
|
||||
},
|
||||
clearSelection: () => {
|
||||
currentSelectionRef.current = ''
|
||||
setHasSelection(false)
|
||||
}
|
||||
}))
|
||||
|
||||
return (
|
||||
<button
|
||||
className="highlight-fab"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '32px',
|
||||
right: '32px',
|
||||
zIndex: 1000,
|
||||
width: '56px',
|
||||
height: '56px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: highlightColor,
|
||||
color: '#000',
|
||||
border: 'none',
|
||||
boxShadow: hasSelection ? '0 4px 12px rgba(0, 0, 0, 0.3)' : 'none',
|
||||
cursor: hasSelection ? 'pointer' : 'default',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.3s ease',
|
||||
opacity: hasSelection ? 1 : 0.4,
|
||||
transform: hasSelection ? 'scale(1)' : 'scale(0.8)',
|
||||
pointerEvents: hasSelection ? 'auto' : 'none',
|
||||
userSelect: 'none'
|
||||
}}
|
||||
onClick={handleClick}
|
||||
aria-label="Create highlight from selection"
|
||||
title={hasSelection ? 'Create highlight' : ''}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} size="lg" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
HighlightButton.displayName = 'HighlightButton'
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faQuoteLeft, faLink, faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faQuoteLeft, faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
|
||||
interface HighlightWithLevel extends Highlight {
|
||||
level?: 'mine' | 'friends' | 'nostrverse'
|
||||
}
|
||||
|
||||
interface HighlightItemProps {
|
||||
highlight: Highlight
|
||||
highlight: HighlightWithLevel
|
||||
onSelectUrl?: (url: string) => void
|
||||
isSelected?: boolean
|
||||
onHighlightClick?: (highlightId: string) => void
|
||||
@@ -14,6 +20,16 @@ interface HighlightItemProps {
|
||||
export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelectUrl, isSelected, onHighlightClick }) => {
|
||||
const itemRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Resolve the profile of the user who made the highlight
|
||||
const profile = useEventModel(Models.ProfileModel, [highlight.pubkey])
|
||||
|
||||
// Get display name for the user
|
||||
const getUserDisplayName = () => {
|
||||
if (profile?.name) return profile.name
|
||||
if (profile?.display_name) return profile.display_name
|
||||
return `${highlight.pubkey.slice(0, 8)}...` // fallback to short pubkey
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelected && itemRef.current) {
|
||||
itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
@@ -45,7 +61,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelec
|
||||
return (
|
||||
<div
|
||||
ref={itemRef}
|
||||
className={`highlight-item ${isSelected ? 'selected' : ''}`}
|
||||
className={`highlight-item ${isSelected ? 'selected' : ''} ${highlight.level ? `level-${highlight.level}` : ''}`}
|
||||
data-highlight-id={highlight.id}
|
||||
onClick={handleItemClick}
|
||||
style={{ cursor: onHighlightClick ? 'pointer' : 'default' }}
|
||||
@@ -65,14 +81,12 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelec
|
||||
</div>
|
||||
)}
|
||||
|
||||
{highlight.context && (
|
||||
<details className="highlight-context">
|
||||
<summary>Show context</summary>
|
||||
<p className="context-text">{highlight.context}</p>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div className="highlight-meta">
|
||||
<span className="highlight-author">
|
||||
{getUserDisplayName()}
|
||||
</span>
|
||||
<span className="highlight-meta-separator">•</span>
|
||||
<span className="highlight-time">
|
||||
{formatDistanceToNow(new Date(highlight.created_at * 1000), { addSuffix: true })}
|
||||
</span>
|
||||
@@ -84,10 +98,9 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelec
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => highlight.urlReference && onSelectUrl ? handleLinkClick(highlight.urlReference, e) : undefined}
|
||||
className="highlight-source"
|
||||
title={highlight.eventReference ? 'View on Nostr' : 'View source'}
|
||||
title={highlight.eventReference ? 'Open on Nostr' : 'Open source'}
|
||||
>
|
||||
<FontAwesomeIcon icon={highlight.eventReference ? faLink : faExternalLinkAlt} />
|
||||
<span>{highlight.eventReference ? 'Nostr event' : 'Source'}</span>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@ interface HighlightsPanelProps {
|
||||
onToggleCollapse: () => void
|
||||
onSelectUrl?: (url: string) => void
|
||||
selectedUrl?: string
|
||||
onToggleUnderlines?: (show: boolean) => void
|
||||
onToggleHighlights?: (show: boolean) => void
|
||||
selectedHighlightId?: string
|
||||
onRefresh?: () => void
|
||||
onHighlightClick?: (highlightId: string) => void
|
||||
@@ -34,7 +34,7 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
onToggleCollapse,
|
||||
onSelectUrl,
|
||||
selectedUrl,
|
||||
onToggleUnderlines,
|
||||
onToggleHighlights,
|
||||
selectedHighlightId,
|
||||
onRefresh,
|
||||
onHighlightClick,
|
||||
@@ -43,12 +43,12 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
onHighlightVisibilityChange,
|
||||
followedPubkeys = new Set()
|
||||
}) => {
|
||||
const [showUnderlines, setShowUnderlines] = useState(true)
|
||||
const [showHighlights, setShowHighlights] = useState(true)
|
||||
|
||||
const handleToggleUnderlines = () => {
|
||||
const newValue = !showUnderlines
|
||||
setShowUnderlines(newValue)
|
||||
onToggleUnderlines?.(newValue)
|
||||
const handleToggleHighlights = () => {
|
||||
const newValue = !showHighlights
|
||||
setShowHighlights(newValue)
|
||||
onToggleHighlights?.(newValue)
|
||||
}
|
||||
|
||||
// Filter highlights based on visibility levels and URL
|
||||
@@ -123,69 +123,71 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
<div className="highlights-container">
|
||||
<div className="highlights-header">
|
||||
<div className="highlights-actions">
|
||||
{onHighlightVisibilityChange && (
|
||||
<div className="highlight-level-toggles">
|
||||
<div className="highlights-actions-left">
|
||||
{onHighlightVisibilityChange && (
|
||||
<div className="highlight-level-toggles">
|
||||
<button
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
...highlightVisibility,
|
||||
nostrverse: !highlightVisibility.nostrverse
|
||||
})}
|
||||
className={`level-toggle-btn ${highlightVisibility.nostrverse ? 'active' : ''}`}
|
||||
title="Toggle nostrverse highlights"
|
||||
aria-label="Toggle nostrverse highlights"
|
||||
style={{ color: highlightVisibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faGlobe} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
...highlightVisibility,
|
||||
friends: !highlightVisibility.friends
|
||||
})}
|
||||
className={`level-toggle-btn ${highlightVisibility.friends ? 'active' : ''}`}
|
||||
title={currentUserPubkey ? "Toggle friends highlights" : "Login to see friends highlights"}
|
||||
aria-label="Toggle friends highlights"
|
||||
style={{ color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined }}
|
||||
disabled={!currentUserPubkey}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUserGroup} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
...highlightVisibility,
|
||||
mine: !highlightVisibility.mine
|
||||
})}
|
||||
className={`level-toggle-btn ${highlightVisibility.mine ? 'active' : ''}`}
|
||||
title={currentUserPubkey ? "Toggle my highlights" : "Login to see your highlights"}
|
||||
aria-label="Toggle my highlights"
|
||||
style={{ color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined }}
|
||||
disabled={!currentUserPubkey}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUser} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<button
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
...highlightVisibility,
|
||||
nostrverse: !highlightVisibility.nostrverse
|
||||
})}
|
||||
className={`level-toggle-btn ${highlightVisibility.nostrverse ? 'active' : ''}`}
|
||||
title="Toggle nostrverse highlights"
|
||||
aria-label="Toggle nostrverse highlights"
|
||||
style={{ color: highlightVisibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }}
|
||||
onClick={onRefresh}
|
||||
className="refresh-highlights-btn"
|
||||
title="Refresh highlights"
|
||||
aria-label="Refresh highlights"
|
||||
disabled={loading}
|
||||
>
|
||||
<FontAwesomeIcon icon={faGlobe} />
|
||||
<FontAwesomeIcon icon={faRotate} spin={loading} />
|
||||
</button>
|
||||
)}
|
||||
{filteredHighlights.length > 0 && (
|
||||
<button
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
...highlightVisibility,
|
||||
friends: !highlightVisibility.friends
|
||||
})}
|
||||
className={`level-toggle-btn ${highlightVisibility.friends ? 'active' : ''}`}
|
||||
title={currentUserPubkey ? "Toggle friends highlights" : "Login to see friends highlights"}
|
||||
aria-label="Toggle friends highlights"
|
||||
style={{ color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined }}
|
||||
disabled={!currentUserPubkey}
|
||||
onClick={handleToggleHighlights}
|
||||
className="toggle-highlight-display-btn"
|
||||
title={showHighlights ? 'Hide highlights' : 'Show highlights'}
|
||||
aria-label={showHighlights ? 'Hide highlights' : 'Show highlights'}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUserGroup} />
|
||||
<FontAwesomeIcon icon={showHighlights ? faEye : faEyeSlash} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
...highlightVisibility,
|
||||
mine: !highlightVisibility.mine
|
||||
})}
|
||||
className={`level-toggle-btn ${highlightVisibility.mine ? 'active' : ''}`}
|
||||
title={currentUserPubkey ? "Toggle my highlights" : "Login to see your highlights"}
|
||||
aria-label="Toggle my highlights"
|
||||
style={{ color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined }}
|
||||
disabled={!currentUserPubkey}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUser} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="refresh-highlights-btn"
|
||||
title="Refresh highlights"
|
||||
aria-label="Refresh highlights"
|
||||
disabled={loading}
|
||||
>
|
||||
<FontAwesomeIcon icon={faRotate} spin={loading} />
|
||||
</button>
|
||||
)}
|
||||
{filteredHighlights.length > 0 && (
|
||||
<button
|
||||
onClick={handleToggleUnderlines}
|
||||
className="toggle-underlines-btn"
|
||||
title={showUnderlines ? 'Hide underlines' : 'Show underlines'}
|
||||
aria-label={showUnderlines ? 'Hide underlines' : 'Show underlines'}
|
||||
>
|
||||
<FontAwesomeIcon icon={showUnderlines ? faEye : faEyeSlash} />
|
||||
</button>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className="toggle-highlights-btn"
|
||||
@@ -197,9 +199,9 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
{loading && filteredHighlights.length === 0 ? (
|
||||
<div className="highlights-loading">
|
||||
<p>Loading highlights...</p>
|
||||
<FontAwesomeIcon icon={faHighlighter} spin />
|
||||
</div>
|
||||
) : filteredHighlights.length === 0 ? (
|
||||
<div className="highlights-empty">
|
||||
|
||||
@@ -9,6 +9,8 @@ interface IconButtonProps {
|
||||
ariaLabel?: string
|
||||
variant?: 'primary' | 'success' | 'ghost'
|
||||
size?: number
|
||||
disabled?: boolean
|
||||
spin?: boolean
|
||||
}
|
||||
|
||||
const IconButton: React.FC<IconButtonProps> = ({
|
||||
@@ -17,7 +19,9 @@ const IconButton: React.FC<IconButtonProps> = ({
|
||||
title,
|
||||
ariaLabel,
|
||||
variant = 'ghost',
|
||||
size = 33
|
||||
size = 33,
|
||||
disabled = false,
|
||||
spin = false
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
@@ -26,8 +30,9 @@ const IconButton: React.FC<IconButtonProps> = ({
|
||||
title={title}
|
||||
aria-label={ariaLabel || title}
|
||||
style={{ width: size, height: size }}
|
||||
disabled={disabled}
|
||||
>
|
||||
<FontAwesomeIcon icon={icon} />
|
||||
<FontAwesomeIcon icon={icon} spin={spin} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { Accounts } from 'applesauce-accounts'
|
||||
|
||||
interface LoginProps {
|
||||
onLogin: () => void
|
||||
}
|
||||
|
||||
const Login: React.FC<LoginProps> = ({ onLogin }) => {
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const accountManager = Hooks.useAccountManager()
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
setIsConnecting(true)
|
||||
|
||||
// Create account from nostr extension
|
||||
const account = await Accounts.ExtensionAccount.fromExtension()
|
||||
accountManager.addAccount(account)
|
||||
accountManager.setActive(account)
|
||||
onLogin()
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error)
|
||||
alert('Login failed. Please install a nostr browser extension and try again.')
|
||||
} finally {
|
||||
setIsConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-card">
|
||||
<h2>Welcome to Boris</h2>
|
||||
<p>Connect your nostr account to view your bookmarks</p>
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
disabled={isConnecting}
|
||||
className="login-button"
|
||||
>
|
||||
{isConnecting ? 'Connecting...' : 'Connect with Nostr'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Login
|
||||
52
src/components/ReaderHeader.tsx
Normal file
52
src/components/ReaderHeader.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHighlighter, faClock } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
interface ReaderHeaderProps {
|
||||
title?: string
|
||||
image?: string
|
||||
readingTimeText?: string | null
|
||||
hasHighlights: boolean
|
||||
highlightCount: number
|
||||
}
|
||||
|
||||
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
title,
|
||||
image,
|
||||
readingTimeText,
|
||||
hasHighlights,
|
||||
highlightCount
|
||||
}) => {
|
||||
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>
|
||||
<div className="reader-meta">
|
||||
{readingTimeText && (
|
||||
<div className="reading-time">
|
||||
<FontAwesomeIcon icon={faClock} />
|
||||
<span>{readingTimeText}</span>
|
||||
</div>
|
||||
)}
|
||||
{hasHighlights && (
|
||||
<div className="highlight-indicator">
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReaderHeader
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { faTimes, faList, faThLarge, faImage, faUnderline, faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faTimes, faList, faThLarge, faImage, faUnderline, faHighlighter, faUndo } from '@fortawesome/free-solid-svg-icons'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import IconButton from './IconButton'
|
||||
import ColorPicker from './ColorPicker'
|
||||
@@ -7,6 +7,21 @@ import FontSelector from './FontSelector'
|
||||
import { loadFont, getFontFamily } from '../utils/fontLoader'
|
||||
import { hexToRgb } from '../utils/colorHelpers'
|
||||
|
||||
const DEFAULT_SETTINGS: UserSettings = {
|
||||
collapseOnArticleOpen: true,
|
||||
defaultViewMode: 'compact',
|
||||
showHighlights: true,
|
||||
sidebarCollapsed: true,
|
||||
highlightsCollapsed: true,
|
||||
readingFont: 'source-serif-4',
|
||||
fontSize: 18,
|
||||
highlightStyle: 'marker',
|
||||
highlightColor: '#ffff00',
|
||||
highlightColorNostrverse: '#9333ea',
|
||||
highlightColorFriends: '#f97316',
|
||||
highlightColorMine: '#ffff00',
|
||||
}
|
||||
|
||||
interface SettingsProps {
|
||||
settings: UserSettings
|
||||
onSave: (settings: UserSettings) => Promise<void>
|
||||
@@ -29,9 +44,8 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
|
||||
useEffect(() => {
|
||||
// Load font for preview when it changes
|
||||
if (localSettings.readingFont) {
|
||||
loadFont(localSettings.readingFont)
|
||||
}
|
||||
const fontToLoad = localSettings.readingFont || 'source-serif-4'
|
||||
loadFont(fontToLoad)
|
||||
}, [localSettings.readingFont])
|
||||
|
||||
// Auto-save settings whenever they change (except on initial mount)
|
||||
@@ -44,19 +58,34 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
onSave(localSettings)
|
||||
}, [localSettings, onSave])
|
||||
|
||||
const previewFontFamily = getFontFamily(localSettings.readingFont)
|
||||
const previewFontFamily = getFontFamily(localSettings.readingFont || 'source-serif-4')
|
||||
|
||||
const handleResetToDefaults = () => {
|
||||
if (confirm('Reset all settings to defaults?')) {
|
||||
setLocalSettings(DEFAULT_SETTINGS)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-view">
|
||||
<div className="settings-header">
|
||||
<h2>Settings</h2>
|
||||
<IconButton
|
||||
icon={faTimes}
|
||||
onClick={onClose}
|
||||
title="Close settings"
|
||||
ariaLabel="Close settings"
|
||||
variant="ghost"
|
||||
/>
|
||||
<div className="settings-header-actions">
|
||||
<IconButton
|
||||
icon={faUndo}
|
||||
onClick={handleResetToDefaults}
|
||||
title="Reset to defaults"
|
||||
ariaLabel="Reset to defaults"
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
icon={faTimes}
|
||||
onClick={onClose}
|
||||
title="Close settings"
|
||||
ariaLabel="Close settings"
|
||||
variant="ghost"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-content">
|
||||
@@ -66,7 +95,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
<div className="setting-group setting-inline">
|
||||
<label htmlFor="readingFont">Reading Font</label>
|
||||
<FontSelector
|
||||
value={localSettings.readingFont || 'system'}
|
||||
value={localSettings.readingFont || 'source-serif-4'}
|
||||
onChange={(font) => setLocalSettings({ ...localSettings, readingFont: font })}
|
||||
/>
|
||||
</div>
|
||||
@@ -78,7 +107,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
<button
|
||||
key={size}
|
||||
onClick={() => setLocalSettings({ ...localSettings, fontSize: size })}
|
||||
className={`font-size-btn ${(localSettings.fontSize || 16) === size ? 'active' : ''}`}
|
||||
className={`font-size-btn ${(localSettings.fontSize || 18) === size ? 'active' : ''}`}
|
||||
title={`${size}px`}
|
||||
style={{ fontSize: `${size - 2}px` }}
|
||||
>
|
||||
@@ -89,12 +118,12 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="showUnderlines" className="checkbox-label">
|
||||
<label htmlFor="showHighlights" className="checkbox-label">
|
||||
<input
|
||||
id="showUnderlines"
|
||||
id="showHighlights"
|
||||
type="checkbox"
|
||||
checked={localSettings.showUnderlines !== false}
|
||||
onChange={(e) => setLocalSettings({ ...localSettings, showUnderlines: e.target.checked })}
|
||||
checked={localSettings.showHighlights !== false}
|
||||
onChange={(e) => setLocalSettings({ ...localSettings, showHighlights: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Show highlights</span>
|
||||
@@ -121,36 +150,35 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Highlight Color (Legacy)</label>
|
||||
<ColorPicker
|
||||
selectedColor={localSettings.highlightColor || '#ffff00'}
|
||||
onColorChange={(color) => setLocalSettings({ ...localSettings, highlightColor: color })}
|
||||
/>
|
||||
<label className="setting-label">My Highlights</label>
|
||||
<div className="setting-control">
|
||||
<ColorPicker
|
||||
selectedColor={localSettings.highlightColorMine || '#ffff00'}
|
||||
onColorChange={(color) => setLocalSettings({ ...localSettings, highlightColorMine: color })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>My Highlights Color</label>
|
||||
<ColorPicker
|
||||
selectedColor={localSettings.highlightColorMine || '#eab308'}
|
||||
onColorChange={(color) => setLocalSettings({ ...localSettings, highlightColorMine: color })}
|
||||
/>
|
||||
<label className="setting-label">Friends Highlights</label>
|
||||
<div className="setting-control">
|
||||
<ColorPicker
|
||||
selectedColor={localSettings.highlightColorFriends || '#f97316'}
|
||||
onColorChange={(color) => setLocalSettings({ ...localSettings, highlightColorFriends: color })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Friends Highlights Color</label>
|
||||
<ColorPicker
|
||||
selectedColor={localSettings.highlightColorFriends || '#f97316'}
|
||||
onColorChange={(color) => setLocalSettings({ ...localSettings, highlightColorFriends: color })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Nostrverse Highlights Color</label>
|
||||
<ColorPicker
|
||||
selectedColor={localSettings.highlightColorNostrverse || '#9333ea'}
|
||||
onColorChange={(color) => setLocalSettings({ ...localSettings, highlightColorNostrverse: color })}
|
||||
/>
|
||||
<label className="setting-label">Nostrverse Highlights</label>
|
||||
<div className="setting-control">
|
||||
<ColorPicker
|
||||
selectedColor={localSettings.highlightColorNostrverse || '#9333ea'}
|
||||
onColorChange={(color) => setLocalSettings({ ...localSettings, highlightColorNostrverse: color })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-preview">
|
||||
@@ -159,13 +187,15 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
className="preview-content"
|
||||
style={{
|
||||
fontFamily: previewFontFamily,
|
||||
fontSize: `${localSettings.fontSize || 16}px`,
|
||||
fontSize: `${localSettings.fontSize || 18}px`,
|
||||
'--highlight-rgb': hexToRgb(localSettings.highlightColor || '#ffff00')
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<h3>The Quick Brown Fox</h3>
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <span className={localSettings.showUnderlines !== false ? `content-highlight-${localSettings.highlightStyle || 'marker'}` : ""}>Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</span> Ut enim ad minim veniam.</p>
|
||||
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.</p>
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <span className={localSettings.showHighlights !== false ? `content-highlight-${localSettings.highlightStyle || 'marker'} level-mine` : ""}>Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</span> Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
|
||||
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. <span className={localSettings.showHighlights !== false ? `content-highlight-${localSettings.highlightStyle || 'marker'} level-friends` : ""}>Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</span> Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.</p>
|
||||
<p>Totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. <span className={localSettings.showHighlights !== false ? `content-highlight-${localSettings.highlightStyle || 'marker'} level-nostrverse` : ""}>Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</span> Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit.</p>
|
||||
<p>Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -204,7 +234,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
<input
|
||||
id="sidebarCollapsed"
|
||||
type="checkbox"
|
||||
checked={localSettings.sidebarCollapsed === true}
|
||||
checked={localSettings.sidebarCollapsed !== false}
|
||||
onChange={(e) => setLocalSettings({ ...localSettings, sidebarCollapsed: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
@@ -217,7 +247,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
<input
|
||||
id="highlightsCollapsed"
|
||||
type="checkbox"
|
||||
checked={localSettings.highlightsCollapsed === true}
|
||||
checked={localSettings.highlightsCollapsed !== false}
|
||||
onChange={(e) => setLocalSettings({ ...localSettings, highlightsCollapsed: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import React, { useState } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronRight, faRightFromBracket, faRightToBracket, faUser, faList, faThLarge, faImage, faGear } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faRotate } 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 IconButton from './IconButton'
|
||||
import { ViewMode } from './Bookmarks'
|
||||
|
||||
interface SidebarHeaderProps {
|
||||
onToggleCollapse: () => void
|
||||
onLogout: () => void
|
||||
viewMode: ViewMode
|
||||
onViewModeChange: (mode: ViewMode) => void
|
||||
onOpenSettings: () => void
|
||||
onRefresh?: () => void
|
||||
isRefreshing?: boolean
|
||||
}
|
||||
|
||||
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, viewMode, onViewModeChange, onOpenSettings }) => {
|
||||
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, onRefresh, isRefreshing }) => {
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const accountManager = Hooks.useAccountManager()
|
||||
@@ -61,13 +60,18 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</button>
|
||||
<div className="profile-avatar" title={getUserDisplayName()}>
|
||||
{profileImage ? (
|
||||
<img src={profileImage} alt={getUserDisplayName()} />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faUser} />
|
||||
)}
|
||||
</div>
|
||||
<div className="sidebar-header-right">
|
||||
{onRefresh && (
|
||||
<IconButton
|
||||
icon={faRotate}
|
||||
onClick={onRefresh}
|
||||
title="Refresh bookmarks"
|
||||
ariaLabel="Refresh bookmarks"
|
||||
variant="ghost"
|
||||
disabled={isRefreshing}
|
||||
spin={isRefreshing}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
icon={faGear}
|
||||
onClick={onOpenSettings}
|
||||
@@ -75,6 +79,18 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
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={faRightFromBracket}
|
||||
@@ -92,29 +108,7 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="view-mode-controls">
|
||||
<IconButton
|
||||
icon={faList}
|
||||
onClick={() => onViewModeChange('compact')}
|
||||
title="Compact list view"
|
||||
ariaLabel="Compact list view"
|
||||
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faThLarge}
|
||||
onClick={() => onViewModeChange('cards')}
|
||||
title="Cards view"
|
||||
ariaLabel="Cards view"
|
||||
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faImage}
|
||||
onClick={() => onViewModeChange('large')}
|
||||
title="Large preview view"
|
||||
ariaLabel="Large preview view"
|
||||
variant={viewMode === 'large' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { fetchArticleByNaddr } from '../services/articleService'
|
||||
import { fetchHighlightsForArticle } from '../services/highlightService'
|
||||
import { ReadableContent } from '../services/readerService'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
|
||||
interface UseArticleLoaderProps {
|
||||
naddr: string | undefined
|
||||
@@ -12,11 +13,11 @@ interface UseArticleLoaderProps {
|
||||
setReaderContent: (content: ReadableContent | undefined) => void
|
||||
setReaderLoading: (loading: boolean) => void
|
||||
setIsCollapsed: (collapsed: boolean) => void
|
||||
setIsHighlightsCollapsed: (collapsed: boolean) => void
|
||||
setHighlights: (highlights: Highlight[]) => void
|
||||
setHighlightsLoading: (loading: boolean) => void
|
||||
setCurrentArticleCoordinate: (coord: string | undefined) => void
|
||||
setCurrentArticleEventId: (id: string | undefined) => void
|
||||
setCurrentArticle?: (article: NostrEvent) => void
|
||||
}
|
||||
|
||||
export function useArticleLoader({
|
||||
@@ -26,11 +27,11 @@ export function useArticleLoader({
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
setIsCollapsed,
|
||||
setIsHighlightsCollapsed,
|
||||
setHighlights,
|
||||
setHighlightsLoading,
|
||||
setCurrentArticleCoordinate,
|
||||
setCurrentArticleEventId
|
||||
setCurrentArticleEventId,
|
||||
setCurrentArticle
|
||||
}: UseArticleLoaderProps) {
|
||||
useEffect(() => {
|
||||
if (!relayPool || !naddr) return
|
||||
@@ -40,7 +41,7 @@ export function useArticleLoader({
|
||||
setReaderContent(undefined)
|
||||
setSelectedUrl(`nostr:${naddr}`)
|
||||
setIsCollapsed(true)
|
||||
setIsHighlightsCollapsed(false)
|
||||
// Keep highlights panel collapsed by default - only open on user interaction
|
||||
|
||||
try {
|
||||
const article = await fetchArticleByNaddr(relayPool, naddr)
|
||||
@@ -56,19 +57,33 @@ export function useArticleLoader({
|
||||
|
||||
setCurrentArticleCoordinate(articleCoordinate)
|
||||
setCurrentArticleEventId(article.event.id)
|
||||
setCurrentArticle?.(article.event)
|
||||
|
||||
console.log('📰 Article loaded:', article.title)
|
||||
console.log('📍 Coordinate:', articleCoordinate)
|
||||
|
||||
// Set reader loading to false immediately after article content is ready
|
||||
// Don't wait for highlights to finish loading
|
||||
setReaderLoading(false)
|
||||
|
||||
// Fetch highlights asynchronously without blocking article display
|
||||
// Stream them as they arrive for instant rendering
|
||||
try {
|
||||
setHighlightsLoading(true)
|
||||
const fetchedHighlights = await fetchHighlightsForArticle(
|
||||
setHighlights([]) // Clear old highlights
|
||||
const highlightsList: Highlight[] = []
|
||||
|
||||
await fetchHighlightsForArticle(
|
||||
relayPool,
|
||||
articleCoordinate,
|
||||
article.event.id
|
||||
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))
|
||||
}
|
||||
)
|
||||
console.log(`📌 Found ${fetchedHighlights.length} highlights`)
|
||||
setHighlights(fetchedHighlights)
|
||||
console.log(`📌 Found ${highlightsList.length} highlights`)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch highlights:', err)
|
||||
} finally {
|
||||
@@ -82,8 +97,6 @@ export function useArticleLoader({
|
||||
url: `nostr:${naddr}`
|
||||
})
|
||||
setReaderLoading(false)
|
||||
} finally {
|
||||
setReaderLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,10 +51,10 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
||||
const fontKey = settings.readingFont || 'system'
|
||||
if (fontKey !== 'system') loadFont(fontKey)
|
||||
root.setProperty('--reading-font', getFontFamily(fontKey))
|
||||
root.setProperty('--reading-font-size', `${settings.fontSize || 16}px`)
|
||||
root.setProperty('--reading-font-size', `${settings.fontSize || 18}px`)
|
||||
|
||||
// Set highlight colors for three levels
|
||||
root.setProperty('--highlight-color-mine', settings.highlightColorMine || '#eab308')
|
||||
root.setProperty('--highlight-color-mine', settings.highlightColorMine || '#ffff00')
|
||||
root.setProperty('--highlight-color-friends', settings.highlightColorFriends || '#f97316')
|
||||
root.setProperty('--highlight-color-nostrverse', settings.highlightColorNostrverse || '#9333ea')
|
||||
}, [settings])
|
||||
|
||||
25
src/hooks/useToast.ts
Normal file
25
src/hooks/useToast.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
interface ToastState {
|
||||
message: string | null
|
||||
type: 'success' | 'error'
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const [toast, setToast] = useState<ToastState>({ message: null, type: 'success' })
|
||||
|
||||
const showToast = useCallback((message: string, type: 'success' | 'error' = 'success') => {
|
||||
setToast({ message, type })
|
||||
}, [])
|
||||
|
||||
const clearToast = useCallback(() => {
|
||||
setToast({ message: null, type: 'success' })
|
||||
}, [])
|
||||
|
||||
return {
|
||||
toastMessage: toast.message,
|
||||
toastType: toast.type,
|
||||
showToast,
|
||||
clearToast
|
||||
}
|
||||
}
|
||||
292
src/index.css
292
src/index.css
@@ -13,8 +13,15 @@
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
|
||||
--reading-font: system-ui, -apple-system, sans-serif;
|
||||
--reading-font-size: 16px;
|
||||
--reading-font: 'Source Serif 4', serif;
|
||||
--reading-font-size: 18px;
|
||||
/* Layout variables */
|
||||
--sidebar-width: 320px;
|
||||
--sidebar-collapsed-width: 64px;
|
||||
--highlights-width: 360px;
|
||||
--highlights-collapsed-width: 56px;
|
||||
--main-max-width: 900px;
|
||||
--main-horizontal-padding: 1rem;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -24,9 +31,9 @@ body {
|
||||
}
|
||||
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.app {
|
||||
@@ -49,57 +56,34 @@ body {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* Login Styles */
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 50vh;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: #1a1a1a;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #333;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-card h2 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.login-card p {
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
background: #646cff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.login-button:hover:not(:disabled) {
|
||||
background: #535bf2;
|
||||
}
|
||||
|
||||
.login-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Bookmarks Styles */
|
||||
.bookmarks-container {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
padding-left: 1rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.bookmarks-container .view-mode-controls {
|
||||
margin-top: auto;
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid #333;
|
||||
background: #1a1a1a;
|
||||
border-radius: 0 0 12px 12px;
|
||||
}
|
||||
|
||||
.bookmarks-container .bookmarks-list {
|
||||
padding: 0.25rem;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sidebar-header-bar {
|
||||
@@ -110,8 +94,15 @@ body {
|
||||
padding: 0.75rem 1rem;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: 12px 12px 0 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.sidebar-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.view-mode-controls {
|
||||
@@ -180,8 +171,8 @@ body {
|
||||
.bookmarks-container.collapsed {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
padding: 0.75rem 0.5rem 0 0;
|
||||
justify-content: flex-start;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
@@ -189,16 +180,17 @@ body {
|
||||
.bookmarks-container.collapsed .toggle-sidebar-btn {
|
||||
background: #2a2a2a;
|
||||
color: #ddd;
|
||||
border: 1px solid #444;
|
||||
border: none;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
width: 48px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bookmarks-container.collapsed .toggle-sidebar-btn:hover {
|
||||
@@ -436,22 +428,22 @@ body {
|
||||
/* Three-pane layout */
|
||||
.three-pane {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr 360px;
|
||||
grid-template-columns: var(--sidebar-width) 1fr var(--highlights-width);
|
||||
column-gap: 0;
|
||||
height: calc(100vh - 4rem);
|
||||
height: calc(100vh - 2rem);
|
||||
transition: grid-template-columns 0.3s ease;
|
||||
}
|
||||
|
||||
.three-pane.sidebar-collapsed {
|
||||
grid-template-columns: 60px 1fr 360px;
|
||||
grid-template-columns: var(--sidebar-collapsed-width) 1fr var(--highlights-width);
|
||||
}
|
||||
|
||||
.three-pane.highlights-collapsed {
|
||||
grid-template-columns: 360px 1fr 60px;
|
||||
grid-template-columns: var(--sidebar-width) 1fr var(--highlights-collapsed-width);
|
||||
}
|
||||
|
||||
.three-pane.sidebar-collapsed.highlights-collapsed {
|
||||
grid-template-columns: 60px 1fr 60px;
|
||||
grid-template-columns: var(--sidebar-collapsed-width) 1fr var(--highlights-collapsed-width);
|
||||
}
|
||||
|
||||
.pane.sidebar {
|
||||
@@ -462,13 +454,22 @@ body {
|
||||
.pane.main {
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
max-width: 900px;
|
||||
max-width: var(--main-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
padding: 0 var(--main-horizontal-padding);
|
||||
overflow-x: hidden;
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
/* Remove padding when sidebar is collapsed for zero gap */
|
||||
.three-pane.sidebar-collapsed .pane.main {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.three-pane.sidebar-collapsed.highlights-collapsed .pane.main {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.pane.highlights {
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
@@ -478,7 +479,7 @@ body {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
contain: layout style;
|
||||
@@ -704,6 +705,8 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.bookmarks-grid.bookmarks-compact {
|
||||
@@ -716,7 +719,7 @@ body {
|
||||
|
||||
.individual-bookmark {
|
||||
background: #2a2a2a;
|
||||
padding: 1.25rem;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid #333;
|
||||
@@ -733,11 +736,13 @@ body {
|
||||
|
||||
/* Compact view styles */
|
||||
.individual-bookmark.compact {
|
||||
padding: 0.4rem 0.75rem;
|
||||
padding: 0.3rem 0.25rem;
|
||||
background: transparent;
|
||||
border-bottom: 1px solid #333;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.individual-bookmark.compact:hover {
|
||||
@@ -751,6 +756,9 @@ body {
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
height: 28px;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.compact-row.clickable {
|
||||
@@ -771,7 +779,7 @@ body {
|
||||
}
|
||||
|
||||
.compact-text {
|
||||
flex: 1;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
color: #ccc;
|
||||
font-size: 0.85rem;
|
||||
@@ -779,6 +787,7 @@ body {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.bookmark-date-compact {
|
||||
@@ -789,8 +798,8 @@ body {
|
||||
}
|
||||
|
||||
.compact-read-btn {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
background: transparent;
|
||||
color: #888;
|
||||
border: none;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
@@ -802,11 +811,12 @@ body {
|
||||
width: 26px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.2s ease;
|
||||
margin-left: auto;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.compact-read-btn:hover {
|
||||
background: #218838;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.compact-read-btn:active {
|
||||
@@ -1090,7 +1100,6 @@ body {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.login-card,
|
||||
.bookmark-item {
|
||||
background: #f9f9f9;
|
||||
border-color: #ddd;
|
||||
@@ -1183,7 +1192,7 @@ body {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
padding: 0.75rem 0 0 0.5rem;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
@@ -1191,9 +1200,9 @@ body {
|
||||
.highlights-container.collapsed .toggle-highlights-btn {
|
||||
background: #2a2a2a;
|
||||
color: #ddd;
|
||||
border: 1px solid #444;
|
||||
border: none;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
@@ -1239,10 +1248,23 @@ body {
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid #333;
|
||||
background: #1e1e1e;
|
||||
background: #1a1a1a;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
.highlights-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.highlights-actions-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.highlights-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1338,7 +1360,7 @@ body {
|
||||
}
|
||||
|
||||
.refresh-highlights-btn,
|
||||
.toggle-underlines-btn,
|
||||
.toggle-highlight-display-btn,
|
||||
.toggle-highlights-btn {
|
||||
background: transparent;
|
||||
color: #ddd;
|
||||
@@ -1355,14 +1377,14 @@ body {
|
||||
}
|
||||
|
||||
.refresh-highlights-btn:hover,
|
||||
.toggle-underlines-btn:hover,
|
||||
.toggle-highlight-display-btn:hover,
|
||||
.toggle-highlights-btn:hover {
|
||||
background: #2a2a2a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.refresh-highlights-btn:active,
|
||||
.toggle-underlines-btn:active,
|
||||
.toggle-highlight-display-btn:active,
|
||||
.toggle-highlights-btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
@@ -1428,6 +1450,22 @@ body {
|
||||
box-shadow: 0 0 0 2px rgba(100, 108, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Level colors in sidebar items */
|
||||
.highlight-item.level-mine {
|
||||
border-color: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 60%, #333);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-mine, #ffff00) 25%, transparent);
|
||||
}
|
||||
|
||||
.highlight-item.level-friends {
|
||||
border-color: color-mix(in srgb, var(--highlight-color-friends, #f97316) 60%, #333);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-friends, #f97316) 25%, transparent);
|
||||
}
|
||||
|
||||
.highlight-item.level-nostrverse {
|
||||
border-color: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 60%, #333);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 25%, transparent);
|
||||
}
|
||||
|
||||
.highlight-quote-icon {
|
||||
color: #646cff;
|
||||
font-size: 1.2rem;
|
||||
@@ -1435,6 +1473,19 @@ body {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Level-colored quote icon */
|
||||
.highlight-item.level-mine .highlight-quote-icon {
|
||||
color: var(--highlight-color-mine, #ffff00);
|
||||
}
|
||||
|
||||
.highlight-item.level-friends .highlight-quote-icon {
|
||||
color: var(--highlight-color-friends, #f97316);
|
||||
}
|
||||
|
||||
.highlight-item.level-nostrverse .highlight-quote-icon {
|
||||
color: var(--highlight-color-nostrverse, #9333ea);
|
||||
}
|
||||
|
||||
.highlight-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -1463,41 +1514,25 @@ body {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.highlight-context {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.highlight-context summary {
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: #888;
|
||||
user-select: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.highlight-context summary:hover {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.context-text {
|
||||
margin: 0.5rem 0 0 0;
|
||||
padding: 0.75rem;
|
||||
background: #252525;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
color: #aaa;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.highlight-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #888;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.highlight-author {
|
||||
color: #aaa;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.highlight-meta-separator {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.highlight-time {
|
||||
color: #888;
|
||||
}
|
||||
@@ -1602,14 +1637,14 @@ body {
|
||||
/* Three-level highlight colors */
|
||||
.content-highlight-marker.level-mine,
|
||||
.content-highlight.level-mine {
|
||||
background: color-mix(in srgb, var(--highlight-color-mine, #eab308) 35%, transparent);
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--highlight-color-mine, #eab308) 20%, transparent);
|
||||
background: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 35%, transparent);
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--highlight-color-mine, #ffff00) 20%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-marker.level-mine:hover,
|
||||
.content-highlight.level-mine:hover {
|
||||
background: color-mix(in srgb, var(--highlight-color-mine, #eab308) 50%, transparent);
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--highlight-color-mine, #eab308) 30%, transparent);
|
||||
background: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 50%, transparent);
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--highlight-color-mine, #ffff00) 30%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-marker.level-friends,
|
||||
@@ -1638,11 +1673,11 @@ body {
|
||||
|
||||
/* Underline styles for three levels */
|
||||
.content-highlight-underline.level-mine {
|
||||
text-decoration-color: color-mix(in srgb, var(--highlight-color-mine, #eab308) 80%, transparent);
|
||||
text-decoration-color: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 80%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-underline.level-mine:hover {
|
||||
text-decoration-color: var(--highlight-color-mine, #eab308);
|
||||
text-decoration-color: var(--highlight-color-mine, #ffff00);
|
||||
}
|
||||
|
||||
.content-highlight-underline.level-friends {
|
||||
@@ -1686,14 +1721,14 @@ body {
|
||||
/* Three-level overrides for light mode */
|
||||
.content-highlight-marker.level-mine,
|
||||
.content-highlight.level-mine {
|
||||
background: color-mix(in srgb, var(--highlight-color-mine, #eab308) 40%, transparent);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--highlight-color-mine, #eab308) 15%, transparent);
|
||||
background: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 40%, transparent);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--highlight-color-mine, #ffff00) 15%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-marker.level-mine:hover,
|
||||
.content-highlight.level-mine:hover {
|
||||
background: color-mix(in srgb, var(--highlight-color-mine, #eab308) 55%, transparent);
|
||||
box-shadow: 0 0 10px color-mix(in srgb, var(--highlight-color-mine, #eab308) 25%, transparent);
|
||||
background: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 55%, transparent);
|
||||
box-shadow: 0 0 10px color-mix(in srgb, var(--highlight-color-mine, #ffff00) 25%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-marker.level-friends,
|
||||
@@ -1721,7 +1756,7 @@ body {
|
||||
}
|
||||
|
||||
.content-highlight-underline.level-mine {
|
||||
text-decoration-color: color-mix(in srgb, var(--highlight-color-mine, #eab308) 90%, transparent);
|
||||
text-decoration-color: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 90%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-underline.level-friends {
|
||||
@@ -1763,6 +1798,12 @@ body {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.settings-header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
@@ -1801,6 +1842,17 @@ body {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
text-align: left;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.setting-control {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.setting-group.setting-inline label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -80,9 +80,7 @@ export async function collectBookmarksFromEvents(
|
||||
privateItemsAll.push(...processApplesauceBookmarks(manualPrivate, activeAccount, true))
|
||||
Reflect.set(evt, BookmarkHiddenSymbol, manualPrivate)
|
||||
Reflect.set(evt, 'EncryptedContentSymbol', decryptedContent)
|
||||
if (!latestContent) {
|
||||
latestContent = decryptedContent
|
||||
}
|
||||
// Don't set latestContent to decrypted JSON - it's not user-facing content
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
67
src/services/highlightCreationService.ts
Normal file
67
src/services/highlightCreationService.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { EventFactory } from 'applesauce-factory'
|
||||
import { HighlightBlueprint } from 'applesauce-factory/blueprints'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { AddressPointer } from 'nostr-tools/nip19'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
|
||||
/**
|
||||
* Creates and publishes a highlight event (NIP-84)
|
||||
*/
|
||||
export async function createHighlight(
|
||||
selectedText: string,
|
||||
article: NostrEvent | null,
|
||||
account: IAccount,
|
||||
relayPool: RelayPool,
|
||||
comment?: string
|
||||
): Promise<void> {
|
||||
if (!selectedText || !article) {
|
||||
throw new Error('Missing required data to create highlight')
|
||||
}
|
||||
|
||||
// Create EventFactory with the account as signer
|
||||
const factory = new EventFactory({ signer: account })
|
||||
|
||||
// Parse article coordinate to get address pointer
|
||||
const addressPointer = parseArticleCoordinate(article)
|
||||
|
||||
// Create highlight event using the blueprint
|
||||
const highlightEvent = await factory.create(
|
||||
HighlightBlueprint,
|
||||
selectedText,
|
||||
addressPointer,
|
||||
comment ? { comment } : undefined
|
||||
)
|
||||
|
||||
// Sign the event
|
||||
const signedEvent = await factory.sign(highlightEvent)
|
||||
|
||||
// Publish to relays
|
||||
const relayUrls = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.nostr.band',
|
||||
'wss://relay.snort.social',
|
||||
'wss://purplepag.es'
|
||||
]
|
||||
|
||||
await relayPool.publish(relayUrls, signedEvent)
|
||||
|
||||
console.log('✅ Highlight published:', signedEvent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse article coordinate to create address pointer
|
||||
*/
|
||||
function parseArticleCoordinate(article: NostrEvent): AddressPointer {
|
||||
// Try to get identifier from article tags
|
||||
const identifier = article.tags.find(tag => tag[0] === 'd')?.[1] || ''
|
||||
|
||||
return {
|
||||
kind: article.kind,
|
||||
pubkey: article.pubkey,
|
||||
identifier,
|
||||
relays: [] // Optional relays hint
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { RelayPool, completeOnEose } from 'applesauce-relay'
|
||||
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
|
||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||
import { lastValueFrom, takeUntil, timer, tap, toArray } from 'rxjs'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import {
|
||||
getHighlightText,
|
||||
@@ -38,7 +38,8 @@ function dedupeHighlights(events: NostrEvent[]): NostrEvent[] {
|
||||
export const fetchHighlightsForArticle = async (
|
||||
relayPool: RelayPool,
|
||||
articleCoordinate: string,
|
||||
eventId?: string
|
||||
eventId?: string,
|
||||
onHighlight?: (highlight: Highlight) => void
|
||||
): Promise<Highlight[]> => {
|
||||
try {
|
||||
// Use well-known relays for highlights even if user isn't logged in
|
||||
@@ -54,12 +55,53 @@ export const fetchHighlightsForArticle = async (
|
||||
console.log('🔍 Event ID:', eventId || 'none')
|
||||
console.log('🔍 From relays:', highlightRelays)
|
||||
|
||||
const seenIds = new Set<string>()
|
||||
const processEvent = (event: NostrEvent): Highlight | null => {
|
||||
if (seenIds.has(event.id)) return null
|
||||
seenIds.add(event.id)
|
||||
|
||||
const highlightText = getHighlightText(event)
|
||||
const context = getHighlightContext(event)
|
||||
const comment = getHighlightComment(event)
|
||||
const sourceEventPointer = getHighlightSourceEventPointer(event)
|
||||
const sourceAddressPointer = getHighlightSourceAddressPointer(event)
|
||||
const sourceUrl = getHighlightSourceUrl(event)
|
||||
const attributions = getHighlightAttributions(event)
|
||||
|
||||
const author = attributions.find(a => a.role === 'author')?.pubkey
|
||||
const eventReference = sourceEventPointer?.id ||
|
||||
(sourceAddressPointer ? `${sourceAddressPointer.kind}:${sourceAddressPointer.pubkey}:${sourceAddressPointer.identifier}` : undefined)
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
created_at: event.created_at,
|
||||
content: highlightText,
|
||||
tags: event.tags,
|
||||
eventReference,
|
||||
urlReference: sourceUrl,
|
||||
author,
|
||||
context,
|
||||
comment
|
||||
}
|
||||
}
|
||||
|
||||
// Query for highlights that reference this article via the 'a' tag
|
||||
console.log('🔍 Filter 1 (a-tag):', JSON.stringify({ kinds: [9802], '#a': [articleCoordinate] }, null, 2))
|
||||
const aTagEvents = await lastValueFrom(
|
||||
relayPool
|
||||
.req(highlightRelays, { kinds: [9802], '#a': [articleCoordinate] })
|
||||
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
const highlight = processEvent(event)
|
||||
if (highlight && onHighlight) {
|
||||
onHighlight(highlight)
|
||||
}
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(10000)),
|
||||
toArray()
|
||||
)
|
||||
)
|
||||
|
||||
console.log('📊 Highlights via a-tag:', aTagEvents.length)
|
||||
@@ -67,11 +109,21 @@ export const fetchHighlightsForArticle = async (
|
||||
// If we have an event ID, also query for highlights that reference via the 'e' tag
|
||||
let eTagEvents: NostrEvent[] = []
|
||||
if (eventId) {
|
||||
console.log('🔍 Filter 2 (e-tag):', JSON.stringify({ kinds: [9802], '#e': [eventId] }, null, 2))
|
||||
eTagEvents = await lastValueFrom(
|
||||
relayPool
|
||||
.req(highlightRelays, { kinds: [9802], '#e': [eventId] })
|
||||
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
const highlight = processEvent(event)
|
||||
if (highlight && onHighlight) {
|
||||
onHighlight(highlight)
|
||||
}
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(10000)),
|
||||
toArray()
|
||||
)
|
||||
)
|
||||
console.log('📊 Highlights via e-tag:', eTagEvents.length)
|
||||
}
|
||||
@@ -135,30 +187,71 @@ export const fetchHighlightsForArticle = async (
|
||||
* Fetches highlights created by a specific user
|
||||
* @param relayPool - The relay pool to query
|
||||
* @param pubkey - The user's public key
|
||||
* @param onHighlight - Optional callback to receive highlights as they arrive
|
||||
*/
|
||||
export const fetchHighlights = async (
|
||||
relayPool: RelayPool,
|
||||
pubkey: string
|
||||
pubkey: string,
|
||||
onHighlight?: (highlight: Highlight) => void
|
||||
): Promise<Highlight[]> => {
|
||||
try {
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
|
||||
console.log('🔍 Fetching highlights (kind 9802) by author:', pubkey)
|
||||
|
||||
const seenIds = new Set<string>()
|
||||
const rawEvents = await lastValueFrom(
|
||||
relayPool
|
||||
.req(relayUrls, { kinds: [9802], authors: [pubkey] })
|
||||
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
if (!seenIds.has(event.id)) {
|
||||
seenIds.add(event.id)
|
||||
|
||||
const highlightText = getHighlightText(event)
|
||||
const context = getHighlightContext(event)
|
||||
const comment = getHighlightComment(event)
|
||||
const sourceEventPointer = getHighlightSourceEventPointer(event)
|
||||
const sourceAddressPointer = getHighlightSourceAddressPointer(event)
|
||||
const sourceUrl = getHighlightSourceUrl(event)
|
||||
const attributions = getHighlightAttributions(event)
|
||||
|
||||
const author = attributions.find(a => a.role === 'author')?.pubkey
|
||||
const eventReference = sourceEventPointer?.id ||
|
||||
(sourceAddressPointer ? `${sourceAddressPointer.kind}:${sourceAddressPointer.pubkey}:${sourceAddressPointer.identifier}` : undefined)
|
||||
|
||||
const highlight: Highlight = {
|
||||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
created_at: event.created_at,
|
||||
content: highlightText,
|
||||
tags: event.tags,
|
||||
eventReference,
|
||||
urlReference: sourceUrl,
|
||||
author,
|
||||
context,
|
||||
comment
|
||||
}
|
||||
|
||||
if (onHighlight) {
|
||||
onHighlight(highlight)
|
||||
}
|
||||
}
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(10000)),
|
||||
toArray()
|
||||
)
|
||||
)
|
||||
|
||||
console.log('📊 Raw highlight events fetched:', rawEvents.length)
|
||||
|
||||
// Deduplicate events by ID
|
||||
// Deduplicate and process events
|
||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||
console.log('📊 Unique highlight events after deduplication:', uniqueEvents.length)
|
||||
|
||||
const highlights: Highlight[] = uniqueEvents.map((event: NostrEvent) => {
|
||||
// Use applesauce helpers to extract highlight data
|
||||
const highlightText = getHighlightText(event)
|
||||
const context = getHighlightContext(event)
|
||||
const comment = getHighlightComment(event)
|
||||
@@ -167,10 +260,7 @@ export const fetchHighlights = async (
|
||||
const sourceUrl = getHighlightSourceUrl(event)
|
||||
const attributions = getHighlightAttributions(event)
|
||||
|
||||
// Get author from attributions
|
||||
const author = attributions.find(a => a.role === 'author')?.pubkey
|
||||
|
||||
// Get event reference (prefer event pointer, fallback to address pointer)
|
||||
const eventReference = sourceEventPointer?.id ||
|
||||
(sourceAddressPointer ? `${sourceAddressPointer.kind}:${sourceAddressPointer.pubkey}:${sourceAddressPointer.identifier}` : undefined)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const SETTINGS_IDENTIFIER = 'com.dergigi.boris.user-settings'
|
||||
export interface UserSettings {
|
||||
collapseOnArticleOpen?: boolean
|
||||
defaultViewMode?: 'compact' | 'cards' | 'large'
|
||||
showUnderlines?: boolean
|
||||
showHighlights?: boolean
|
||||
sidebarCollapsed?: boolean
|
||||
highlightsCollapsed?: boolean
|
||||
readingFont?: string
|
||||
|
||||
@@ -8,9 +8,9 @@ export function hexToRgb(hex: string): string {
|
||||
|
||||
export const HIGHLIGHT_COLORS = [
|
||||
{ name: 'Yellow', value: '#ffff00' },
|
||||
{ name: 'Orange', value: '#ff9500' },
|
||||
{ name: 'Orange', value: '#f97316' },
|
||||
{ name: 'Pink', value: '#ff69b4' },
|
||||
{ name: 'Green', value: '#00ff7f' },
|
||||
{ name: 'Blue', value: '#4da6ff' },
|
||||
{ name: 'Purple', value: '#b19cd9' }
|
||||
{ name: 'Purple', value: '#9333ea' }
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user