mirror of
https://github.com/dergigi/boris.git
synced 2026-02-23 07:54:59 +01:00
Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8d3fac149 | ||
|
|
61e948f6a4 | ||
|
|
22323591c9 | ||
|
|
1b548cee3c | ||
|
|
fbb8fbdc20 | ||
|
|
1e7be50e35 | ||
|
|
1a7a8367a0 | ||
|
|
1f9dbf576c | ||
|
|
630c7ef0a4 | ||
|
|
b01293aa20 | ||
|
|
d9db10fd70 | ||
|
|
872d38c7f3 | ||
|
|
06c3c1ff20 | ||
|
|
107d6757bd | ||
|
|
89bd9f631a | ||
|
|
beeb296d3b | ||
|
|
0e992ae814 | ||
|
|
8b023af6a0 | ||
|
|
6e2f1102f7 | ||
|
|
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 | ||
|
|
9638ab0b84 | ||
|
|
8d7b853e75 | ||
|
|
cdbb920a5f | ||
|
|
cc311c7dc4 | ||
|
|
d4d54b1a7c | ||
|
|
235d6e33a9 | ||
|
|
0fe1085457 | ||
|
|
65e7709c63 | ||
|
|
17b5ffd96e | ||
|
|
7f95eae405 | ||
|
|
8f1e5e1082 | ||
|
|
c536de0144 | ||
|
|
8e0970b717 |
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
|
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.
|
||||||
10
.cursor/rules/web-bookmarks.mdc
Normal file
10
.cursor/rules/web-bookmarks.mdc
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
description: anything to do with "web bookmarks" aka NIP-B0
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
The app also supports web bookmarks (`kind:39701`) which are distinct from public/private bookmarks as defined in NIP-51.
|
||||||
|
|
||||||
|
See NIP-B0 for details:
|
||||||
|
|
||||||
|
- https://github.com/nostr-protocol/nips/blob/master/B0.md
|
||||||
212
README.md
212
README.md
@@ -1,187 +1,77 @@
|
|||||||
# Boris
|
# 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
|
## The Vision
|
||||||
- **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
|
|
||||||
|
|
||||||
## 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+
|
In case it's not self-explanatory:
|
||||||
- npm, pnpm, or yarn
|
- **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:
|
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.
|
||||||
```bash
|
|
||||||
git clone <your-repo-url>
|
|
||||||
cd boris
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Install dependencies:
|
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.
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
# or
|
|
||||||
pnpm install
|
|
||||||
# or
|
|
||||||
yarn install
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Start the development server:
|
## What Boris does
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
yarn dev
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
1. Connect your Nostr account.
|
||||||
2. **View Bookmarks**: Once connected, you'll see all your nostr bookmarks in the left sidebar
|
- Click “Connect” and approve with your usual Nostr signer.
|
||||||
3. **View Highlights**: Your NIP-84 highlights appear in the right panel
|
2. Browse your bookmarks.
|
||||||
4. **Navigate**: Click on bookmark URLs to view content in the center panel
|
- Your lists and items appear on the left. Pick anything to read.
|
||||||
5. **Collapse Panels**: Use the collapse buttons to hide/show sidebars for focused viewing
|
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
|
- No noise: Just your saved links and the best excerpts others found
|
||||||
- Uses [applesauce-core](https://github.com/hzrd149/applesauce) for nostr functionality
|
- Fast by default: Opens instantly in your browser
|
||||||
- Implements [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md) for bookmark management
|
- Portable: Works with any Nostr account; your data travels with you
|
||||||
- Supports both individual bookmarks and bookmark lists
|
- 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.
|
||||||
|
|
||||||
```
|
## Privacy and data
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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**
|
- If something looks empty, try opening another article and coming back — network data can arrive in bursts.
|
||||||
- Use `Helpers.hasHiddenTags(evt)` and `Helpers.isHiddenTagsLocked(evt)` to detect hidden tags.
|
- Not every article has highlights yet; they grow as the community reads.
|
||||||
- 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
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
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" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Boris - Nostr Bookmarks</title>
|
<title>Boris - Nostr Bookmarks</title>
|
||||||
<script type="module" crossorigin src="/assets/index-8PiwZoBK.js"></script>
|
<script type="module" crossorigin src="/assets/index-rEUBRPdE.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-Dljx1pJR.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Bqz-n1DY.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.1.8",
|
"version": "0.2.2",
|
||||||
"description": "A minimal nostr client for bookmark management",
|
"description": "A minimal nostr client for bookmark management",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
192
src/App.tsx
192
src/App.tsx
@@ -1,32 +1,78 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
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 { EventStore } from 'applesauce-core'
|
||||||
import { AccountManager } from 'applesauce-accounts'
|
import { AccountManager } from 'applesauce-accounts'
|
||||||
import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
|
import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
||||||
import Login from './components/Login'
|
|
||||||
import Bookmarks from './components/Bookmarks'
|
import Bookmarks from './components/Bookmarks'
|
||||||
|
import Toast from './components/Toast'
|
||||||
|
import { useToast } from './hooks/useToast'
|
||||||
|
import { RELAYS } from './config/relays'
|
||||||
|
|
||||||
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
||||||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
'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="/r/*"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [eventStore, setEventStore] = useState<EventStore | null>(null)
|
const [eventStore, setEventStore] = useState<EventStore | null>(null)
|
||||||
const [accountManager, setAccountManager] = useState<AccountManager | null>(null)
|
const [accountManager, setAccountManager] = useState<AccountManager | null>(null)
|
||||||
const [relayPool, setRelayPool] = useState<RelayPool | null>(null)
|
const [relayPool, setRelayPool] = useState<RelayPool | null>(null)
|
||||||
|
const { toastMessage, toastType, showToast, clearToast } = useToast()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Initialize event store, account manager, and relay pool
|
const initializeApp = async () => {
|
||||||
const store = new EventStore()
|
// Initialize event store, account manager, and relay pool
|
||||||
const accounts = new AccountManager()
|
const store = new EventStore()
|
||||||
|
const accounts = new AccountManager()
|
||||||
// Register common account types (needed for deserialization)
|
|
||||||
registerCommonAccountTypes(accounts)
|
// Register common account types (needed for deserialization)
|
||||||
|
registerCommonAccountTypes(accounts)
|
||||||
// Load persisted accounts from localStorage
|
|
||||||
const loadAccounts = async () => {
|
// Load persisted accounts from localStorage
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(localStorage.getItem('accounts') || '[]')
|
const json = JSON.parse(localStorage.getItem('accounts') || '[]')
|
||||||
await accounts.fromJSON(json)
|
await accounts.fromJSON(json)
|
||||||
@@ -41,70 +87,63 @@ function App() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load accounts from storage:', 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()
|
||||||
|
|
||||||
|
// Create a relay group for better event deduplication and management
|
||||||
|
pool.group(RELAYS)
|
||||||
|
console.log('Created relay group with', RELAYS.length, 'relays (including local)')
|
||||||
|
console.log('Relay URLs:', RELAYS)
|
||||||
|
|
||||||
|
// Attach address/replaceable loaders so ProfileModel can fetch profiles
|
||||||
|
const addressLoader = createAddressLoader(pool, {
|
||||||
|
eventStore: store,
|
||||||
|
lookupRelays: RELAYS
|
||||||
|
})
|
||||||
|
store.addressableLoader = addressLoader
|
||||||
|
store.replaceableLoader = addressLoader
|
||||||
|
|
||||||
|
setEventStore(store)
|
||||||
|
setAccountManager(accounts)
|
||||||
|
setRelayPool(pool)
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
return () => {
|
||||||
|
accountsSub.unsubscribe()
|
||||||
|
activeSub.unsubscribe()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadAccounts()
|
let cleanup: (() => void) | undefined
|
||||||
|
initializeApp().then((fn) => {
|
||||||
// Subscribe to accounts changes and persist to localStorage
|
cleanup = fn
|
||||||
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 subscriptions on unmount
|
|
||||||
return () => {
|
return () => {
|
||||||
accountsSub.unsubscribe()
|
if (cleanup) cleanup()
|
||||||
activeSub.unsubscribe()
|
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (!eventStore || !accountManager || !relayPool) {
|
if (!eventStore || !accountManager || !relayPool) {
|
||||||
return <div>Loading...</div>
|
return (
|
||||||
|
<div className="loading">
|
||||||
|
<FontAwesomeIcon icon={faSpinner} spin />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -112,21 +151,16 @@ function App() {
|
|||||||
<AccountsProvider manager={accountManager}>
|
<AccountsProvider manager={accountManager}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<Routes>
|
<AppRoutes relayPool={relayPool} showToast={showToast} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
{toastMessage && (
|
||||||
|
<Toast
|
||||||
|
message={toastMessage}
|
||||||
|
type={toastType}
|
||||||
|
onClose={clearToast}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</AccountsProvider>
|
</AccountsProvider>
|
||||||
</EventStoreProvider>
|
</EventStoreProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,8 +24,15 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
|||||||
|
|
||||||
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
|
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
|
||||||
|
|
||||||
// Extract URLs from bookmark content
|
// For web bookmarks (kind:39701), URL is stored in the 'd' tag
|
||||||
const extractedUrls = extractUrlsFromContent(bookmark.content)
|
const isWebBookmark = bookmark.kind === 39701
|
||||||
|
const webBookmarkUrl = isWebBookmark ? bookmark.tags.find(t => t[0] === 'd')?.[1] : null
|
||||||
|
|
||||||
|
// Extract URLs from bookmark content (for regular bookmarks)
|
||||||
|
// For web bookmarks, ensure URL has protocol
|
||||||
|
const extractedUrls = webBookmarkUrl
|
||||||
|
? [webBookmarkUrl.startsWith('http') ? webBookmarkUrl : `https://${webBookmarkUrl}`]
|
||||||
|
: extractUrlsFromContent(bookmark.content)
|
||||||
const hasUrls = extractedUrls.length > 0
|
const hasUrls = extractedUrls.length > 0
|
||||||
const firstUrl = hasUrls ? extractedUrls[0] : null
|
const firstUrl = hasUrls ? extractedUrls[0] : null
|
||||||
const firstUrlClassification = firstUrl ? classifyUrl(firstUrl) : null
|
const firstUrlClassification = firstUrl ? classifyUrl(firstUrl) : null
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
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 { Bookmark } from '../types/bookmarks'
|
||||||
import { BookmarkItem } from './BookmarkItem'
|
import { BookmarkItem } from './BookmarkItem'
|
||||||
import { formatDate, renderParsedContent } from '../utils/bookmarkUtils'
|
import { formatDate, renderParsedContent } from '../utils/bookmarkUtils'
|
||||||
import SidebarHeader from './SidebarHeader'
|
import SidebarHeader from './SidebarHeader'
|
||||||
|
import IconButton from './IconButton'
|
||||||
import { ViewMode } from './Bookmarks'
|
import { ViewMode } from './Bookmarks'
|
||||||
|
|
||||||
interface BookmarkListProps {
|
interface BookmarkListProps {
|
||||||
@@ -17,6 +18,9 @@ interface BookmarkListProps {
|
|||||||
onViewModeChange: (mode: ViewMode) => void
|
onViewModeChange: (mode: ViewMode) => void
|
||||||
selectedUrl?: string
|
selectedUrl?: string
|
||||||
onOpenSettings: () => void
|
onOpenSettings: () => void
|
||||||
|
onRefresh?: () => void
|
||||||
|
isRefreshing?: boolean
|
||||||
|
loading?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BookmarkList: React.FC<BookmarkListProps> = ({
|
export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||||
@@ -28,7 +32,10 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
viewMode,
|
viewMode,
|
||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
selectedUrl,
|
selectedUrl,
|
||||||
onOpenSettings
|
onOpenSettings,
|
||||||
|
onRefresh,
|
||||||
|
isRefreshing,
|
||||||
|
loading = false
|
||||||
}) => {
|
}) => {
|
||||||
if (isCollapsed) {
|
if (isCollapsed) {
|
||||||
// Check if the selected URL is in bookmarks
|
// Check if the selected URL is in bookmarks
|
||||||
@@ -57,12 +64,16 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
<SidebarHeader
|
<SidebarHeader
|
||||||
onToggleCollapse={onToggleCollapse}
|
onToggleCollapse={onToggleCollapse}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
viewMode={viewMode}
|
|
||||||
onViewModeChange={onViewModeChange}
|
|
||||||
onOpenSettings={onOpenSettings}
|
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">
|
<div className="empty-state">
|
||||||
<p>No bookmarks found.</p>
|
<p>No bookmarks found.</p>
|
||||||
<p>Add bookmarks using your nostr client to see them here.</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>
|
||||||
)}
|
)}
|
||||||
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faBookmark, faUserLock, faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'
|
import { faBookmark, faUserLock, faChevronDown, faChevronUp, faGlobe } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { IndividualBookmark } from '../../types/bookmarks'
|
import { IndividualBookmark } from '../../types/bookmarks'
|
||||||
import { formatDate, renderParsedContent } from '../../utils/bookmarkUtils'
|
import { formatDate, renderParsedContent } from '../../utils/bookmarkUtils'
|
||||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||||
@@ -42,6 +42,7 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
const contentLength = (bookmark.content || '').length
|
const contentLength = (bookmark.content || '').length
|
||||||
const shouldTruncate = !expanded && contentLength > 210
|
const shouldTruncate = !expanded && contentLength > 210
|
||||||
const isArticle = bookmark.kind === 30023
|
const isArticle = bookmark.kind === 30023
|
||||||
|
const isWebBookmark = bookmark.kind === 39701
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
||||||
@@ -54,7 +55,12 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
)}
|
)}
|
||||||
<div className="bookmark-header">
|
<div className="bookmark-header">
|
||||||
<span className="bookmark-type">
|
<span className="bookmark-type">
|
||||||
{bookmark.isPrivate ? (
|
{isWebBookmark ? (
|
||||||
|
<span className="fa-layers fa-fw">
|
||||||
|
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
||||||
|
<FontAwesomeIcon icon={faGlobe} className="bookmark-visibility public" transform="shrink-8 down-2" />
|
||||||
|
</span>
|
||||||
|
) : bookmark.isPrivate ? (
|
||||||
<>
|
<>
|
||||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
||||||
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
|
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faBookmark, faUserLock } from '@fortawesome/free-solid-svg-icons'
|
import { faBookmark, faUserLock, faGlobe } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { IndividualBookmark } from '../../types/bookmarks'
|
import { IndividualBookmark } from '../../types/bookmarks'
|
||||||
import { formatDate } from '../../utils/bookmarkUtils'
|
import { formatDate } from '../../utils/bookmarkUtils'
|
||||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||||
@@ -27,7 +27,8 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
|||||||
firstUrlClassification
|
firstUrlClassification
|
||||||
}) => {
|
}) => {
|
||||||
const isArticle = bookmark.kind === 30023
|
const isArticle = bookmark.kind === 30023
|
||||||
const isClickable = hasUrls || isArticle
|
const isWebBookmark = bookmark.kind === 39701
|
||||||
|
const isClickable = hasUrls || isArticle || isWebBookmark
|
||||||
|
|
||||||
const handleCompactClick = () => {
|
const handleCompactClick = () => {
|
||||||
if (!onSelectUrl) return
|
if (!onSelectUrl) return
|
||||||
@@ -48,7 +49,12 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
|||||||
tabIndex={isClickable ? 0 : undefined}
|
tabIndex={isClickable ? 0 : undefined}
|
||||||
>
|
>
|
||||||
<span className="bookmark-type-compact">
|
<span className="bookmark-type-compact">
|
||||||
{bookmark.isPrivate ? (
|
{isWebBookmark ? (
|
||||||
|
<span className="fa-layers fa-fw">
|
||||||
|
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
||||||
|
<FontAwesomeIcon icon={faGlobe} className="bookmark-visibility public" transform="shrink-8 down-2" />
|
||||||
|
</span>
|
||||||
|
) : bookmark.isPrivate ? (
|
||||||
<>
|
<>
|
||||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
||||||
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
|
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect, useMemo } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams, useLocation } from 'react-router-dom'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import { useEventStore } from 'applesauce-react/hooks'
|
import { useEventStore } from 'applesauce-react/hooks'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
@@ -8,6 +8,7 @@ import { Highlight } from '../types/highlights'
|
|||||||
import { BookmarkList } from './BookmarkList'
|
import { BookmarkList } from './BookmarkList'
|
||||||
import { fetchBookmarks } from '../services/bookmarkService'
|
import { fetchBookmarks } from '../services/bookmarkService'
|
||||||
import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService'
|
import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService'
|
||||||
|
import { fetchContacts } from '../services/contactService'
|
||||||
import ContentPanel from './ContentPanel'
|
import ContentPanel from './ContentPanel'
|
||||||
import { HighlightsPanel } from './HighlightsPanel'
|
import { HighlightsPanel } from './HighlightsPanel'
|
||||||
import { ReadableContent } from '../services/readerService'
|
import { ReadableContent } from '../services/readerService'
|
||||||
@@ -15,8 +16,13 @@ import Settings from './Settings'
|
|||||||
import Toast from './Toast'
|
import Toast from './Toast'
|
||||||
import { useSettings } from '../hooks/useSettings'
|
import { useSettings } from '../hooks/useSettings'
|
||||||
import { useArticleLoader } from '../hooks/useArticleLoader'
|
import { useArticleLoader } from '../hooks/useArticleLoader'
|
||||||
|
import { useExternalUrlLoader } from '../hooks/useExternalUrlLoader'
|
||||||
import { loadContent, BookmarkReference } from '../utils/contentLoader'
|
import { loadContent, BookmarkReference } from '../utils/contentLoader'
|
||||||
import { HighlightMode } from './HighlightsPanel'
|
import { HighlightVisibility } from './HighlightsPanel'
|
||||||
|
import { HighlightButton, HighlightButtonRef } from './HighlightButton'
|
||||||
|
import { createHighlight, eventToHighlight } from '../services/highlightCreationService'
|
||||||
|
import { useRef, useCallback } from 'react'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
export type ViewMode = 'compact' | 'cards' | 'large'
|
export type ViewMode = 'compact' | 'cards' | 'large'
|
||||||
|
|
||||||
interface BookmarksProps {
|
interface BookmarksProps {
|
||||||
@@ -26,7 +32,15 @@ interface BookmarksProps {
|
|||||||
|
|
||||||
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||||
const { naddr } = useParams<{ naddr?: string }>()
|
const { naddr } = useParams<{ naddr?: string }>()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
// Extract external URL from /r/* route
|
||||||
|
const externalUrl = location.pathname.startsWith('/r/')
|
||||||
|
? location.pathname.slice(3) // Remove '/r/' prefix
|
||||||
|
: undefined
|
||||||
|
|
||||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||||
|
const [bookmarksLoading, setBookmarksLoading] = useState(true)
|
||||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||||
const [highlightsLoading, setHighlightsLoading] = useState(true)
|
const [highlightsLoading, setHighlightsLoading] = useState(true)
|
||||||
const [selectedUrl, setSelectedUrl] = useState<string | undefined>(undefined)
|
const [selectedUrl, setSelectedUrl] = useState<string | undefined>(undefined)
|
||||||
@@ -35,15 +49,23 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
const [isCollapsed, setIsCollapsed] = useState(true) // Start collapsed
|
const [isCollapsed, setIsCollapsed] = useState(true) // Start collapsed
|
||||||
const [isHighlightsCollapsed, setIsHighlightsCollapsed] = useState(true) // Start collapsed
|
const [isHighlightsCollapsed, setIsHighlightsCollapsed] = useState(true) // Start collapsed
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('compact')
|
const [viewMode, setViewMode] = useState<ViewMode>('compact')
|
||||||
const [showUnderlines, setShowUnderlines] = useState(true)
|
const [showHighlights, setShowHighlights] = useState(true)
|
||||||
const [selectedHighlightId, setSelectedHighlightId] = useState<string | undefined>(undefined)
|
const [selectedHighlightId, setSelectedHighlightId] = useState<string | undefined>(undefined)
|
||||||
const [showSettings, setShowSettings] = useState(false)
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
const [currentArticleCoordinate, setCurrentArticleCoordinate] = useState<string | undefined>(undefined)
|
const [currentArticleCoordinate, setCurrentArticleCoordinate] = useState<string | undefined>(undefined)
|
||||||
const [currentArticleEventId, setCurrentArticleEventId] = useState<string | undefined>(undefined)
|
const [currentArticleEventId, setCurrentArticleEventId] = useState<string | undefined>(undefined)
|
||||||
const [highlightMode, setHighlightMode] = useState<HighlightMode>('others')
|
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 activeAccount = Hooks.useActiveAccount()
|
||||||
const accountManager = Hooks.useAccountManager()
|
const accountManager = Hooks.useAccountManager()
|
||||||
const eventStore = useEventStore()
|
const eventStore = useEventStore()
|
||||||
|
const highlightButtonRef = useRef<HighlightButtonRef>(null)
|
||||||
|
|
||||||
const { settings, saveSettings, toastMessage, toastType, clearToast } = useSettings({
|
const { settings, saveSettings, toastMessage, toastType, clearToast } = useSettings({
|
||||||
relayPool,
|
relayPool,
|
||||||
@@ -52,7 +74,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
accountManager
|
accountManager
|
||||||
})
|
})
|
||||||
|
|
||||||
// Load article if naddr is in URL
|
// Load nostr-native article if naddr is in URL
|
||||||
useArticleLoader({
|
useArticleLoader({
|
||||||
naddr,
|
naddr,
|
||||||
relayPool,
|
relayPool,
|
||||||
@@ -60,7 +82,21 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
setReaderContent,
|
setReaderContent,
|
||||||
setReaderLoading,
|
setReaderLoading,
|
||||||
setIsCollapsed,
|
setIsCollapsed,
|
||||||
setIsHighlightsCollapsed,
|
setHighlights,
|
||||||
|
setHighlightsLoading,
|
||||||
|
setCurrentArticleCoordinate,
|
||||||
|
setCurrentArticleEventId,
|
||||||
|
setCurrentArticle
|
||||||
|
})
|
||||||
|
|
||||||
|
// Load external URL if /r/* route is used
|
||||||
|
useExternalUrlLoader({
|
||||||
|
url: externalUrl,
|
||||||
|
relayPool,
|
||||||
|
setSelectedUrl,
|
||||||
|
setReaderContent,
|
||||||
|
setReaderLoading,
|
||||||
|
setIsCollapsed,
|
||||||
setHighlights,
|
setHighlights,
|
||||||
setHighlightsLoading,
|
setHighlightsLoading,
|
||||||
setCurrentArticleCoordinate,
|
setCurrentArticleCoordinate,
|
||||||
@@ -71,21 +107,43 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!relayPool || !activeAccount) return
|
if (!relayPool || !activeAccount) return
|
||||||
handleFetchBookmarks()
|
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])
|
}, [relayPool, activeAccount?.pubkey])
|
||||||
|
|
||||||
|
const handleFetchContacts = async () => {
|
||||||
|
if (!relayPool || !activeAccount) return
|
||||||
|
const contacts = await fetchContacts(relayPool, activeAccount.pubkey)
|
||||||
|
setFollowedPubkeys(contacts)
|
||||||
|
}
|
||||||
|
|
||||||
// Apply UI settings
|
// Apply UI settings
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settings.defaultViewMode) setViewMode(settings.defaultViewMode)
|
if (settings.defaultViewMode) setViewMode(settings.defaultViewMode)
|
||||||
if (settings.showUnderlines !== undefined) setShowUnderlines(settings.showUnderlines)
|
if (settings.showHighlights !== undefined) setShowHighlights(settings.showHighlights)
|
||||||
if (settings.sidebarCollapsed !== undefined) setIsCollapsed(settings.sidebarCollapsed)
|
// Apply default highlight visibility settings
|
||||||
if (settings.highlightsCollapsed !== undefined) setIsHighlightsCollapsed(settings.highlightsCollapsed)
|
setHighlightVisibility({
|
||||||
|
nostrverse: settings.defaultHighlightVisibilityNostrverse !== false,
|
||||||
|
friends: settings.defaultHighlightVisibilityFriends !== false,
|
||||||
|
mine: settings.defaultHighlightVisibilityMine !== false
|
||||||
|
})
|
||||||
|
// Always start with both panels collapsed on initial load
|
||||||
|
// Don't apply saved collapse settings on initial load - let user control them
|
||||||
}, [settings])
|
}, [settings])
|
||||||
|
|
||||||
const handleFetchBookmarks = async () => {
|
const handleFetchBookmarks = async () => {
|
||||||
if (!relayPool || !activeAccount) return
|
if (!relayPool || !activeAccount) return
|
||||||
const fullAccount = accountManager.getActive()
|
setBookmarksLoading(true)
|
||||||
await fetchBookmarks(relayPool, fullAccount || activeAccount, setBookmarks)
|
try {
|
||||||
|
const fullAccount = accountManager.getActive()
|
||||||
|
await fetchBookmarks(relayPool, fullAccount || activeAccount, setBookmarks)
|
||||||
|
} finally {
|
||||||
|
setBookmarksLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFetchHighlights = async () => {
|
const handleFetchHighlights = async () => {
|
||||||
@@ -95,13 +153,18 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
try {
|
try {
|
||||||
// If we're viewing an article, fetch highlights for that article
|
// If we're viewing an article, fetch highlights for that article
|
||||||
if (currentArticleCoordinate) {
|
if (currentArticleCoordinate) {
|
||||||
const fetchedHighlights = await fetchHighlightsForArticle(
|
const highlightsList: Highlight[] = []
|
||||||
|
await fetchHighlightsForArticle(
|
||||||
relayPool,
|
relayPool,
|
||||||
currentArticleCoordinate,
|
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`)
|
console.log(`🔄 Refreshed ${highlightsList.length} highlights for article`)
|
||||||
setHighlights(fetchedHighlights)
|
|
||||||
}
|
}
|
||||||
// Otherwise, if logged in, fetch user's own highlights
|
// Otherwise, if logged in, fetch user's own highlights
|
||||||
else if (activeAccount) {
|
else if (activeAccount) {
|
||||||
@@ -115,18 +178,50 @@ 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 => {
|
||||||
|
let level: 'mine' | 'friends' | 'nostrverse' = 'nostrverse'
|
||||||
|
if (h.pubkey === activeAccount?.pubkey) {
|
||||||
|
level = 'mine'
|
||||||
|
} else if (followedPubkeys.has(h.pubkey)) {
|
||||||
|
level = 'friends'
|
||||||
|
}
|
||||||
|
return { ...h, level }
|
||||||
|
})
|
||||||
|
}, [highlights, activeAccount?.pubkey, followedPubkeys])
|
||||||
|
|
||||||
const handleSelectUrl = async (url: string, bookmark?: BookmarkReference) => {
|
const handleSelectUrl = async (url: string, bookmark?: BookmarkReference) => {
|
||||||
if (!relayPool) return
|
if (!relayPool) return
|
||||||
|
|
||||||
setSelectedUrl(url)
|
setSelectedUrl(url)
|
||||||
setReaderLoading(true)
|
setReaderLoading(true)
|
||||||
setReaderContent(undefined)
|
setReaderContent(undefined)
|
||||||
|
setCurrentArticle(undefined) // Clear previous article
|
||||||
setShowSettings(false)
|
setShowSettings(false)
|
||||||
if (settings.collapseOnArticleOpen !== false) setIsCollapsed(true)
|
if (settings.collapseOnArticleOpen !== false) setIsCollapsed(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = await loadContent(url, relayPool, bookmark)
|
const content = await loadContent(url, relayPool, bookmark)
|
||||||
setReaderContent(content)
|
setReaderContent(content)
|
||||||
|
|
||||||
|
// Note: currentArticle is set by useArticleLoader when loading Nostr articles
|
||||||
|
// For web bookmarks, there's no article event to set
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to fetch content:', err)
|
console.warn('Failed to fetch content:', err)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -134,6 +229,55 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleTextSelection = useCallback((text: string) => {
|
||||||
|
highlightButtonRef.current?.updateSelection(text)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleClearSelection = useCallback(() => {
|
||||||
|
highlightButtonRef.current?.clearSelection()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleCreateHighlight = useCallback(async (text: string) => {
|
||||||
|
if (!activeAccount || !relayPool) {
|
||||||
|
console.error('Missing requirements for highlight creation')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Need either a nostr article or an external URL
|
||||||
|
if (!currentArticle && !selectedUrl) {
|
||||||
|
console.error('No source available for highlight creation')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Determine the source: prefer currentArticle (for nostr content), fallback to selectedUrl (for external URLs)
|
||||||
|
const source = currentArticle || selectedUrl!
|
||||||
|
|
||||||
|
// For context extraction, use article content or reader content
|
||||||
|
const contentForContext = currentArticle
|
||||||
|
? currentArticle.content
|
||||||
|
: readerContent?.markdown || readerContent?.html
|
||||||
|
|
||||||
|
// Create and publish the highlight
|
||||||
|
const signedEvent = await createHighlight(
|
||||||
|
text,
|
||||||
|
source,
|
||||||
|
activeAccount,
|
||||||
|
relayPool,
|
||||||
|
contentForContext
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('✅ Highlight created successfully!')
|
||||||
|
highlightButtonRef.current?.clearSelection()
|
||||||
|
|
||||||
|
// Immediately add the highlight to the UI (optimistic update)
|
||||||
|
const newHighlight = eventToHighlight(signedEvent)
|
||||||
|
setHighlights(prev => [newHighlight, ...prev])
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create highlight:', error)
|
||||||
|
}
|
||||||
|
}, [activeAccount, relayPool, currentArticle, selectedUrl, readerContent])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={`three-pane ${isCollapsed ? 'sidebar-collapsed' : ''} ${isHighlightsCollapsed ? 'highlights-collapsed' : ''}`}>
|
<div className={`three-pane ${isCollapsed ? 'sidebar-collapsed' : ''} ${isHighlightsCollapsed ? 'highlights-collapsed' : ''}`}>
|
||||||
@@ -152,6 +296,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
setIsCollapsed(true)
|
setIsCollapsed(true)
|
||||||
setIsHighlightsCollapsed(true)
|
setIsHighlightsCollapsed(true)
|
||||||
}}
|
}}
|
||||||
|
onRefresh={handleRefreshBookmarks}
|
||||||
|
isRefreshing={isRefreshing}
|
||||||
|
loading={bookmarksLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="pane main">
|
<div className="pane main">
|
||||||
@@ -169,8 +316,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
markdown={readerContent?.markdown}
|
markdown={readerContent?.markdown}
|
||||||
image={readerContent?.image}
|
image={readerContent?.image}
|
||||||
selectedUrl={selectedUrl}
|
selectedUrl={selectedUrl}
|
||||||
highlights={highlights}
|
highlights={classifiedHighlights}
|
||||||
showUnderlines={showUnderlines}
|
showHighlights={showHighlights}
|
||||||
highlightStyle={settings.highlightStyle || 'marker'}
|
highlightStyle={settings.highlightStyle || 'marker'}
|
||||||
highlightColor={settings.highlightColor || '#ffff00'}
|
highlightColor={settings.highlightColor || '#ffff00'}
|
||||||
onHighlightClick={(id) => {
|
onHighlightClick={(id) => {
|
||||||
@@ -178,6 +325,11 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
if (isHighlightsCollapsed) setIsHighlightsCollapsed(false)
|
if (isHighlightsCollapsed) setIsHighlightsCollapsed(false)
|
||||||
}}
|
}}
|
||||||
selectedHighlightId={selectedHighlightId}
|
selectedHighlightId={selectedHighlightId}
|
||||||
|
highlightVisibility={highlightVisibility}
|
||||||
|
onTextSelection={handleTextSelection}
|
||||||
|
onClearSelection={handleClearSelection}
|
||||||
|
currentUserPubkey={activeAccount?.pubkey}
|
||||||
|
followedPubkeys={followedPubkeys}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -189,16 +341,24 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
onToggleCollapse={() => setIsHighlightsCollapsed(!isHighlightsCollapsed)}
|
onToggleCollapse={() => setIsHighlightsCollapsed(!isHighlightsCollapsed)}
|
||||||
onSelectUrl={handleSelectUrl}
|
onSelectUrl={handleSelectUrl}
|
||||||
selectedUrl={selectedUrl}
|
selectedUrl={selectedUrl}
|
||||||
onToggleUnderlines={setShowUnderlines}
|
onToggleHighlights={setShowHighlights}
|
||||||
selectedHighlightId={selectedHighlightId}
|
selectedHighlightId={selectedHighlightId}
|
||||||
onRefresh={handleFetchHighlights}
|
onRefresh={handleFetchHighlights}
|
||||||
onHighlightClick={setSelectedHighlightId}
|
onHighlightClick={setSelectedHighlightId}
|
||||||
currentUserPubkey={activeAccount?.pubkey}
|
currentUserPubkey={activeAccount?.pubkey}
|
||||||
highlightMode={highlightMode}
|
highlightVisibility={highlightVisibility}
|
||||||
onHighlightModeChange={setHighlightMode}
|
onHighlightVisibilityChange={setHighlightVisibility}
|
||||||
|
followedPubkeys={followedPubkeys}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{activeAccount && relayPool && (
|
||||||
|
<HighlightButton
|
||||||
|
ref={highlightButtonRef}
|
||||||
|
onHighlight={handleCreateHighlight}
|
||||||
|
highlightColor={settings.highlightColor || '#ffff00'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{toastMessage && (
|
{toastMessage && (
|
||||||
<Toast
|
<Toast
|
||||||
message={toastMessage}
|
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 ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
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 { Highlight } from '../types/highlights'
|
||||||
import { applyHighlightsToHTML } from '../utils/highlightMatching'
|
import { applyHighlightsToHTML } from '../utils/highlightMatching'
|
||||||
import { readingTime } from 'reading-time-estimator'
|
import { readingTime } from 'reading-time-estimator'
|
||||||
import { filterHighlightsByUrl } from '../utils/urlHelpers'
|
import { filterHighlightsByUrl } from '../utils/urlHelpers'
|
||||||
import { hexToRgb } from '../utils/colorHelpers'
|
import { hexToRgb } from '../utils/colorHelpers'
|
||||||
|
import ReaderHeader from './ReaderHeader'
|
||||||
|
import { HighlightVisibility } from './HighlightsPanel'
|
||||||
|
|
||||||
interface ContentPanelProps {
|
interface ContentPanelProps {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
@@ -17,11 +19,17 @@ interface ContentPanelProps {
|
|||||||
selectedUrl?: string
|
selectedUrl?: string
|
||||||
image?: string
|
image?: string
|
||||||
highlights?: Highlight[]
|
highlights?: Highlight[]
|
||||||
showUnderlines?: boolean
|
showHighlights?: boolean
|
||||||
highlightStyle?: 'marker' | 'underline'
|
highlightStyle?: 'marker' | 'underline'
|
||||||
highlightColor?: string
|
highlightColor?: string
|
||||||
onHighlightClick?: (highlightId: string) => void
|
onHighlightClick?: (highlightId: string) => void
|
||||||
selectedHighlightId?: string
|
selectedHighlightId?: string
|
||||||
|
highlightVisibility?: HighlightVisibility
|
||||||
|
currentUserPubkey?: string
|
||||||
|
followedPubkeys?: Set<string>
|
||||||
|
// For highlight creation
|
||||||
|
onTextSelection?: (text: string) => void
|
||||||
|
onClearSelection?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContentPanel: React.FC<ContentPanelProps> = ({
|
const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||||
@@ -32,17 +40,55 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
selectedUrl,
|
selectedUrl,
|
||||||
image,
|
image,
|
||||||
highlights = [],
|
highlights = [],
|
||||||
showUnderlines = true,
|
showHighlights = true,
|
||||||
highlightStyle = 'marker',
|
highlightStyle = 'marker',
|
||||||
highlightColor = '#ffff00',
|
highlightColor = '#ffff00',
|
||||||
onHighlightClick,
|
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 contentRef = useRef<HTMLDivElement>(null)
|
||||||
const markdownPreviewRef = useRef<HTMLDivElement>(null)
|
const markdownPreviewRef = useRef<HTMLDivElement>(null)
|
||||||
const [renderedHtml, setRenderedHtml] = useState<string>('')
|
const [renderedHtml, setRenderedHtml] = useState<string>('')
|
||||||
|
|
||||||
const relevantHighlights = useMemo(() => filterHighlightsByUrl(highlights, selectedUrl), [selectedUrl, highlights])
|
// Filter highlights by URL and visibility settings
|
||||||
|
const relevantHighlights = useMemo(() => {
|
||||||
|
console.log('🔍 ContentPanel: Processing highlights', {
|
||||||
|
totalHighlights: highlights.length,
|
||||||
|
selectedUrl,
|
||||||
|
showHighlights
|
||||||
|
})
|
||||||
|
|
||||||
|
const urlFiltered = filterHighlightsByUrl(highlights, selectedUrl)
|
||||||
|
console.log('📌 URL filtered highlights:', urlFiltered.length)
|
||||||
|
|
||||||
|
// Apply visibility filtering
|
||||||
|
const filtered = urlFiltered
|
||||||
|
.map(h => {
|
||||||
|
// Classify highlight level
|
||||||
|
let level: 'mine' | 'friends' | 'nostrverse' = 'nostrverse'
|
||||||
|
if (h.pubkey === currentUserPubkey) {
|
||||||
|
level = 'mine'
|
||||||
|
} else if (followedPubkeys.has(h.pubkey)) {
|
||||||
|
level = 'friends'
|
||||||
|
}
|
||||||
|
return { ...h, level }
|
||||||
|
})
|
||||||
|
.filter(h => {
|
||||||
|
// Filter by visibility settings
|
||||||
|
if (h.level === 'mine') return highlightVisibility.mine
|
||||||
|
if (h.level === 'friends') return highlightVisibility.friends
|
||||||
|
return highlightVisibility.nostrverse
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('✅ Relevant highlights after filtering:', filtered.length, filtered.map(h => h.content.substring(0, 30)))
|
||||||
|
return filtered
|
||||||
|
}, [selectedUrl, highlights, highlightVisibility, currentUserPubkey, followedPubkeys, showHighlights])
|
||||||
|
|
||||||
// Convert markdown to HTML when markdown content changes
|
// Convert markdown to HTML when markdown content changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -51,10 +97,16 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('📝 Converting markdown to HTML...')
|
||||||
|
|
||||||
// Use requestAnimationFrame to ensure ReactMarkdown has rendered
|
// Use requestAnimationFrame to ensure ReactMarkdown has rendered
|
||||||
const rafId = requestAnimationFrame(() => {
|
const rafId = requestAnimationFrame(() => {
|
||||||
if (markdownPreviewRef.current) {
|
if (markdownPreviewRef.current) {
|
||||||
setRenderedHtml(markdownPreviewRef.current.innerHTML)
|
const html = markdownPreviewRef.current.innerHTML
|
||||||
|
console.log('✅ Markdown converted to HTML:', html.length, 'chars')
|
||||||
|
setRenderedHtml(html)
|
||||||
|
} else {
|
||||||
|
console.warn('⚠️ markdownPreviewRef.current is null')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -64,15 +116,33 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
// Prepare the final HTML with highlights applied
|
// Prepare the final HTML with highlights applied
|
||||||
const finalHtml = useMemo(() => {
|
const finalHtml = useMemo(() => {
|
||||||
const sourceHtml = markdown ? renderedHtml : html
|
const sourceHtml = markdown ? renderedHtml : html
|
||||||
if (!sourceHtml) return ''
|
|
||||||
|
|
||||||
// Apply highlights if we have them and underlines are shown
|
console.log('🎨 Preparing final HTML:', {
|
||||||
if (showUnderlines && relevantHighlights.length > 0) {
|
hasMarkdown: !!markdown,
|
||||||
return applyHighlightsToHTML(sourceHtml, relevantHighlights, highlightStyle)
|
hasHtml: !!html,
|
||||||
|
renderedHtmlLength: renderedHtml.length,
|
||||||
|
sourceHtmlLength: sourceHtml?.length || 0,
|
||||||
|
showHighlights,
|
||||||
|
relevantHighlightsCount: relevantHighlights.length
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!sourceHtml) {
|
||||||
|
console.warn('⚠️ No source HTML available')
|
||||||
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply highlights if we have them and highlights are enabled
|
||||||
|
if (showHighlights && relevantHighlights.length > 0) {
|
||||||
|
console.log('✨ Applying', relevantHighlights.length, 'highlights to HTML')
|
||||||
|
const highlightedHtml = applyHighlightsToHTML(sourceHtml, relevantHighlights, highlightStyle)
|
||||||
|
console.log('✅ Highlights applied, result length:', highlightedHtml.length)
|
||||||
|
return highlightedHtml
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📄 Returning source HTML without highlights')
|
||||||
return sourceHtml
|
return sourceHtml
|
||||||
}, [html, renderedHtml, markdown, relevantHighlights, showUnderlines, highlightStyle])
|
}, [html, renderedHtml, markdown, relevantHighlights, showHighlights, highlightStyle])
|
||||||
|
|
||||||
|
|
||||||
// Attach click handlers to highlight marks
|
// Attach click handlers to highlight marks
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -127,6 +197,26 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
|
|
||||||
const hasHighlights = relevantHighlights.length > 0
|
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) {
|
if (!selectedUrl) {
|
||||||
return (
|
return (
|
||||||
<div className="reader empty">
|
<div className="reader empty">
|
||||||
@@ -140,7 +230,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
<div className="reader loading">
|
<div className="reader loading">
|
||||||
<div className="loading-spinner">
|
<div className="loading-spinner">
|
||||||
<FontAwesomeIcon icon={faSpinner} spin />
|
<FontAwesomeIcon icon={faSpinner} spin />
|
||||||
<span>Loading content…</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -159,39 +248,39 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{image && (
|
<ReaderHeader
|
||||||
<div className="reader-hero-image">
|
title={title}
|
||||||
<img src={image} alt={title || 'Article image'} />
|
image={image}
|
||||||
</div>
|
readingTimeText={readingStats ? readingStats.text : null}
|
||||||
)}
|
hasHighlights={hasHighlights}
|
||||||
{title && (
|
highlightCount={relevantHighlights.length}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
{markdown || html ? (
|
{markdown || html ? (
|
||||||
finalHtml ? (
|
markdown ? (
|
||||||
|
// For markdown, always use finalHtml once it's ready to ensure highlights are applied
|
||||||
|
renderedHtml && finalHtml ? (
|
||||||
|
<div
|
||||||
|
ref={contentRef}
|
||||||
|
className="reader-markdown"
|
||||||
|
dangerouslySetInnerHTML={{ __html: finalHtml }}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// Show loading state while markdown is being converted to HTML
|
||||||
|
<div className="reader-markdown">
|
||||||
|
<div className="loading-spinner">
|
||||||
|
<FontAwesomeIcon icon={faSpinner} spin size="sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
// For HTML, use finalHtml directly
|
||||||
<div
|
<div
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
className={markdown ? "reader-markdown" : "reader-html"}
|
className="reader-html"
|
||||||
dangerouslySetInnerHTML={{ __html: finalHtml }}
|
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<div className="reader-markdown" ref={contentRef} />
|
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="reader empty">
|
<div className="reader empty">
|
||||||
|
|||||||
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 React, { useEffect, useRef } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
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 { Highlight } from '../types/highlights'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
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 {
|
interface HighlightItemProps {
|
||||||
highlight: Highlight
|
highlight: HighlightWithLevel
|
||||||
onSelectUrl?: (url: string) => void
|
onSelectUrl?: (url: string) => void
|
||||||
isSelected?: boolean
|
isSelected?: boolean
|
||||||
onHighlightClick?: (highlightId: string) => void
|
onHighlightClick?: (highlightId: string) => void
|
||||||
@@ -14,6 +20,16 @@ interface HighlightItemProps {
|
|||||||
export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelectUrl, isSelected, onHighlightClick }) => {
|
export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelectUrl, isSelected, onHighlightClick }) => {
|
||||||
const itemRef = useRef<HTMLDivElement>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
if (isSelected && itemRef.current) {
|
if (isSelected && itemRef.current) {
|
||||||
itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
@@ -45,7 +61,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelec
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={itemRef}
|
ref={itemRef}
|
||||||
className={`highlight-item ${isSelected ? 'selected' : ''}`}
|
className={`highlight-item ${isSelected ? 'selected' : ''} ${highlight.level ? `level-${highlight.level}` : ''}`}
|
||||||
data-highlight-id={highlight.id}
|
data-highlight-id={highlight.id}
|
||||||
onClick={handleItemClick}
|
onClick={handleItemClick}
|
||||||
style={{ cursor: onHighlightClick ? 'pointer' : 'default' }}
|
style={{ cursor: onHighlightClick ? 'pointer' : 'default' }}
|
||||||
@@ -65,14 +81,12 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelec
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{highlight.context && (
|
|
||||||
<details className="highlight-context">
|
|
||||||
<summary>Show context</summary>
|
|
||||||
<p className="context-text">{highlight.context}</p>
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="highlight-meta">
|
<div className="highlight-meta">
|
||||||
|
<span className="highlight-author">
|
||||||
|
{getUserDisplayName()}
|
||||||
|
</span>
|
||||||
|
<span className="highlight-meta-separator">•</span>
|
||||||
<span className="highlight-time">
|
<span className="highlight-time">
|
||||||
{formatDistanceToNow(new Date(highlight.created_at * 1000), { addSuffix: true })}
|
{formatDistanceToNow(new Date(highlight.created_at * 1000), { addSuffix: true })}
|
||||||
</span>
|
</span>
|
||||||
@@ -84,10 +98,9 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelec
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
onClick={(e) => highlight.urlReference && onSelectUrl ? handleLinkClick(highlight.urlReference, e) : undefined}
|
onClick={(e) => highlight.urlReference && onSelectUrl ? handleLinkClick(highlight.urlReference, e) : undefined}
|
||||||
className="highlight-source"
|
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} />
|
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||||
<span>{highlight.eventReference ? 'Nostr event' : 'Source'}</span>
|
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import React, { useMemo, useState } from 'react'
|
import React, { useMemo, useState } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faChevronRight, faHighlighter, faEye, faEyeSlash, faRotate, faUser, faUserGroup } from '@fortawesome/free-solid-svg-icons'
|
import { faChevronRight, faHighlighter, faEye, faEyeSlash, faRotate, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { HighlightItem } from './HighlightItem'
|
import { HighlightItem } from './HighlightItem'
|
||||||
|
|
||||||
export type HighlightMode = 'mine' | 'others'
|
export interface HighlightVisibility {
|
||||||
|
nostrverse: boolean
|
||||||
|
friends: boolean
|
||||||
|
mine: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface HighlightsPanelProps {
|
interface HighlightsPanelProps {
|
||||||
highlights: Highlight[]
|
highlights: Highlight[]
|
||||||
@@ -13,13 +17,14 @@ interface HighlightsPanelProps {
|
|||||||
onToggleCollapse: () => void
|
onToggleCollapse: () => void
|
||||||
onSelectUrl?: (url: string) => void
|
onSelectUrl?: (url: string) => void
|
||||||
selectedUrl?: string
|
selectedUrl?: string
|
||||||
onToggleUnderlines?: (show: boolean) => void
|
onToggleHighlights?: (show: boolean) => void
|
||||||
selectedHighlightId?: string
|
selectedHighlightId?: string
|
||||||
onRefresh?: () => void
|
onRefresh?: () => void
|
||||||
onHighlightClick?: (highlightId: string) => void
|
onHighlightClick?: (highlightId: string) => void
|
||||||
currentUserPubkey?: string
|
currentUserPubkey?: string
|
||||||
highlightMode?: HighlightMode
|
highlightVisibility?: HighlightVisibility
|
||||||
onHighlightModeChange?: (mode: HighlightMode) => void
|
onHighlightVisibilityChange?: (visibility: HighlightVisibility) => void
|
||||||
|
followedPubkeys?: Set<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||||
@@ -29,23 +34,24 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
|||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
onSelectUrl,
|
onSelectUrl,
|
||||||
selectedUrl,
|
selectedUrl,
|
||||||
onToggleUnderlines,
|
onToggleHighlights,
|
||||||
selectedHighlightId,
|
selectedHighlightId,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onHighlightClick,
|
onHighlightClick,
|
||||||
currentUserPubkey,
|
currentUserPubkey,
|
||||||
highlightMode = 'others',
|
highlightVisibility = { nostrverse: true, friends: true, mine: true },
|
||||||
onHighlightModeChange
|
onHighlightVisibilityChange,
|
||||||
|
followedPubkeys = new Set()
|
||||||
}) => {
|
}) => {
|
||||||
const [showUnderlines, setShowUnderlines] = useState(true)
|
const [showHighlights, setShowHighlights] = useState(true)
|
||||||
|
|
||||||
const handleToggleUnderlines = () => {
|
const handleToggleHighlights = () => {
|
||||||
const newValue = !showUnderlines
|
const newValue = !showHighlights
|
||||||
setShowUnderlines(newValue)
|
setShowHighlights(newValue)
|
||||||
onToggleUnderlines?.(newValue)
|
onToggleHighlights?.(newValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter highlights based on mode and URL
|
// Filter highlights based on visibility levels and URL
|
||||||
const filteredHighlights = useMemo(() => {
|
const filteredHighlights = useMemo(() => {
|
||||||
if (!selectedUrl) return highlights
|
if (!selectedUrl) return highlights
|
||||||
|
|
||||||
@@ -75,18 +81,25 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by mode (mine vs others)
|
// Classify and filter by visibility levels
|
||||||
if (!currentUserPubkey) {
|
return urlFiltered
|
||||||
// If no user is logged in, show all highlights (others mode only makes sense)
|
.map(h => {
|
||||||
return urlFiltered
|
// Classify highlight level
|
||||||
}
|
let level: 'mine' | 'friends' | 'nostrverse' = 'nostrverse'
|
||||||
|
if (h.pubkey === currentUserPubkey) {
|
||||||
if (highlightMode === 'mine') {
|
level = 'mine'
|
||||||
return urlFiltered.filter(h => h.pubkey === currentUserPubkey)
|
} else if (followedPubkeys.has(h.pubkey)) {
|
||||||
} else {
|
level = 'friends'
|
||||||
return urlFiltered.filter(h => h.pubkey !== currentUserPubkey)
|
}
|
||||||
}
|
return { ...h, level }
|
||||||
}, [highlights, selectedUrl, highlightMode, currentUserPubkey])
|
})
|
||||||
|
.filter(h => {
|
||||||
|
// Filter by visibility settings
|
||||||
|
if (h.level === 'mine') return highlightVisibility.mine
|
||||||
|
if (h.level === 'friends') return highlightVisibility.friends
|
||||||
|
return highlightVisibility.nostrverse
|
||||||
|
})
|
||||||
|
}, [highlights, selectedUrl, highlightVisibility, currentUserPubkey, followedPubkeys])
|
||||||
|
|
||||||
if (isCollapsed) {
|
if (isCollapsed) {
|
||||||
const hasHighlights = filteredHighlights.length > 0
|
const hasHighlights = filteredHighlights.length > 0
|
||||||
@@ -109,53 +122,72 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="highlights-container">
|
<div className="highlights-container">
|
||||||
<div className="highlights-header">
|
<div className="highlights-header">
|
||||||
<div className="highlights-title">
|
|
||||||
<FontAwesomeIcon icon={faHighlighter} />
|
|
||||||
<h3>Highlights</h3>
|
|
||||||
{!loading && <span className="count">({filteredHighlights.length})</span>}
|
|
||||||
</div>
|
|
||||||
<div className="highlights-actions">
|
<div className="highlights-actions">
|
||||||
{currentUserPubkey && onHighlightModeChange && (
|
<div className="highlights-actions-left">
|
||||||
<div className="highlight-mode-toggle">
|
{onHighlightVisibilityChange && (
|
||||||
|
<div className="highlight-level-toggles">
|
||||||
|
<button
|
||||||
|
onClick={() => onHighlightVisibilityChange({
|
||||||
|
...highlightVisibility,
|
||||||
|
nostrverse: !highlightVisibility.nostrverse
|
||||||
|
})}
|
||||||
|
className={`level-toggle-btn ${highlightVisibility.nostrverse ? 'active' : ''}`}
|
||||||
|
title="Toggle nostrverse highlights"
|
||||||
|
aria-label="Toggle nostrverse highlights"
|
||||||
|
style={{ color: highlightVisibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faNetworkWired} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onHighlightVisibilityChange({
|
||||||
|
...highlightVisibility,
|
||||||
|
friends: !highlightVisibility.friends
|
||||||
|
})}
|
||||||
|
className={`level-toggle-btn ${highlightVisibility.friends ? 'active' : ''}`}
|
||||||
|
title={currentUserPubkey ? "Toggle friends highlights" : "Login to see friends highlights"}
|
||||||
|
aria-label="Toggle friends highlights"
|
||||||
|
style={{ color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined }}
|
||||||
|
disabled={!currentUserPubkey}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faUserGroup} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onHighlightVisibilityChange({
|
||||||
|
...highlightVisibility,
|
||||||
|
mine: !highlightVisibility.mine
|
||||||
|
})}
|
||||||
|
className={`level-toggle-btn ${highlightVisibility.mine ? 'active' : ''}`}
|
||||||
|
title={currentUserPubkey ? "Toggle my highlights" : "Login to see your highlights"}
|
||||||
|
aria-label="Toggle my highlights"
|
||||||
|
style={{ color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined }}
|
||||||
|
disabled={!currentUserPubkey}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faUser} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{onRefresh && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onHighlightModeChange('mine')}
|
onClick={onRefresh}
|
||||||
className={`mode-btn ${highlightMode === 'mine' ? 'active' : ''}`}
|
className="refresh-highlights-btn"
|
||||||
title="My highlights"
|
title="Refresh highlights"
|
||||||
aria-label="Show my highlights"
|
aria-label="Refresh highlights"
|
||||||
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faUser} />
|
<FontAwesomeIcon icon={faRotate} spin={loading} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{filteredHighlights.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onHighlightModeChange('others')}
|
onClick={handleToggleHighlights}
|
||||||
className={`mode-btn ${highlightMode === 'others' ? 'active' : ''}`}
|
className="toggle-highlight-display-btn"
|
||||||
title="Other highlights"
|
title={showHighlights ? 'Hide highlights' : 'Show highlights'}
|
||||||
aria-label="Show highlights from others"
|
aria-label={showHighlights ? 'Hide highlights' : 'Show highlights'}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faUserGroup} />
|
<FontAwesomeIcon icon={showHighlights ? faEye : faEyeSlash} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
)}
|
</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>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
onClick={onToggleCollapse}
|
onClick={onToggleCollapse}
|
||||||
className="toggle-highlights-btn"
|
className="toggle-highlights-btn"
|
||||||
@@ -167,9 +199,9 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading && filteredHighlights.length === 0 ? (
|
||||||
<div className="highlights-loading">
|
<div className="highlights-loading">
|
||||||
<p>Loading highlights...</p>
|
<FontAwesomeIcon icon={faHighlighter} spin />
|
||||||
</div>
|
</div>
|
||||||
) : filteredHighlights.length === 0 ? (
|
) : filteredHighlights.length === 0 ? (
|
||||||
<div className="highlights-empty">
|
<div className="highlights-empty">
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ interface IconButtonProps {
|
|||||||
ariaLabel?: string
|
ariaLabel?: string
|
||||||
variant?: 'primary' | 'success' | 'ghost'
|
variant?: 'primary' | 'success' | 'ghost'
|
||||||
size?: number
|
size?: number
|
||||||
|
disabled?: boolean
|
||||||
|
spin?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const IconButton: React.FC<IconButtonProps> = ({
|
const IconButton: React.FC<IconButtonProps> = ({
|
||||||
@@ -17,7 +19,9 @@ const IconButton: React.FC<IconButtonProps> = ({
|
|||||||
title,
|
title,
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
variant = 'ghost',
|
variant = 'ghost',
|
||||||
size = 33
|
size = 33,
|
||||||
|
disabled = false,
|
||||||
|
spin = false
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -26,8 +30,9 @@ const IconButton: React.FC<IconButtonProps> = ({
|
|||||||
title={title}
|
title={title}
|
||||||
aria-label={ariaLabel || title}
|
aria-label={ariaLabel || title}
|
||||||
style={{ width: size, height: size }}
|
style={{ width: size, height: size }}
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={icon} />
|
<FontAwesomeIcon icon={icon} spin={spin} />
|
||||||
</button>
|
</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 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, faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
import ColorPicker from './ColorPicker'
|
import ColorPicker from './ColorPicker'
|
||||||
@@ -7,6 +7,24 @@ import FontSelector from './FontSelector'
|
|||||||
import { loadFont, getFontFamily } from '../utils/fontLoader'
|
import { loadFont, getFontFamily } from '../utils/fontLoader'
|
||||||
import { hexToRgb } from '../utils/colorHelpers'
|
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',
|
||||||
|
defaultHighlightVisibilityNostrverse: true,
|
||||||
|
defaultHighlightVisibilityFriends: true,
|
||||||
|
defaultHighlightVisibilityMine: true,
|
||||||
|
}
|
||||||
|
|
||||||
interface SettingsProps {
|
interface SettingsProps {
|
||||||
settings: UserSettings
|
settings: UserSettings
|
||||||
onSave: (settings: UserSettings) => Promise<void>
|
onSave: (settings: UserSettings) => Promise<void>
|
||||||
@@ -24,14 +42,15 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Preload all fonts for the dropdown
|
// Preload all fonts for the dropdown
|
||||||
const fonts = ['inter', 'lora', 'merriweather', 'open-sans', 'roboto', 'source-serif-4', 'crimson-text', 'libre-baskerville', 'pt-serif']
|
const fonts = ['inter', 'lora', 'merriweather', 'open-sans', 'roboto', 'source-serif-4', 'crimson-text', 'libre-baskerville', 'pt-serif']
|
||||||
fonts.forEach(font => loadFont(font))
|
fonts.forEach(font => {
|
||||||
|
loadFont(font).catch(err => console.warn('Failed to preload font:', font, err))
|
||||||
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Load font for preview when it changes
|
// Load font for preview when it changes
|
||||||
if (localSettings.readingFont) {
|
const fontToLoad = localSettings.readingFont || 'source-serif-4'
|
||||||
loadFont(localSettings.readingFont)
|
loadFont(fontToLoad).catch(err => console.warn('Failed to load preview font:', fontToLoad, err))
|
||||||
}
|
|
||||||
}, [localSettings.readingFont])
|
}, [localSettings.readingFont])
|
||||||
|
|
||||||
// Auto-save settings whenever they change (except on initial mount)
|
// Auto-save settings whenever they change (except on initial mount)
|
||||||
@@ -44,19 +63,34 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
|||||||
onSave(localSettings)
|
onSave(localSettings)
|
||||||
}, [localSettings, onSave])
|
}, [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 (
|
return (
|
||||||
<div className="settings-view">
|
<div className="settings-view">
|
||||||
<div className="settings-header">
|
<div className="settings-header">
|
||||||
<h2>Settings</h2>
|
<h2>Settings</h2>
|
||||||
<IconButton
|
<div className="settings-header-actions">
|
||||||
icon={faTimes}
|
<IconButton
|
||||||
onClick={onClose}
|
icon={faUndo}
|
||||||
title="Close settings"
|
onClick={handleResetToDefaults}
|
||||||
ariaLabel="Close settings"
|
title="Reset to defaults"
|
||||||
variant="ghost"
|
ariaLabel="Reset to defaults"
|
||||||
/>
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faTimes}
|
||||||
|
onClick={onClose}
|
||||||
|
title="Close settings"
|
||||||
|
ariaLabel="Close settings"
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="settings-content">
|
<div className="settings-content">
|
||||||
@@ -66,7 +100,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
|||||||
<div className="setting-group setting-inline">
|
<div className="setting-group setting-inline">
|
||||||
<label htmlFor="readingFont">Reading Font</label>
|
<label htmlFor="readingFont">Reading Font</label>
|
||||||
<FontSelector
|
<FontSelector
|
||||||
value={localSettings.readingFont || 'system'}
|
value={localSettings.readingFont || 'source-serif-4'}
|
||||||
onChange={(font) => setLocalSettings({ ...localSettings, readingFont: font })}
|
onChange={(font) => setLocalSettings({ ...localSettings, readingFont: font })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,7 +112,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
|||||||
<button
|
<button
|
||||||
key={size}
|
key={size}
|
||||||
onClick={() => setLocalSettings({ ...localSettings, fontSize: 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`}
|
title={`${size}px`}
|
||||||
style={{ fontSize: `${size - 2}px` }}
|
style={{ fontSize: `${size - 2}px` }}
|
||||||
>
|
>
|
||||||
@@ -89,12 +123,12 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="setting-group">
|
<div className="setting-group">
|
||||||
<label htmlFor="showUnderlines" className="checkbox-label">
|
<label htmlFor="showHighlights" className="checkbox-label">
|
||||||
<input
|
<input
|
||||||
id="showUnderlines"
|
id="showHighlights"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={localSettings.showUnderlines !== false}
|
checked={localSettings.showHighlights !== false}
|
||||||
onChange={(e) => setLocalSettings({ ...localSettings, showUnderlines: e.target.checked })}
|
onChange={(e) => setLocalSettings({ ...localSettings, showHighlights: e.target.checked })}
|
||||||
className="setting-checkbox"
|
className="setting-checkbox"
|
||||||
/>
|
/>
|
||||||
<span>Show highlights</span>
|
<span>Show highlights</span>
|
||||||
@@ -121,12 +155,35 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="setting-group setting-inline">
|
<div className="setting-group setting-inline">
|
||||||
<label>Highlight Color</label>
|
<label className="setting-label">My Highlights</label>
|
||||||
<ColorPicker
|
<div className="setting-control">
|
||||||
selectedColor={localSettings.highlightColor || '#ffff00'}
|
<ColorPicker
|
||||||
onColorChange={(color) => setLocalSettings({ ...localSettings, highlightColor: color })}
|
selectedColor={localSettings.highlightColorMine || '#ffff00'}
|
||||||
/>
|
onColorChange={(color) => setLocalSettings({ ...localSettings, highlightColorMine: color })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group setting-inline">
|
||||||
|
<label className="setting-label">Friends Highlights</label>
|
||||||
|
<div className="setting-control">
|
||||||
|
<ColorPicker
|
||||||
|
selectedColor={localSettings.highlightColorFriends || '#f97316'}
|
||||||
|
onColorChange={(color) => setLocalSettings({ ...localSettings, highlightColorFriends: color })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group setting-inline">
|
||||||
|
<label className="setting-label">Nostrverse Highlights</label>
|
||||||
|
<div className="setting-control">
|
||||||
|
<ColorPicker
|
||||||
|
selectedColor={localSettings.highlightColorNostrverse || '#9333ea'}
|
||||||
|
onColorChange={(color) => setLocalSettings({ ...localSettings, highlightColorNostrverse: color })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="setting-preview">
|
<div className="setting-preview">
|
||||||
@@ -135,13 +192,15 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
|||||||
className="preview-content"
|
className="preview-content"
|
||||||
style={{
|
style={{
|
||||||
fontFamily: previewFontFamily,
|
fontFamily: previewFontFamily,
|
||||||
fontSize: `${localSettings.fontSize || 16}px`,
|
fontSize: `${localSettings.fontSize || 18}px`,
|
||||||
'--highlight-rgb': hexToRgb(localSettings.highlightColor || '#ffff00')
|
'--highlight-rgb': hexToRgb(localSettings.highlightColor || '#ffff00')
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties}
|
||||||
>
|
>
|
||||||
<h3>The Quick Brown Fox</h3>
|
<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>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.</p>
|
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. <span className={localSettings.showHighlights !== false ? `content-highlight-${localSettings.highlightStyle || 'marker'} level-friends` : ""}>Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</span> Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.</p>
|
||||||
|
<p>Totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. <span className={localSettings.showHighlights !== false ? `content-highlight-${localSettings.highlightStyle || 'marker'} level-nostrverse` : ""}>Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</span> Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit.</p>
|
||||||
|
<p>Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,7 +239,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
|||||||
<input
|
<input
|
||||||
id="sidebarCollapsed"
|
id="sidebarCollapsed"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={localSettings.sidebarCollapsed === true}
|
checked={localSettings.sidebarCollapsed !== false}
|
||||||
onChange={(e) => setLocalSettings({ ...localSettings, sidebarCollapsed: e.target.checked })}
|
onChange={(e) => setLocalSettings({ ...localSettings, sidebarCollapsed: e.target.checked })}
|
||||||
className="setting-checkbox"
|
className="setting-checkbox"
|
||||||
/>
|
/>
|
||||||
@@ -193,13 +252,40 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
|||||||
<input
|
<input
|
||||||
id="highlightsCollapsed"
|
id="highlightsCollapsed"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={localSettings.highlightsCollapsed === true}
|
checked={localSettings.highlightsCollapsed !== false}
|
||||||
onChange={(e) => setLocalSettings({ ...localSettings, highlightsCollapsed: e.target.checked })}
|
onChange={(e) => setLocalSettings({ ...localSettings, highlightsCollapsed: e.target.checked })}
|
||||||
className="setting-checkbox"
|
className="setting-checkbox"
|
||||||
/>
|
/>
|
||||||
<span>Start with highlights panel collapsed</span>
|
<span>Start with highlights panel collapsed</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group setting-inline">
|
||||||
|
<label>Default Highlight Visibility</label>
|
||||||
|
<div className="setting-buttons">
|
||||||
|
<IconButton
|
||||||
|
icon={faNetworkWired}
|
||||||
|
onClick={() => setLocalSettings({ ...localSettings, defaultHighlightVisibilityNostrverse: !(localSettings.defaultHighlightVisibilityNostrverse !== false) })}
|
||||||
|
title="Nostrverse highlights"
|
||||||
|
ariaLabel="Toggle nostrverse highlights by default"
|
||||||
|
variant={(localSettings.defaultHighlightVisibilityNostrverse !== false) ? 'primary' : 'ghost'}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faUserGroup}
|
||||||
|
onClick={() => setLocalSettings({ ...localSettings, defaultHighlightVisibilityFriends: !(localSettings.defaultHighlightVisibilityFriends !== false) })}
|
||||||
|
title="Friends highlights"
|
||||||
|
ariaLabel="Toggle friends highlights by default"
|
||||||
|
variant={(localSettings.defaultHighlightVisibilityFriends !== false) ? 'primary' : 'ghost'}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faUser}
|
||||||
|
onClick={() => setLocalSettings({ ...localSettings, defaultHighlightVisibilityMine: !(localSettings.defaultHighlightVisibilityMine !== false) })}
|
||||||
|
title="My highlights"
|
||||||
|
ariaLabel="Toggle my highlights by default"
|
||||||
|
variant={(localSettings.defaultHighlightVisibilityMine !== false) ? 'primary' : 'ghost'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
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 { Hooks } from 'applesauce-react'
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
import { Models } from 'applesauce-core'
|
import { Models } from 'applesauce-core'
|
||||||
import { Accounts } from 'applesauce-accounts'
|
import { Accounts } from 'applesauce-accounts'
|
||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
import { ViewMode } from './Bookmarks'
|
|
||||||
|
|
||||||
interface SidebarHeaderProps {
|
interface SidebarHeaderProps {
|
||||||
onToggleCollapse: () => void
|
onToggleCollapse: () => void
|
||||||
onLogout: () => void
|
onLogout: () => void
|
||||||
viewMode: ViewMode
|
|
||||||
onViewModeChange: (mode: ViewMode) => void
|
|
||||||
onOpenSettings: () => 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 [isConnecting, setIsConnecting] = useState(false)
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
const accountManager = Hooks.useAccountManager()
|
const accountManager = Hooks.useAccountManager()
|
||||||
@@ -61,13 +60,18 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
|||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faChevronRight} />
|
<FontAwesomeIcon icon={faChevronRight} />
|
||||||
</button>
|
</button>
|
||||||
<div className="profile-avatar" title={getUserDisplayName()}>
|
<div className="sidebar-header-right">
|
||||||
{profileImage ? (
|
{onRefresh && (
|
||||||
<img src={profileImage} alt={getUserDisplayName()} />
|
<IconButton
|
||||||
) : (
|
icon={faRotate}
|
||||||
<FontAwesomeIcon icon={faUser} />
|
onClick={onRefresh}
|
||||||
)}
|
title="Refresh bookmarks"
|
||||||
</div>
|
ariaLabel="Refresh bookmarks"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={isRefreshing}
|
||||||
|
spin={isRefreshing}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={faGear}
|
icon={faGear}
|
||||||
onClick={onOpenSettings}
|
onClick={onOpenSettings}
|
||||||
@@ -75,6 +79,18 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
|||||||
ariaLabel="Settings"
|
ariaLabel="Settings"
|
||||||
variant="ghost"
|
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 ? (
|
{activeAccount ? (
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={faRightFromBracket}
|
icon={faRightFromBracket}
|
||||||
@@ -92,29 +108,7 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
21
src/config/relays.ts
Normal file
21
src/config/relays.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Centralized relay configuration
|
||||||
|
* Single set of relays used throughout the application
|
||||||
|
*/
|
||||||
|
|
||||||
|
// All relays including local relay
|
||||||
|
export const RELAYS = [
|
||||||
|
'ws://localhost:10547',
|
||||||
|
'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',
|
||||||
|
'wss://purplepag.es',
|
||||||
|
'wss://relay.primal.net',
|
||||||
|
'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87'
|
||||||
|
]
|
||||||
|
|
||||||
@@ -4,6 +4,7 @@ import { fetchArticleByNaddr } from '../services/articleService'
|
|||||||
import { fetchHighlightsForArticle } from '../services/highlightService'
|
import { fetchHighlightsForArticle } from '../services/highlightService'
|
||||||
import { ReadableContent } from '../services/readerService'
|
import { ReadableContent } from '../services/readerService'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
|
||||||
interface UseArticleLoaderProps {
|
interface UseArticleLoaderProps {
|
||||||
naddr: string | undefined
|
naddr: string | undefined
|
||||||
@@ -12,11 +13,11 @@ interface UseArticleLoaderProps {
|
|||||||
setReaderContent: (content: ReadableContent | undefined) => void
|
setReaderContent: (content: ReadableContent | undefined) => void
|
||||||
setReaderLoading: (loading: boolean) => void
|
setReaderLoading: (loading: boolean) => void
|
||||||
setIsCollapsed: (collapsed: boolean) => void
|
setIsCollapsed: (collapsed: boolean) => void
|
||||||
setIsHighlightsCollapsed: (collapsed: boolean) => void
|
|
||||||
setHighlights: (highlights: Highlight[]) => void
|
setHighlights: (highlights: Highlight[]) => void
|
||||||
setHighlightsLoading: (loading: boolean) => void
|
setHighlightsLoading: (loading: boolean) => void
|
||||||
setCurrentArticleCoordinate: (coord: string | undefined) => void
|
setCurrentArticleCoordinate: (coord: string | undefined) => void
|
||||||
setCurrentArticleEventId: (id: string | undefined) => void
|
setCurrentArticleEventId: (id: string | undefined) => void
|
||||||
|
setCurrentArticle?: (article: NostrEvent) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useArticleLoader({
|
export function useArticleLoader({
|
||||||
@@ -26,11 +27,11 @@ export function useArticleLoader({
|
|||||||
setReaderContent,
|
setReaderContent,
|
||||||
setReaderLoading,
|
setReaderLoading,
|
||||||
setIsCollapsed,
|
setIsCollapsed,
|
||||||
setIsHighlightsCollapsed,
|
|
||||||
setHighlights,
|
setHighlights,
|
||||||
setHighlightsLoading,
|
setHighlightsLoading,
|
||||||
setCurrentArticleCoordinate,
|
setCurrentArticleCoordinate,
|
||||||
setCurrentArticleEventId
|
setCurrentArticleEventId,
|
||||||
|
setCurrentArticle
|
||||||
}: UseArticleLoaderProps) {
|
}: UseArticleLoaderProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!relayPool || !naddr) return
|
if (!relayPool || !naddr) return
|
||||||
@@ -40,7 +41,7 @@ export function useArticleLoader({
|
|||||||
setReaderContent(undefined)
|
setReaderContent(undefined)
|
||||||
setSelectedUrl(`nostr:${naddr}`)
|
setSelectedUrl(`nostr:${naddr}`)
|
||||||
setIsCollapsed(true)
|
setIsCollapsed(true)
|
||||||
setIsHighlightsCollapsed(false)
|
// Keep highlights panel collapsed by default - only open on user interaction
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const article = await fetchArticleByNaddr(relayPool, naddr)
|
const article = await fetchArticleByNaddr(relayPool, naddr)
|
||||||
@@ -56,19 +57,33 @@ export function useArticleLoader({
|
|||||||
|
|
||||||
setCurrentArticleCoordinate(articleCoordinate)
|
setCurrentArticleCoordinate(articleCoordinate)
|
||||||
setCurrentArticleEventId(article.event.id)
|
setCurrentArticleEventId(article.event.id)
|
||||||
|
setCurrentArticle?.(article.event)
|
||||||
|
|
||||||
console.log('📰 Article loaded:', article.title)
|
console.log('📰 Article loaded:', article.title)
|
||||||
console.log('📍 Coordinate:', articleCoordinate)
|
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 {
|
try {
|
||||||
setHighlightsLoading(true)
|
setHighlightsLoading(true)
|
||||||
const fetchedHighlights = await fetchHighlightsForArticle(
|
setHighlights([]) // Clear old highlights
|
||||||
|
const highlightsList: Highlight[] = []
|
||||||
|
|
||||||
|
await fetchHighlightsForArticle(
|
||||||
relayPool,
|
relayPool,
|
||||||
articleCoordinate,
|
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`)
|
console.log(`📌 Found ${highlightsList.length} highlights`)
|
||||||
setHighlights(fetchedHighlights)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch highlights:', err)
|
console.error('Failed to fetch highlights:', err)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -82,8 +97,6 @@ export function useArticleLoader({
|
|||||||
url: `nostr:${naddr}`
|
url: `nostr:${naddr}`
|
||||||
})
|
})
|
||||||
setReaderLoading(false)
|
setReaderLoading(false)
|
||||||
} finally {
|
|
||||||
setReaderLoading(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
85
src/hooks/useExternalUrlLoader.ts
Normal file
85
src/hooks/useExternalUrlLoader.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { fetchReadableContent, ReadableContent } from '../services/readerService'
|
||||||
|
import { fetchHighlightsForUrl } from '../services/highlightService'
|
||||||
|
import { Highlight } from '../types/highlights'
|
||||||
|
|
||||||
|
interface UseExternalUrlLoaderProps {
|
||||||
|
url: string | undefined
|
||||||
|
relayPool: RelayPool | null
|
||||||
|
setSelectedUrl: (url: string) => void
|
||||||
|
setReaderContent: (content: ReadableContent | undefined) => void
|
||||||
|
setReaderLoading: (loading: boolean) => void
|
||||||
|
setIsCollapsed: (collapsed: boolean) => void
|
||||||
|
setHighlights: (highlights: Highlight[]) => void
|
||||||
|
setHighlightsLoading: (loading: boolean) => void
|
||||||
|
setCurrentArticleCoordinate: (coord: string | undefined) => void
|
||||||
|
setCurrentArticleEventId: (id: string | undefined) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useExternalUrlLoader({
|
||||||
|
url,
|
||||||
|
relayPool,
|
||||||
|
setSelectedUrl,
|
||||||
|
setReaderContent,
|
||||||
|
setReaderLoading,
|
||||||
|
setIsCollapsed,
|
||||||
|
setHighlights,
|
||||||
|
setHighlightsLoading,
|
||||||
|
setCurrentArticleCoordinate,
|
||||||
|
setCurrentArticleEventId
|
||||||
|
}: UseExternalUrlLoaderProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!relayPool || !url) return
|
||||||
|
|
||||||
|
const loadExternalUrl = async () => {
|
||||||
|
setReaderLoading(true)
|
||||||
|
setReaderContent(undefined)
|
||||||
|
setSelectedUrl(url)
|
||||||
|
setIsCollapsed(true)
|
||||||
|
// Clear article-specific state
|
||||||
|
setCurrentArticleCoordinate(undefined)
|
||||||
|
setCurrentArticleEventId(undefined)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await fetchReadableContent(url)
|
||||||
|
setReaderContent(content)
|
||||||
|
|
||||||
|
console.log('🌐 External URL loaded:', content.title)
|
||||||
|
|
||||||
|
// Set reader loading to false immediately after content is ready
|
||||||
|
setReaderLoading(false)
|
||||||
|
|
||||||
|
// Fetch highlights for this URL asynchronously
|
||||||
|
try {
|
||||||
|
setHighlightsLoading(true)
|
||||||
|
setHighlights([])
|
||||||
|
|
||||||
|
// Check if fetchHighlightsForUrl exists, otherwise skip
|
||||||
|
if (typeof fetchHighlightsForUrl === 'function') {
|
||||||
|
const highlightsList = await fetchHighlightsForUrl(relayPool, url)
|
||||||
|
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
|
||||||
|
console.log(`📌 Found ${highlightsList.length} highlights for URL`)
|
||||||
|
} else {
|
||||||
|
console.log('📌 Highlight fetching for URLs not yet implemented')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch highlights:', err)
|
||||||
|
} finally {
|
||||||
|
setHighlightsLoading(false)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load external URL:', err)
|
||||||
|
setReaderContent({
|
||||||
|
title: 'Error Loading Content',
|
||||||
|
html: `<p>Failed to load content: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
|
||||||
|
url
|
||||||
|
})
|
||||||
|
setReaderLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadExternalUrl()
|
||||||
|
}, [url, relayPool])
|
||||||
|
}
|
||||||
|
|
||||||
@@ -5,11 +5,7 @@ import { EventFactory } from 'applesauce-factory'
|
|||||||
import { AccountManager } from 'applesauce-accounts'
|
import { AccountManager } from 'applesauce-accounts'
|
||||||
import { UserSettings, loadSettings, saveSettings, watchSettings } from '../services/settingsService'
|
import { UserSettings, loadSettings, saveSettings, watchSettings } from '../services/settingsService'
|
||||||
import { loadFont, getFontFamily } from '../utils/fontLoader'
|
import { loadFont, getFontFamily } from '../utils/fontLoader'
|
||||||
|
import { RELAYS } from '../config/relays'
|
||||||
const RELAY_URLS = [
|
|
||||||
'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.nostr.band',
|
|
||||||
'wss://relay.dergigi.com', 'wss://wot.dergigi.com'
|
|
||||||
]
|
|
||||||
|
|
||||||
interface UseSettingsParams {
|
interface UseSettingsParams {
|
||||||
relayPool: RelayPool | null
|
relayPool: RelayPool | null
|
||||||
@@ -29,7 +25,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
|||||||
|
|
||||||
const loadAndWatch = async () => {
|
const loadAndWatch = async () => {
|
||||||
try {
|
try {
|
||||||
const loadedSettings = await loadSettings(relayPool, eventStore, pubkey, RELAY_URLS)
|
const loadedSettings = await loadSettings(relayPool, eventStore, pubkey, RELAYS)
|
||||||
if (loadedSettings) setSettings(loadedSettings)
|
if (loadedSettings) setSettings(loadedSettings)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load settings:', err)
|
console.error('Failed to load settings:', err)
|
||||||
@@ -47,11 +43,32 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
|||||||
|
|
||||||
// Apply settings to document
|
// Apply settings to document
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement.style
|
const applyStyles = async () => {
|
||||||
const fontKey = settings.readingFont || 'system'
|
const root = document.documentElement.style
|
||||||
if (fontKey !== 'system') loadFont(fontKey)
|
const fontKey = settings.readingFont || 'system'
|
||||||
root.setProperty('--reading-font', getFontFamily(fontKey))
|
|
||||||
root.setProperty('--reading-font-size', `${settings.fontSize || 16}px`)
|
console.log('🎨 Applying settings styles:', { fontKey, fontSize: settings.fontSize })
|
||||||
|
|
||||||
|
// Load font first and wait for it to be ready
|
||||||
|
if (fontKey !== 'system') {
|
||||||
|
console.log('⏳ Waiting for font to load...')
|
||||||
|
await loadFont(fontKey)
|
||||||
|
console.log('✅ Font loaded, applying styles')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply font settings after font is loaded
|
||||||
|
root.setProperty('--reading-font', getFontFamily(fontKey))
|
||||||
|
root.setProperty('--reading-font-size', `${settings.fontSize || 18}px`)
|
||||||
|
|
||||||
|
// Set highlight colors for three levels
|
||||||
|
root.setProperty('--highlight-color-mine', settings.highlightColorMine || '#ffff00')
|
||||||
|
root.setProperty('--highlight-color-friends', settings.highlightColorFriends || '#f97316')
|
||||||
|
root.setProperty('--highlight-color-nostrverse', settings.highlightColorNostrverse || '#9333ea')
|
||||||
|
|
||||||
|
console.log('✅ All styles applied')
|
||||||
|
}
|
||||||
|
|
||||||
|
applyStyles()
|
||||||
}, [settings])
|
}, [settings])
|
||||||
|
|
||||||
const saveSettingsWithToast = useCallback(async (newSettings: UserSettings) => {
|
const saveSettingsWithToast = useCallback(async (newSettings: UserSettings) => {
|
||||||
@@ -60,7 +77,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
|||||||
const fullAccount = accountManager.getActive()
|
const fullAccount = accountManager.getActive()
|
||||||
if (!fullAccount) throw new Error('No active account')
|
if (!fullAccount) throw new Error('No active account')
|
||||||
const factory = new EventFactory({ signer: fullAccount })
|
const factory = new EventFactory({ signer: fullAccount })
|
||||||
await saveSettings(relayPool, eventStore, factory, newSettings, RELAY_URLS)
|
await saveSettings(relayPool, eventStore, factory, newSettings, RELAYS)
|
||||||
setSettings(newSettings)
|
setSettings(newSettings)
|
||||||
setToastType('success')
|
setToastType('success')
|
||||||
setToastMessage('Settings saved')
|
setToastMessage('Settings saved')
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
435
src/index.css
435
src/index.css
@@ -13,8 +13,15 @@
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
-webkit-text-size-adjust: 100%;
|
-webkit-text-size-adjust: 100%;
|
||||||
|
|
||||||
--reading-font: system-ui, -apple-system, sans-serif;
|
--reading-font: 'Source Serif 4', serif;
|
||||||
--reading-font-size: 16px;
|
--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 {
|
body {
|
||||||
@@ -24,9 +31,9 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
max-width: 1280px;
|
max-width: none;
|
||||||
margin: 0 auto;
|
margin: 0;
|
||||||
padding: 2rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app {
|
.app {
|
||||||
@@ -49,56 +56,34 @@ body {
|
|||||||
color: #888;
|
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 Styles */
|
||||||
.bookmarks-container {
|
.bookmarks-container {
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
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 {
|
.sidebar-header-bar {
|
||||||
@@ -109,8 +94,15 @@ body {
|
|||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
background: #1a1a1a;
|
background: #1a1a1a;
|
||||||
border: 1px solid #333;
|
border: 1px solid #333;
|
||||||
border-radius: 8px;
|
border-radius: 12px 12px 0 0;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-mode-controls {
|
.view-mode-controls {
|
||||||
@@ -179,8 +171,8 @@ body {
|
|||||||
.bookmarks-container.collapsed {
|
.bookmarks-container.collapsed {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: flex-end;
|
justify-content: flex-start;
|
||||||
padding: 0.75rem 0 0 0;
|
padding: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
@@ -188,16 +180,17 @@ body {
|
|||||||
.bookmarks-container.collapsed .toggle-sidebar-btn {
|
.bookmarks-container.collapsed .toggle-sidebar-btn {
|
||||||
background: #2a2a2a;
|
background: #2a2a2a;
|
||||||
color: #ddd;
|
color: #ddd;
|
||||||
border: 1px solid #444;
|
border: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-radius: 6px;
|
border-radius: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 36px;
|
width: 48px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bookmarks-container.collapsed .toggle-sidebar-btn:hover {
|
.bookmarks-container.collapsed .toggle-sidebar-btn:hover {
|
||||||
@@ -423,7 +416,7 @@ body {
|
|||||||
.two-pane {
|
.two-pane {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 360px 1fr;
|
grid-template-columns: 360px 1fr;
|
||||||
gap: 1rem;
|
column-gap: 0;
|
||||||
height: calc(100vh - 4rem);
|
height: calc(100vh - 4rem);
|
||||||
transition: grid-template-columns 0.3s ease;
|
transition: grid-template-columns 0.3s ease;
|
||||||
}
|
}
|
||||||
@@ -435,22 +428,22 @@ body {
|
|||||||
/* Three-pane layout */
|
/* Three-pane layout */
|
||||||
.three-pane {
|
.three-pane {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 360px 1fr 360px;
|
grid-template-columns: var(--sidebar-width) 1fr var(--highlights-width);
|
||||||
gap: 1rem;
|
column-gap: 0;
|
||||||
height: calc(100vh - 4rem);
|
height: calc(100vh - 2rem);
|
||||||
transition: grid-template-columns 0.3s ease;
|
transition: grid-template-columns 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.three-pane.sidebar-collapsed {
|
.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 {
|
.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 {
|
.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 {
|
.pane.sidebar {
|
||||||
@@ -461,9 +454,20 @@ body {
|
|||||||
.pane.main {
|
.pane.main {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
max-width: 900px;
|
max-width: var(--main-max-width);
|
||||||
margin: 0 auto;
|
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 {
|
.pane.highlights {
|
||||||
@@ -474,9 +478,11 @@ body {
|
|||||||
.reader {
|
.reader {
|
||||||
background: #1a1a1a;
|
background: #1a1a1a;
|
||||||
border: 1px solid #333;
|
border: 1px solid #333;
|
||||||
border-radius: 12px;
|
border-radius: 8px;
|
||||||
padding: 1rem;
|
padding: 0.75rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
overflow: hidden;
|
||||||
|
contain: layout style;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reader.empty {
|
.reader.empty {
|
||||||
@@ -699,6 +705,8 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bookmarks-grid.bookmarks-compact {
|
.bookmarks-grid.bookmarks-compact {
|
||||||
@@ -711,7 +719,7 @@ body {
|
|||||||
|
|
||||||
.individual-bookmark {
|
.individual-bookmark {
|
||||||
background: #2a2a2a;
|
background: #2a2a2a;
|
||||||
padding: 1.25rem;
|
padding: 1rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
border: 1px solid #333;
|
border: 1px solid #333;
|
||||||
@@ -728,11 +736,13 @@ body {
|
|||||||
|
|
||||||
/* Compact view styles */
|
/* Compact view styles */
|
||||||
.individual-bookmark.compact {
|
.individual-bookmark.compact {
|
||||||
padding: 0.4rem 0.75rem;
|
padding: 0.3rem 0.25rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-bottom: 1px solid #333;
|
border-bottom: 1px solid #333;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.individual-bookmark.compact:hover {
|
.individual-bookmark.compact:hover {
|
||||||
@@ -746,6 +756,9 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compact-row.clickable {
|
.compact-row.clickable {
|
||||||
@@ -766,7 +779,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.compact-text {
|
.compact-text {
|
||||||
flex: 1;
|
flex: 1 1 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
color: #ccc;
|
color: #ccc;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -774,6 +787,7 @@ body {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bookmark-date-compact {
|
.bookmark-date-compact {
|
||||||
@@ -784,8 +798,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.compact-read-btn {
|
.compact-read-btn {
|
||||||
background: #28a745;
|
background: transparent;
|
||||||
color: white;
|
color: #888;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -797,11 +811,12 @@ body {
|
|||||||
width: 26px;
|
width: 26px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
transition: background-color 0.2s ease;
|
margin-left: auto;
|
||||||
|
transition: color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compact-read-btn:hover {
|
.compact-read-btn:hover {
|
||||||
background: #218838;
|
color: #ccc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compact-read-btn:active {
|
.compact-read-btn:active {
|
||||||
@@ -1085,7 +1100,6 @@ body {
|
|||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card,
|
|
||||||
.bookmark-item {
|
.bookmark-item {
|
||||||
background: #f9f9f9;
|
background: #f9f9f9;
|
||||||
border-color: #ddd;
|
border-color: #ddd;
|
||||||
@@ -1171,13 +1185,14 @@ body {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
padding-right: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.highlights-container.collapsed {
|
.highlights-container.collapsed {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
padding: 0.75rem 0 0 0;
|
padding: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
@@ -1185,9 +1200,9 @@ body {
|
|||||||
.highlights-container.collapsed .toggle-highlights-btn {
|
.highlights-container.collapsed .toggle-highlights-btn {
|
||||||
background: #2a2a2a;
|
background: #2a2a2a;
|
||||||
color: #ddd;
|
color: #ddd;
|
||||||
border: 1px solid #444;
|
border: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-radius: 6px;
|
border-radius: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1233,10 +1248,23 @@ body {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-bottom: 1px solid #333;
|
border-bottom: 1px solid #333;
|
||||||
background: #1e1e1e;
|
background: #1a1a1a;
|
||||||
border-radius: 12px 12px 0 0;
|
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 {
|
.highlights-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1289,8 +1317,50 @@ body {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Three-level highlight toggles */
|
||||||
|
.highlight-level-toggles {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight-level-toggles .level-toggle-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight-level-toggles .level-toggle-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight-level-toggles .level-toggle-btn.active {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight-level-toggles .level-toggle-btn:not(.active) {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight-level-toggles .level-toggle-btn:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight-level-toggles .level-toggle-btn:disabled:hover {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
.refresh-highlights-btn,
|
.refresh-highlights-btn,
|
||||||
.toggle-underlines-btn,
|
.toggle-highlight-display-btn,
|
||||||
.toggle-highlights-btn {
|
.toggle-highlights-btn {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #ddd;
|
color: #ddd;
|
||||||
@@ -1307,14 +1377,14 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.refresh-highlights-btn:hover,
|
.refresh-highlights-btn:hover,
|
||||||
.toggle-underlines-btn:hover,
|
.toggle-highlight-display-btn:hover,
|
||||||
.toggle-highlights-btn:hover {
|
.toggle-highlights-btn:hover {
|
||||||
background: #2a2a2a;
|
background: #2a2a2a;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.refresh-highlights-btn:active,
|
.refresh-highlights-btn:active,
|
||||||
.toggle-underlines-btn:active,
|
.toggle-highlight-display-btn:active,
|
||||||
.toggle-highlights-btn:active {
|
.toggle-highlights-btn:active {
|
||||||
transform: translateY(1px);
|
transform: translateY(1px);
|
||||||
}
|
}
|
||||||
@@ -1380,6 +1450,22 @@ body {
|
|||||||
box-shadow: 0 0 0 2px rgba(100, 108, 255, 0.3);
|
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 {
|
.highlight-quote-icon {
|
||||||
color: #646cff;
|
color: #646cff;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
@@ -1387,6 +1473,19 @@ body {
|
|||||||
margin-top: 0.25rem;
|
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 {
|
.highlight-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1415,41 +1514,25 @@ body {
|
|||||||
line-height: 1.5;
|
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 {
|
.highlight-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.5rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #888;
|
color: #888;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.highlight-author {
|
||||||
|
color: #aaa;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight-meta-separator {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
.highlight-time {
|
.highlight-time {
|
||||||
color: #888;
|
color: #888;
|
||||||
}
|
}
|
||||||
@@ -1482,6 +1565,7 @@ body {
|
|||||||
position: relative;
|
position: relative;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
box-shadow: 0 0 8px rgba(var(--highlight-rgb, 255, 255, 0), 0.2);
|
box-shadow: 0 0 8px rgba(var(--highlight-rgb, 255, 255, 0), 0.2);
|
||||||
|
contain: layout style;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-highlight:hover,
|
.content-highlight:hover,
|
||||||
@@ -1501,6 +1585,7 @@ body {
|
|||||||
text-decoration-color: rgba(var(--highlight-rgb, 255, 255, 0), 0.8);
|
text-decoration-color: rgba(var(--highlight-rgb, 255, 255, 0), 0.8);
|
||||||
text-decoration-thickness: 2px;
|
text-decoration-thickness: 2px;
|
||||||
text-underline-offset: 2px;
|
text-underline-offset: 2px;
|
||||||
|
contain: layout style;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-highlight-underline:hover {
|
.content-highlight-underline:hover {
|
||||||
@@ -1549,6 +1634,68 @@ body {
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Three-level highlight colors */
|
||||||
|
.content-highlight-marker.level-mine,
|
||||||
|
.content-highlight.level-mine {
|
||||||
|
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, #ffff00) 50%, transparent);
|
||||||
|
box-shadow: 0 0 12px color-mix(in srgb, var(--highlight-color-mine, #ffff00) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-highlight-marker.level-friends,
|
||||||
|
.content-highlight.level-friends {
|
||||||
|
background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 35%, transparent);
|
||||||
|
box-shadow: 0 0 8px color-mix(in srgb, var(--highlight-color-friends, #f97316) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-highlight-marker.level-friends:hover,
|
||||||
|
.content-highlight.level-friends:hover {
|
||||||
|
background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 50%, transparent);
|
||||||
|
box-shadow: 0 0 12px color-mix(in srgb, var(--highlight-color-friends, #f97316) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-highlight-marker.level-nostrverse,
|
||||||
|
.content-highlight.level-nostrverse {
|
||||||
|
background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 35%, transparent);
|
||||||
|
box-shadow: 0 0 8px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-highlight-marker.level-nostrverse:hover,
|
||||||
|
.content-highlight.level-nostrverse:hover {
|
||||||
|
background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 50%, transparent);
|
||||||
|
box-shadow: 0 0 12px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Underline styles for three levels */
|
||||||
|
.content-highlight-underline.level-mine {
|
||||||
|
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, #ffff00);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-highlight-underline.level-friends {
|
||||||
|
text-decoration-color: color-mix(in srgb, var(--highlight-color-friends, #f97316) 80%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-highlight-underline.level-friends:hover {
|
||||||
|
text-decoration-color: var(--highlight-color-friends, #f97316);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-highlight-underline.level-nostrverse {
|
||||||
|
text-decoration-color: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 80%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-highlight-underline.level-nostrverse:hover {
|
||||||
|
text-decoration-color: var(--highlight-color-nostrverse, #9333ea);
|
||||||
|
}
|
||||||
|
|
||||||
/* Ensure highlights work in both light and dark mode */
|
/* Ensure highlights work in both light and dark mode */
|
||||||
@media (prefers-color-scheme: light) {
|
@media (prefers-color-scheme: light) {
|
||||||
.content-highlight,
|
.content-highlight,
|
||||||
@@ -1571,6 +1718,55 @@ body {
|
|||||||
text-decoration-color: rgba(var(--highlight-rgb, 255, 255, 0), 1);
|
text-decoration-color: rgba(var(--highlight-rgb, 255, 255, 0), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Three-level overrides for light mode */
|
||||||
|
.content-highlight-marker.level-mine,
|
||||||
|
.content-highlight.level-mine {
|
||||||
|
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, #ffff00) 55%, transparent);
|
||||||
|
box-shadow: 0 0 10px color-mix(in srgb, var(--highlight-color-mine, #ffff00) 25%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-highlight-marker.level-friends,
|
||||||
|
.content-highlight.level-friends {
|
||||||
|
background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 40%, transparent);
|
||||||
|
box-shadow: 0 0 6px color-mix(in srgb, var(--highlight-color-friends, #f97316) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-highlight-marker.level-friends:hover,
|
||||||
|
.content-highlight.level-friends:hover {
|
||||||
|
background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 55%, transparent);
|
||||||
|
box-shadow: 0 0 10px color-mix(in srgb, var(--highlight-color-friends, #f97316) 25%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-highlight-marker.level-nostrverse,
|
||||||
|
.content-highlight.level-nostrverse {
|
||||||
|
background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 40%, transparent);
|
||||||
|
box-shadow: 0 0 6px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-highlight-marker.level-nostrverse:hover,
|
||||||
|
.content-highlight.level-nostrverse:hover {
|
||||||
|
background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 55%, transparent);
|
||||||
|
box-shadow: 0 0 10px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 25%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-highlight-underline.level-mine {
|
||||||
|
text-decoration-color: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 90%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-highlight-underline.level-friends {
|
||||||
|
text-decoration-color: color-mix(in srgb, var(--highlight-color-friends, #f97316) 90%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-highlight-underline.level-nostrverse {
|
||||||
|
text-decoration-color: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 90%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.highlight-indicator {
|
.highlight-indicator {
|
||||||
background: rgba(100, 108, 255, 0.15);
|
background: rgba(100, 108, 255, 0.15);
|
||||||
border-color: rgba(100, 108, 255, 0.4);
|
border-color: rgba(100, 108, 255, 0.4);
|
||||||
@@ -1602,6 +1798,12 @@ body {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-content {
|
.settings-content {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -1640,6 +1842,17 @@ body {
|
|||||||
gap: 1rem;
|
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 {
|
.setting-group.setting-inline label {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
getArticlePublished,
|
getArticlePublished,
|
||||||
getArticleSummary
|
getArticleSummary
|
||||||
} from 'applesauce-core/helpers'
|
} from 'applesauce-core/helpers'
|
||||||
|
import { RELAYS } from '../config/relays'
|
||||||
|
|
||||||
export interface ArticleContent {
|
export interface ArticleContent {
|
||||||
title: string
|
title: string
|
||||||
@@ -95,15 +96,10 @@ export async function fetchArticleByNaddr(
|
|||||||
|
|
||||||
const pointer = decoded.data as AddressPointer
|
const pointer = decoded.data as AddressPointer
|
||||||
|
|
||||||
// Define relays to query
|
// Define relays to query - prefer relays from naddr, fallback to configured relays (including local)
|
||||||
const relays = pointer.relays && pointer.relays.length > 0
|
const relays = pointer.relays && pointer.relays.length > 0
|
||||||
? pointer.relays
|
? pointer.relays
|
||||||
: [
|
: RELAYS
|
||||||
'wss://relay.damus.io',
|
|
||||||
'wss://nos.lol',
|
|
||||||
'wss://relay.nostr.band',
|
|
||||||
'wss://relay.primal.net'
|
|
||||||
]
|
|
||||||
|
|
||||||
// Fetch the article event
|
// Fetch the article event
|
||||||
const filter = {
|
const filter = {
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ export function dedupeNip51Events(events: NostrEvent[]): NostrEvent[] {
|
|||||||
}
|
}
|
||||||
const unique = Array.from(byId.values())
|
const unique = Array.from(byId.values())
|
||||||
|
|
||||||
|
// Separate web bookmarks (kind:39701) from list-based bookmarks
|
||||||
|
const webBookmarks = unique.filter(e => e.kind === 39701)
|
||||||
|
|
||||||
const bookmarkLists = unique
|
const bookmarkLists = unique
|
||||||
.filter(e => e.kind === 10003 || e.kind === 30001)
|
.filter(e => e.kind === 10003 || e.kind === 30001)
|
||||||
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
|
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
|
||||||
@@ -33,6 +36,8 @@ export function dedupeNip51Events(events: NostrEvent[]): NostrEvent[] {
|
|||||||
const out: NostrEvent[] = []
|
const out: NostrEvent[] = []
|
||||||
if (latestBookmarkList) out.push(latestBookmarkList)
|
if (latestBookmarkList) out.push(latestBookmarkList)
|
||||||
out.push(...setsAndNamedLists)
|
out.push(...setsAndNamedLists)
|
||||||
|
// Add web bookmarks as individual events
|
||||||
|
out.push(...webBookmarks)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,23 @@ export async function collectBookmarksFromEvents(
|
|||||||
if (!latestContent && evt.content && !Helpers.hasHiddenContent(evt)) latestContent = evt.content
|
if (!latestContent && evt.content && !Helpers.hasHiddenContent(evt)) latestContent = evt.content
|
||||||
if (Array.isArray(evt.tags)) allTags = allTags.concat(evt.tags)
|
if (Array.isArray(evt.tags)) allTags = allTags.concat(evt.tags)
|
||||||
|
|
||||||
|
// Handle web bookmarks (kind:39701) as individual bookmarks
|
||||||
|
if (evt.kind === 39701) {
|
||||||
|
publicItemsAll.push({
|
||||||
|
id: evt.id,
|
||||||
|
content: evt.content || '',
|
||||||
|
created_at: evt.created_at || Math.floor(Date.now() / 1000),
|
||||||
|
pubkey: evt.pubkey,
|
||||||
|
kind: evt.kind,
|
||||||
|
tags: evt.tags || [],
|
||||||
|
parsedContent: undefined,
|
||||||
|
type: 'web' as const,
|
||||||
|
isPrivate: false,
|
||||||
|
added_at: evt.created_at || Math.floor(Date.now() / 1000)
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const pub = Helpers.getPublicBookmarks(evt)
|
const pub = Helpers.getPublicBookmarks(evt)
|
||||||
publicItemsAll.push(...processApplesauceBookmarks(pub, activeAccount, false))
|
publicItemsAll.push(...processApplesauceBookmarks(pub, activeAccount, false))
|
||||||
|
|
||||||
@@ -80,9 +97,7 @@ export async function collectBookmarksFromEvents(
|
|||||||
privateItemsAll.push(...processApplesauceBookmarks(manualPrivate, activeAccount, true))
|
privateItemsAll.push(...processApplesauceBookmarks(manualPrivate, activeAccount, true))
|
||||||
Reflect.set(evt, BookmarkHiddenSymbol, manualPrivate)
|
Reflect.set(evt, BookmarkHiddenSymbol, manualPrivate)
|
||||||
Reflect.set(evt, 'EncryptedContentSymbol', decryptedContent)
|
Reflect.set(evt, 'EncryptedContentSymbol', decryptedContent)
|
||||||
if (!latestContent) {
|
// Don't set latestContent to decrypted JSON - it's not user-facing content
|
||||||
latestContent = decryptedContent
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,11 +29,11 @@ export const fetchBookmarks = async (
|
|||||||
}
|
}
|
||||||
// Get relay URLs from the pool
|
// Get relay URLs from the pool
|
||||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||||
// Fetch bookmark events - NIP-51 standards and legacy formats
|
// Fetch bookmark events - NIP-51 standards, legacy formats, and web bookmarks (NIP-B0)
|
||||||
console.log('🔍 Fetching bookmark events from relays:', relayUrls)
|
console.log('🔍 Fetching bookmark events from relays:', relayUrls)
|
||||||
const rawEvents = await lastValueFrom(
|
const rawEvents = await lastValueFrom(
|
||||||
relayPool
|
relayPool
|
||||||
.req(relayUrls, { kinds: [10003, 30003, 30001], authors: [activeAccount.pubkey] })
|
.req(relayUrls, { kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] })
|
||||||
.pipe(completeOnEose(), takeUntil(timer(20000)), toArray())
|
.pipe(completeOnEose(), takeUntil(timer(20000)), toArray())
|
||||||
)
|
)
|
||||||
console.log('📊 Raw events fetched:', rawEvents.length, 'events')
|
console.log('📊 Raw events fetched:', rawEvents.length, 'events')
|
||||||
|
|||||||
50
src/services/contactService.ts
Normal file
50
src/services/contactService.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { RelayPool, completeOnEose } from 'applesauce-relay'
|
||||||
|
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the contact list (follows) for a specific user
|
||||||
|
* @param relayPool - The relay pool to query
|
||||||
|
* @param pubkey - The user's public key
|
||||||
|
* @returns Set of pubkeys that the user follows
|
||||||
|
*/
|
||||||
|
export const fetchContacts = async (
|
||||||
|
relayPool: RelayPool,
|
||||||
|
pubkey: string
|
||||||
|
): Promise<Set<string>> => {
|
||||||
|
try {
|
||||||
|
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||||
|
|
||||||
|
console.log('🔍 Fetching contacts (kind 3) for user:', pubkey)
|
||||||
|
|
||||||
|
const events = await lastValueFrom(
|
||||||
|
relayPool
|
||||||
|
.req(relayUrls, { kinds: [3], authors: [pubkey] })
|
||||||
|
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('📊 Contact events fetched:', events.length)
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
return new Set()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the most recent contact list
|
||||||
|
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
const contactList = sortedEvents[0]
|
||||||
|
|
||||||
|
// Extract pubkeys from 'p' tags
|
||||||
|
const followedPubkeys = new Set<string>()
|
||||||
|
for (const tag of contactList.tags) {
|
||||||
|
if (tag[0] === 'p' && tag[1]) {
|
||||||
|
followedPubkeys.add(tag[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('👥 Followed contacts:', followedPubkeys.size)
|
||||||
|
|
||||||
|
return followedPubkeys
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch contacts:', error)
|
||||||
|
return new Set()
|
||||||
|
}
|
||||||
|
}
|
||||||
205
src/services/highlightCreationService.ts
Normal file
205
src/services/highlightCreationService.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
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'
|
||||||
|
import { RELAYS } from '../config/relays'
|
||||||
|
import { Highlight } from '../types/highlights'
|
||||||
|
import {
|
||||||
|
getHighlightText,
|
||||||
|
getHighlightContext,
|
||||||
|
getHighlightComment,
|
||||||
|
getHighlightSourceEventPointer,
|
||||||
|
getHighlightSourceAddressPointer,
|
||||||
|
getHighlightSourceUrl,
|
||||||
|
getHighlightAttributions
|
||||||
|
} from 'applesauce-core/helpers'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates and publishes a highlight event (NIP-84)
|
||||||
|
* Supports both nostr-native articles and external URLs
|
||||||
|
* Returns the signed event for immediate UI updates
|
||||||
|
*/
|
||||||
|
export async function createHighlight(
|
||||||
|
selectedText: string,
|
||||||
|
source: NostrEvent | string,
|
||||||
|
account: IAccount,
|
||||||
|
relayPool: RelayPool,
|
||||||
|
contentForContext?: string,
|
||||||
|
comment?: string
|
||||||
|
): Promise<NostrEvent> {
|
||||||
|
if (!selectedText || !source) {
|
||||||
|
throw new Error('Missing required data to create highlight')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create EventFactory with the account as signer
|
||||||
|
const factory = new EventFactory({ signer: account })
|
||||||
|
|
||||||
|
let blueprintSource: NostrEvent | AddressPointer | string
|
||||||
|
let context: string | undefined
|
||||||
|
|
||||||
|
// Handle NostrEvent (article) source
|
||||||
|
if (typeof source === 'object' && 'kind' in source) {
|
||||||
|
blueprintSource = parseArticleCoordinate(source)
|
||||||
|
context = extractContext(selectedText, source.content)
|
||||||
|
}
|
||||||
|
// Handle URL string source
|
||||||
|
else {
|
||||||
|
blueprintSource = source
|
||||||
|
// Try to extract context from provided content if available
|
||||||
|
if (contentForContext) {
|
||||||
|
context = extractContext(selectedText, contentForContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create highlight event using the blueprint
|
||||||
|
const highlightEvent = await factory.create(
|
||||||
|
HighlightBlueprint,
|
||||||
|
selectedText,
|
||||||
|
blueprintSource,
|
||||||
|
context ? { comment, context } : comment ? { comment } : undefined
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update the alt tag to identify Boris as the creator
|
||||||
|
const altTagIndex = highlightEvent.tags.findIndex(tag => tag[0] === 'alt')
|
||||||
|
if (altTagIndex !== -1) {
|
||||||
|
highlightEvent.tags[altTagIndex] = ['alt', 'Highlight created by Boris. readwithboris.com']
|
||||||
|
} else {
|
||||||
|
highlightEvent.tags.push(['alt', 'Highlight created by Boris. readwithboris.com'])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign the event
|
||||||
|
const signedEvent = await factory.sign(highlightEvent)
|
||||||
|
|
||||||
|
// Publish to relays (including local relay)
|
||||||
|
await relayPool.publish(RELAYS, signedEvent)
|
||||||
|
|
||||||
|
console.log('✅ Highlight published to', RELAYS.length, 'relays (including local):', signedEvent)
|
||||||
|
|
||||||
|
// Return the signed event for immediate UI updates
|
||||||
|
return 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts context for a highlight by finding the previous and next sentences
|
||||||
|
* in the same paragraph as the selected text
|
||||||
|
*/
|
||||||
|
function extractContext(selectedText: string, articleContent: string): string | undefined {
|
||||||
|
if (!selectedText || !articleContent) return undefined
|
||||||
|
|
||||||
|
// Find the position of the selected text in the article
|
||||||
|
const selectedIndex = articleContent.indexOf(selectedText)
|
||||||
|
if (selectedIndex === -1) return undefined
|
||||||
|
|
||||||
|
// Split content into paragraphs (by double newlines or single newlines)
|
||||||
|
const paragraphs = articleContent.split(/\n\n+/)
|
||||||
|
|
||||||
|
// Find which paragraph contains the selected text
|
||||||
|
let currentPos = 0
|
||||||
|
let containingParagraph: string | undefined
|
||||||
|
|
||||||
|
for (const paragraph of paragraphs) {
|
||||||
|
const paragraphEnd = currentPos + paragraph.length
|
||||||
|
if (selectedIndex >= currentPos && selectedIndex < paragraphEnd) {
|
||||||
|
containingParagraph = paragraph
|
||||||
|
break
|
||||||
|
}
|
||||||
|
currentPos = paragraphEnd + 2 // Account for the double newline
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!containingParagraph) return undefined
|
||||||
|
|
||||||
|
// Split paragraph into sentences (basic sentence splitting)
|
||||||
|
// This regex splits on periods, exclamation marks, or question marks followed by space or end of string
|
||||||
|
const sentences = containingParagraph.split(/([.!?]+\s+)/).filter(s => s.trim().length > 0)
|
||||||
|
|
||||||
|
// Reconstruct sentences properly by joining sentence text with punctuation
|
||||||
|
const reconstructedSentences: string[] = []
|
||||||
|
for (let i = 0; i < sentences.length; i++) {
|
||||||
|
if (sentences[i].match(/^[.!?]+\s*$/)) {
|
||||||
|
// This is punctuation, attach it to previous sentence
|
||||||
|
if (reconstructedSentences.length > 0) {
|
||||||
|
reconstructedSentences[reconstructedSentences.length - 1] += sentences[i]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
reconstructedSentences.push(sentences[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find which sentence contains the selected text
|
||||||
|
let selectedSentenceIndex = -1
|
||||||
|
for (let i = 0; i < reconstructedSentences.length; i++) {
|
||||||
|
if (reconstructedSentences[i].includes(selectedText)) {
|
||||||
|
selectedSentenceIndex = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedSentenceIndex === -1) return undefined
|
||||||
|
|
||||||
|
// Build context from previous and next sentences
|
||||||
|
const contextParts: string[] = []
|
||||||
|
|
||||||
|
// Add previous sentence if it exists
|
||||||
|
if (selectedSentenceIndex > 0) {
|
||||||
|
contextParts.push(reconstructedSentences[selectedSentenceIndex - 1].trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the selected sentence itself
|
||||||
|
contextParts.push(reconstructedSentences[selectedSentenceIndex].trim())
|
||||||
|
|
||||||
|
// Add next sentence if it exists
|
||||||
|
if (selectedSentenceIndex < reconstructedSentences.length - 1) {
|
||||||
|
contextParts.push(reconstructedSentences[selectedSentenceIndex + 1].trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only return context if we have more than just the selected sentence
|
||||||
|
return contextParts.length > 1 ? contextParts.join(' ') : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a NostrEvent to a Highlight object for immediate UI display
|
||||||
|
*/
|
||||||
|
export function eventToHighlight(event: NostrEvent): Highlight {
|
||||||
|
const highlightText = getHighlightText(event)
|
||||||
|
const context = getHighlightContext(event)
|
||||||
|
const comment = getHighlightComment(event)
|
||||||
|
const sourceEventPointer = getHighlightSourceEventPointer(event)
|
||||||
|
const sourceAddressPointer = getHighlightSourceAddressPointer(event)
|
||||||
|
const sourceUrl = getHighlightSourceUrl(event)
|
||||||
|
const attributions = getHighlightAttributions(event)
|
||||||
|
|
||||||
|
const author = attributions.find(a => a.role === 'author')?.pubkey
|
||||||
|
const eventReference = sourceEventPointer?.id ||
|
||||||
|
(sourceAddressPointer ? `${sourceAddressPointer.kind}:${sourceAddressPointer.pubkey}:${sourceAddressPointer.identifier}` : undefined)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
created_at: event.created_at,
|
||||||
|
content: highlightText,
|
||||||
|
tags: event.tags,
|
||||||
|
eventReference,
|
||||||
|
urlReference: sourceUrl,
|
||||||
|
author,
|
||||||
|
context,
|
||||||
|
comment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { RelayPool, completeOnEose } from 'applesauce-relay'
|
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||||
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
|
import { lastValueFrom, takeUntil, timer, tap, toArray } from 'rxjs'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import {
|
import {
|
||||||
getHighlightText,
|
getHighlightText,
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
getHighlightAttributions
|
getHighlightAttributions
|
||||||
} from 'applesauce-core/helpers'
|
} from 'applesauce-core/helpers'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
|
import { RELAYS } from '../config/relays'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deduplicate highlight events by ID
|
* Deduplicate highlight events by ID
|
||||||
@@ -38,28 +39,61 @@ function dedupeHighlights(events: NostrEvent[]): NostrEvent[] {
|
|||||||
export const fetchHighlightsForArticle = async (
|
export const fetchHighlightsForArticle = async (
|
||||||
relayPool: RelayPool,
|
relayPool: RelayPool,
|
||||||
articleCoordinate: string,
|
articleCoordinate: string,
|
||||||
eventId?: string
|
eventId?: string,
|
||||||
|
onHighlight?: (highlight: Highlight) => void
|
||||||
): Promise<Highlight[]> => {
|
): Promise<Highlight[]> => {
|
||||||
try {
|
try {
|
||||||
// Use well-known relays for highlights even if user isn't logged in
|
|
||||||
const highlightRelays = [
|
|
||||||
'wss://relay.damus.io',
|
|
||||||
'wss://nos.lol',
|
|
||||||
'wss://relay.nostr.band',
|
|
||||||
'wss://relay.snort.social',
|
|
||||||
'wss://purplepag.es'
|
|
||||||
]
|
|
||||||
|
|
||||||
console.log('🔍 Fetching highlights (kind 9802) for article:', articleCoordinate)
|
console.log('🔍 Fetching highlights (kind 9802) for article:', articleCoordinate)
|
||||||
console.log('🔍 Event ID:', eventId || 'none')
|
console.log('🔍 Event ID:', eventId || 'none')
|
||||||
console.log('🔍 From relays:', highlightRelays)
|
console.log('🔍 From relays (including local):', RELAYS)
|
||||||
|
|
||||||
|
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
|
// 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(
|
const aTagEvents = await lastValueFrom(
|
||||||
relayPool
|
relayPool
|
||||||
.req(highlightRelays, { kinds: [9802], '#a': [articleCoordinate] })
|
.req(RELAYS, { 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)
|
console.log('📊 Highlights via a-tag:', aTagEvents.length)
|
||||||
@@ -67,11 +101,21 @@ export const fetchHighlightsForArticle = async (
|
|||||||
// If we have an event ID, also query for highlights that reference via the 'e' tag
|
// If we have an event ID, also query for highlights that reference via the 'e' tag
|
||||||
let eTagEvents: NostrEvent[] = []
|
let eTagEvents: NostrEvent[] = []
|
||||||
if (eventId) {
|
if (eventId) {
|
||||||
console.log('🔍 Filter 2 (e-tag):', JSON.stringify({ kinds: [9802], '#e': [eventId] }, null, 2))
|
|
||||||
eTagEvents = await lastValueFrom(
|
eTagEvents = await lastValueFrom(
|
||||||
relayPool
|
relayPool
|
||||||
.req(highlightRelays, { kinds: [9802], '#e': [eventId] })
|
.req(RELAYS, { 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)
|
console.log('📊 Highlights via e-tag:', eTagEvents.length)
|
||||||
}
|
}
|
||||||
@@ -132,33 +176,36 @@ export const fetchHighlightsForArticle = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches highlights created by a specific user
|
* Fetches highlights for a specific URL
|
||||||
* @param relayPool - The relay pool to query
|
* @param relayPool - The relay pool to query
|
||||||
* @param pubkey - The user's public key
|
* @param url - The external URL to find highlights for
|
||||||
*/
|
*/
|
||||||
export const fetchHighlights = async (
|
export const fetchHighlightsForUrl = async (
|
||||||
relayPool: RelayPool,
|
relayPool: RelayPool,
|
||||||
pubkey: string
|
url: string
|
||||||
): Promise<Highlight[]> => {
|
): Promise<Highlight[]> => {
|
||||||
try {
|
try {
|
||||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
console.log('🔍 Fetching highlights (kind 9802) for URL:', url)
|
||||||
|
|
||||||
console.log('🔍 Fetching highlights (kind 9802) by author:', pubkey)
|
|
||||||
|
|
||||||
|
const seenIds = new Set<string>()
|
||||||
const rawEvents = await lastValueFrom(
|
const rawEvents = await lastValueFrom(
|
||||||
relayPool
|
relayPool
|
||||||
.req(relayUrls, { kinds: [9802], authors: [pubkey] })
|
.req(RELAYS, { kinds: [9802], '#r': [url] })
|
||||||
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
|
.pipe(
|
||||||
|
onlyEvents(),
|
||||||
|
tap((event: NostrEvent) => {
|
||||||
|
seenIds.add(event.id)
|
||||||
|
}),
|
||||||
|
completeOnEose(),
|
||||||
|
takeUntil(timer(10000)),
|
||||||
|
toArray()
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
console.log('📊 Raw highlight events fetched:', rawEvents.length)
|
console.log('📊 Highlights for URL:', rawEvents.length)
|
||||||
|
|
||||||
// Deduplicate events by ID
|
|
||||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||||
console.log('📊 Unique highlight events after deduplication:', uniqueEvents.length)
|
|
||||||
|
|
||||||
const highlights: Highlight[] = uniqueEvents.map((event: NostrEvent) => {
|
const highlights: Highlight[] = uniqueEvents.map((event: NostrEvent) => {
|
||||||
// Use applesauce helpers to extract highlight data
|
|
||||||
const highlightText = getHighlightText(event)
|
const highlightText = getHighlightText(event)
|
||||||
const context = getHighlightContext(event)
|
const context = getHighlightContext(event)
|
||||||
const comment = getHighlightComment(event)
|
const comment = getHighlightComment(event)
|
||||||
@@ -167,10 +214,109 @@ export const fetchHighlights = async (
|
|||||||
const sourceUrl = getHighlightSourceUrl(event)
|
const sourceUrl = getHighlightSourceUrl(event)
|
||||||
const attributions = getHighlightAttributions(event)
|
const attributions = getHighlightAttributions(event)
|
||||||
|
|
||||||
// Get author from attributions
|
|
||||||
const author = attributions.find(a => a.role === 'author')?.pubkey
|
const author = attributions.find(a => a.role === 'author')?.pubkey
|
||||||
|
const eventReference = sourceEventPointer?.id ||
|
||||||
|
(sourceAddressPointer ? `${sourceAddressPointer.kind}:${sourceAddressPointer.pubkey}:${sourceAddressPointer.identifier}` : undefined)
|
||||||
|
|
||||||
// Get event reference (prefer event pointer, fallback to address pointer)
|
return {
|
||||||
|
id: event.id,
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
created_at: event.created_at,
|
||||||
|
content: highlightText,
|
||||||
|
tags: event.tags,
|
||||||
|
eventReference,
|
||||||
|
urlReference: sourceUrl,
|
||||||
|
author,
|
||||||
|
context,
|
||||||
|
comment
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return highlights.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch highlights for URL:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
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(
|
||||||
|
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 and process events
|
||||||
|
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||||
|
console.log('📊 Unique highlight events after deduplication:', uniqueEvents.length)
|
||||||
|
|
||||||
|
const highlights: Highlight[] = uniqueEvents.map((event: NostrEvent) => {
|
||||||
|
const highlightText = getHighlightText(event)
|
||||||
|
const context = getHighlightContext(event)
|
||||||
|
const comment = getHighlightComment(event)
|
||||||
|
const sourceEventPointer = getHighlightSourceEventPointer(event)
|
||||||
|
const sourceAddressPointer = getHighlightSourceAddressPointer(event)
|
||||||
|
const sourceUrl = getHighlightSourceUrl(event)
|
||||||
|
const attributions = getHighlightAttributions(event)
|
||||||
|
|
||||||
|
const author = attributions.find(a => a.role === 'author')?.pubkey
|
||||||
const eventReference = sourceEventPointer?.id ||
|
const eventReference = sourceEventPointer?.id ||
|
||||||
(sourceAddressPointer ? `${sourceAddressPointer.kind}:${sourceAddressPointer.pubkey}:${sourceAddressPointer.identifier}` : undefined)
|
(sourceAddressPointer ? `${sourceAddressPointer.kind}:${sourceAddressPointer.pubkey}:${sourceAddressPointer.identifier}` : undefined)
|
||||||
|
|
||||||
|
|||||||
@@ -11,13 +11,21 @@ const SETTINGS_IDENTIFIER = 'com.dergigi.boris.user-settings'
|
|||||||
export interface UserSettings {
|
export interface UserSettings {
|
||||||
collapseOnArticleOpen?: boolean
|
collapseOnArticleOpen?: boolean
|
||||||
defaultViewMode?: 'compact' | 'cards' | 'large'
|
defaultViewMode?: 'compact' | 'cards' | 'large'
|
||||||
showUnderlines?: boolean
|
showHighlights?: boolean
|
||||||
sidebarCollapsed?: boolean
|
sidebarCollapsed?: boolean
|
||||||
highlightsCollapsed?: boolean
|
highlightsCollapsed?: boolean
|
||||||
readingFont?: string
|
readingFont?: string
|
||||||
fontSize?: number
|
fontSize?: number
|
||||||
highlightStyle?: 'marker' | 'underline'
|
highlightStyle?: 'marker' | 'underline'
|
||||||
highlightColor?: string
|
highlightColor?: string
|
||||||
|
// Three-level highlight colors
|
||||||
|
highlightColorNostrverse?: string
|
||||||
|
highlightColorFriends?: string
|
||||||
|
highlightColorMine?: string
|
||||||
|
// Default highlight visibility toggles
|
||||||
|
defaultHighlightVisibilityNostrverse?: boolean
|
||||||
|
defaultHighlightVisibilityFriends?: boolean
|
||||||
|
defaultHighlightVisibilityMine?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadSettings(
|
export async function loadSettings(
|
||||||
@@ -26,10 +34,39 @@ export async function loadSettings(
|
|||||||
pubkey: string,
|
pubkey: string,
|
||||||
relays: string[]
|
relays: string[]
|
||||||
): Promise<UserSettings | null> {
|
): Promise<UserSettings | null> {
|
||||||
|
console.log('⚙️ Loading settings from nostr...', { pubkey: pubkey.slice(0, 8) + '...', relays })
|
||||||
|
|
||||||
|
// First, check if we already have settings in the local event store
|
||||||
|
try {
|
||||||
|
const localEvent = await firstValueFrom(
|
||||||
|
eventStore.replaceable(APP_DATA_KIND, pubkey, SETTINGS_IDENTIFIER)
|
||||||
|
)
|
||||||
|
if (localEvent) {
|
||||||
|
const content = getAppDataContent<UserSettings>(localEvent)
|
||||||
|
console.log('✅ Settings loaded from local store (cached):', content)
|
||||||
|
|
||||||
|
// Still fetch from relays in the background to get any updates
|
||||||
|
relayPool
|
||||||
|
.subscription(relays, {
|
||||||
|
kinds: [APP_DATA_KIND],
|
||||||
|
authors: [pubkey],
|
||||||
|
'#d': [SETTINGS_IDENTIFIER]
|
||||||
|
})
|
||||||
|
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||||
|
.subscribe()
|
||||||
|
|
||||||
|
return content || null
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log('📭 No cached settings found, fetching from relays...')
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not in local store, fetch from relays
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let hasResolved = false
|
let hasResolved = false
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
if (!hasResolved) {
|
if (!hasResolved) {
|
||||||
|
console.warn('⚠️ Settings load timeout - no settings event found')
|
||||||
hasResolved = true
|
hasResolved = true
|
||||||
resolve(null)
|
resolve(null)
|
||||||
}
|
}
|
||||||
@@ -53,16 +90,20 @@ export async function loadSettings(
|
|||||||
)
|
)
|
||||||
if (event) {
|
if (event) {
|
||||||
const content = getAppDataContent<UserSettings>(event)
|
const content = getAppDataContent<UserSettings>(event)
|
||||||
|
console.log('✅ Settings loaded from relays:', content)
|
||||||
resolve(content || null)
|
resolve(content || null)
|
||||||
} else {
|
} else {
|
||||||
|
console.log('📭 No settings event found - using defaults')
|
||||||
resolve(null)
|
resolve(null)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('❌ Error loading settings:', err)
|
||||||
resolve(null)
|
resolve(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: () => {
|
error: (err) => {
|
||||||
|
console.error('❌ Settings subscription error:', err)
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
if (!hasResolved) {
|
if (!hasResolved) {
|
||||||
hasResolved = true
|
hasResolved = true
|
||||||
@@ -84,11 +125,17 @@ export async function saveSettings(
|
|||||||
settings: UserSettings,
|
settings: UserSettings,
|
||||||
relays: string[]
|
relays: string[]
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
console.log('💾 Saving settings to nostr:', settings)
|
||||||
|
|
||||||
const draft = await factory.create(AppDataBlueprint, SETTINGS_IDENTIFIER, settings, false)
|
const draft = await factory.create(AppDataBlueprint, SETTINGS_IDENTIFIER, settings, false)
|
||||||
const signed = await factory.sign(draft)
|
const signed = await factory.sign(draft)
|
||||||
|
|
||||||
|
console.log('📤 Publishing settings event:', signed.id, 'to', relays.length, 'relays')
|
||||||
|
|
||||||
eventStore.add(signed)
|
eventStore.add(signed)
|
||||||
await relayPool.publish(relays, signed)
|
await relayPool.publish(relays, signed)
|
||||||
|
|
||||||
|
console.log('✅ Settings published successfully')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function watchSettings(
|
export function watchSettings(
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export interface IndividualBookmark {
|
|||||||
tags: string[][]
|
tags: string[][]
|
||||||
parsedContent?: ParsedContent
|
parsedContent?: ParsedContent
|
||||||
author?: string
|
author?: string
|
||||||
type: 'event' | 'article'
|
type: 'event' | 'article' | 'web'
|
||||||
isPrivate?: boolean
|
isPrivate?: boolean
|
||||||
encryptedContent?: string
|
encryptedContent?: string
|
||||||
// When the item was added to the bookmark list (synthetic, for sorting)
|
// When the item was added to the bookmark list (synthetic, for sorting)
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
// NIP-84 Highlight types
|
// NIP-84 Highlight types
|
||||||
|
export type HighlightLevel = 'nostrverse' | 'friends' | 'mine'
|
||||||
|
|
||||||
export interface Highlight {
|
export interface Highlight {
|
||||||
id: string
|
id: string
|
||||||
pubkey: string
|
pubkey: string
|
||||||
@@ -11,5 +13,7 @@ export interface Highlight {
|
|||||||
author?: string // 'p' tag with 'author' role
|
author?: string // 'p' tag with 'author' role
|
||||||
context?: string // surrounding text context
|
context?: string // surrounding text context
|
||||||
comment?: string // optional comment about the highlight
|
comment?: string // optional comment about the highlight
|
||||||
|
// Level classification (computed based on user's context)
|
||||||
|
level?: HighlightLevel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ export function hexToRgb(hex: string): string {
|
|||||||
|
|
||||||
export const HIGHLIGHT_COLORS = [
|
export const HIGHLIGHT_COLORS = [
|
||||||
{ name: 'Yellow', value: '#ffff00' },
|
{ name: 'Yellow', value: '#ffff00' },
|
||||||
{ name: 'Orange', value: '#ff9500' },
|
{ name: 'Orange', value: '#f97316' },
|
||||||
{ name: 'Pink', value: '#ff69b4' },
|
{ name: 'Pink', value: '#ff69b4' },
|
||||||
{ name: 'Green', value: '#00ff7f' },
|
{ name: 'Green', value: '#00ff7f' },
|
||||||
{ name: 'Blue', value: '#4da6ff' },
|
{ name: 'Blue', value: '#4da6ff' },
|
||||||
{ name: 'Purple', value: '#b19cd9' }
|
{ name: 'Purple', value: '#9333ea' }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -12,25 +12,83 @@ const FONT_FAMILIES: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadedFonts = new Set<string>()
|
const loadedFonts = new Set<string>()
|
||||||
|
const loadingFonts = new Map<string, Promise<void>>()
|
||||||
|
|
||||||
export function loadFont(fontKey: string) {
|
export async function loadFont(fontKey: string): Promise<void> {
|
||||||
if (fontKey === 'system' || loadedFonts.has(fontKey)) {
|
if (fontKey === 'system') {
|
||||||
return
|
console.log('📝 Using system font')
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadedFonts.has(fontKey)) {
|
||||||
|
console.log('✅ Font already loaded:', fontKey)
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
// If font is currently loading, return the existing promise
|
||||||
|
if (loadingFonts.has(fontKey)) {
|
||||||
|
console.log('⏳ Font already loading:', fontKey)
|
||||||
|
return loadingFonts.get(fontKey)!
|
||||||
}
|
}
|
||||||
|
|
||||||
const fontFamily = FONT_FAMILIES[fontKey]
|
const fontFamily = FONT_FAMILIES[fontKey]
|
||||||
if (!fontFamily) {
|
if (!fontFamily) {
|
||||||
console.warn(`Unknown font: ${fontKey}`)
|
console.warn(`Unknown font: ${fontKey}`)
|
||||||
return
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a link element to load the font from Bunny Fonts
|
console.log('🔤 Loading font:', fontFamily)
|
||||||
const link = document.createElement('link')
|
|
||||||
link.rel = 'stylesheet'
|
|
||||||
link.href = `https://fonts.bunny.net/css?family=${encodeURIComponent(fontFamily.toLowerCase().replace(/ /g, '-'))}:400,400i,700,700i`
|
|
||||||
document.head.appendChild(link)
|
|
||||||
|
|
||||||
loadedFonts.add(fontKey)
|
// Create a promise for this font loading
|
||||||
|
const loadPromise = new Promise<void>((resolve) => {
|
||||||
|
// Create a link element to load the font from Bunny Fonts
|
||||||
|
const link = document.createElement('link')
|
||||||
|
link.rel = 'stylesheet'
|
||||||
|
link.href = `https://fonts.bunny.net/css?family=${encodeURIComponent(fontFamily.toLowerCase().replace(/ /g, '-'))}:400,400i,700,700i`
|
||||||
|
|
||||||
|
// Wait for the stylesheet to load
|
||||||
|
link.onload = () => {
|
||||||
|
console.log('📄 Stylesheet loaded for:', fontFamily)
|
||||||
|
|
||||||
|
// Use Font Loading API to wait for the actual font to be ready
|
||||||
|
if ('fonts' in document) {
|
||||||
|
Promise.all([
|
||||||
|
document.fonts.load(`400 16px "${fontFamily}"`),
|
||||||
|
document.fonts.load(`700 16px "${fontFamily}"`)
|
||||||
|
]).then(() => {
|
||||||
|
console.log('✅ Font ready:', fontFamily)
|
||||||
|
loadedFonts.add(fontKey)
|
||||||
|
loadingFonts.delete(fontKey)
|
||||||
|
resolve()
|
||||||
|
}).catch((err) => {
|
||||||
|
console.warn('⚠️ Font loading failed:', fontFamily, err)
|
||||||
|
loadedFonts.add(fontKey) // Mark as loaded anyway to prevent retries
|
||||||
|
loadingFonts.delete(fontKey)
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Fallback: just wait a bit for older browsers
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('✅ Font assumed ready (no Font Loading API):', fontFamily)
|
||||||
|
loadedFonts.add(fontKey)
|
||||||
|
loadingFonts.delete(fontKey)
|
||||||
|
resolve()
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
link.onerror = () => {
|
||||||
|
console.error('❌ Failed to load font stylesheet:', fontFamily)
|
||||||
|
loadedFonts.add(fontKey) // Mark as loaded to prevent retries
|
||||||
|
loadingFonts.delete(fontKey)
|
||||||
|
resolve() // Resolve anyway so we don't block
|
||||||
|
}
|
||||||
|
|
||||||
|
document.head.appendChild(link)
|
||||||
|
})
|
||||||
|
|
||||||
|
loadingFonts.set(fontKey, loadPromise)
|
||||||
|
return loadPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFontFamily(fontKey: string | undefined): string {
|
export function getFontFamily(fontKey: string | undefined): string {
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ export interface UrlClassification {
|
|||||||
buttonText: string
|
buttonText: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const classifyUrl = (url: string): UrlClassification => {
|
export const classifyUrl = (url: string | undefined): UrlClassification => {
|
||||||
|
if (!url) {
|
||||||
|
return { type: 'article', buttonText: 'READ NOW' }
|
||||||
|
}
|
||||||
const urlLower = url.toLowerCase()
|
const urlLower = url.toLowerCase()
|
||||||
|
|
||||||
// Check for YouTube
|
// Check for YouTube
|
||||||
|
|||||||
@@ -73,11 +73,13 @@ export function applyHighlightsToText(
|
|||||||
|
|
||||||
// Add the highlighted text
|
// Add the highlighted text
|
||||||
const highlightedText = text.substring(match.startIndex, match.endIndex)
|
const highlightedText = text.substring(match.startIndex, match.endIndex)
|
||||||
|
const levelClass = match.highlight.level ? ` level-${match.highlight.level}` : ''
|
||||||
result.push(
|
result.push(
|
||||||
<mark
|
<mark
|
||||||
key={`highlight-${match.highlight.id}-${match.startIndex}`}
|
key={`highlight-${match.highlight.id}-${match.startIndex}`}
|
||||||
className="content-highlight"
|
className={`content-highlight${levelClass}`}
|
||||||
data-highlight-id={match.highlight.id}
|
data-highlight-id={match.highlight.id}
|
||||||
|
data-highlight-level={match.highlight.level || 'nostrverse'}
|
||||||
title={`Highlighted ${new Date(match.highlight.created_at * 1000).toLocaleDateString()}`}
|
title={`Highlighted ${new Date(match.highlight.created_at * 1000).toLocaleDateString()}`}
|
||||||
>
|
>
|
||||||
{highlightedText}
|
{highlightedText}
|
||||||
@@ -101,8 +103,10 @@ const normalizeWhitespace = (str: string) => str.replace(/\s+/g, ' ').trim()
|
|||||||
// Helper to create a mark element for a highlight
|
// Helper to create a mark element for a highlight
|
||||||
function createMarkElement(highlight: Highlight, matchText: string, highlightStyle: 'marker' | 'underline' = 'marker'): HTMLElement {
|
function createMarkElement(highlight: Highlight, matchText: string, highlightStyle: 'marker' | 'underline' = 'marker'): HTMLElement {
|
||||||
const mark = document.createElement('mark')
|
const mark = document.createElement('mark')
|
||||||
mark.className = `content-highlight-${highlightStyle}`
|
const levelClass = highlight.level ? ` level-${highlight.level}` : ''
|
||||||
|
mark.className = `content-highlight-${highlightStyle}${levelClass}`
|
||||||
mark.setAttribute('data-highlight-id', highlight.id)
|
mark.setAttribute('data-highlight-id', highlight.id)
|
||||||
|
mark.setAttribute('data-highlight-level', highlight.level || 'nostrverse')
|
||||||
mark.setAttribute('title', `Highlighted ${new Date(highlight.created_at * 1000).toLocaleDateString()}`)
|
mark.setAttribute('title', `Highlighted ${new Date(highlight.created_at * 1000).toLocaleDateString()}`)
|
||||||
mark.textContent = matchText
|
mark.textContent = matchText
|
||||||
return mark
|
return mark
|
||||||
@@ -140,8 +144,6 @@ function tryMarkInTextNodes(
|
|||||||
|
|
||||||
if (index === -1) continue
|
if (index === -1) continue
|
||||||
|
|
||||||
console.log(`✅ Found ${useNormalized ? 'normalized' : 'exact'} match:`, text.slice(0, 50))
|
|
||||||
|
|
||||||
let actualIndex = index
|
let actualIndex = index
|
||||||
if (useNormalized) {
|
if (useNormalized) {
|
||||||
// Map normalized index back to original text
|
// Map normalized index back to original text
|
||||||
@@ -168,14 +170,26 @@ function tryMarkInTextNodes(
|
|||||||
* Apply highlights to HTML content by injecting mark tags using DOM manipulation
|
* Apply highlights to HTML content by injecting mark tags using DOM manipulation
|
||||||
*/
|
*/
|
||||||
export function applyHighlightsToHTML(html: string, highlights: Highlight[], highlightStyle: 'marker' | 'underline' = 'marker'): string {
|
export function applyHighlightsToHTML(html: string, highlights: Highlight[], highlightStyle: 'marker' | 'underline' = 'marker'): string {
|
||||||
if (!html || highlights.length === 0) return html
|
if (!html || highlights.length === 0) {
|
||||||
|
console.log('⚠️ applyHighlightsToHTML: No HTML or highlights', { htmlLength: html?.length, highlightsCount: highlights.length })
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🔨 applyHighlightsToHTML: Processing', highlights.length, 'highlights')
|
||||||
|
|
||||||
const tempDiv = document.createElement('div')
|
const tempDiv = document.createElement('div')
|
||||||
tempDiv.innerHTML = html
|
tempDiv.innerHTML = html
|
||||||
|
|
||||||
|
let appliedCount = 0
|
||||||
|
|
||||||
for (const highlight of highlights) {
|
for (const highlight of highlights) {
|
||||||
const searchText = highlight.content.trim()
|
const searchText = highlight.content.trim()
|
||||||
if (!searchText) continue
|
if (!searchText) {
|
||||||
|
console.warn('⚠️ Empty highlight content:', highlight.id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🔍 Searching for highlight:', searchText.substring(0, 50) + '...')
|
||||||
|
|
||||||
// Collect all text nodes
|
// Collect all text nodes
|
||||||
const walker = document.createTreeWalker(tempDiv, NodeFilter.SHOW_TEXT, null)
|
const walker = document.createTreeWalker(tempDiv, NodeFilter.SHOW_TEXT, null)
|
||||||
@@ -183,10 +197,21 @@ export function applyHighlightsToHTML(html: string, highlights: Highlight[], hig
|
|||||||
let node: Node | null
|
let node: Node | null
|
||||||
while ((node = walker.nextNode())) textNodes.push(node as Text)
|
while ((node = walker.nextNode())) textNodes.push(node as Text)
|
||||||
|
|
||||||
|
console.log('📄 Found', textNodes.length, 'text nodes to search')
|
||||||
|
|
||||||
// Try exact match first, then normalized match
|
// Try exact match first, then normalized match
|
||||||
tryMarkInTextNodes(textNodes, searchText, highlight, false, highlightStyle) ||
|
const found = tryMarkInTextNodes(textNodes, searchText, highlight, false, highlightStyle) ||
|
||||||
tryMarkInTextNodes(textNodes, searchText, highlight, true, highlightStyle)
|
tryMarkInTextNodes(textNodes, searchText, highlight, true, highlightStyle)
|
||||||
|
|
||||||
|
if (found) {
|
||||||
|
appliedCount++
|
||||||
|
console.log('✅ Highlight applied successfully')
|
||||||
|
} else {
|
||||||
|
console.warn('❌ Could not find match for highlight:', searchText.substring(0, 50))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('🎉 Applied', appliedCount, '/', highlights.length, 'highlights')
|
||||||
|
|
||||||
return tempDiv.innerHTML
|
return tempDiv.innerHTML
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,22 +10,43 @@ export function normalizeUrl(url: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function filterHighlightsByUrl(highlights: Highlight[], selectedUrl: string | undefined): Highlight[] {
|
export function filterHighlightsByUrl(highlights: Highlight[], selectedUrl: string | undefined): Highlight[] {
|
||||||
if (!selectedUrl || highlights.length === 0) return []
|
if (!selectedUrl || highlights.length === 0) {
|
||||||
|
console.log('🔍 filterHighlightsByUrl: No URL or highlights', { selectedUrl, count: highlights.length })
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🔍 filterHighlightsByUrl:', { selectedUrl, totalHighlights: highlights.length })
|
||||||
|
|
||||||
// For Nostr articles, we already fetched highlights specifically for this article
|
// For Nostr articles, we already fetched highlights specifically for this article
|
||||||
// So we don't need to filter them - they're all relevant
|
// So we don't need to filter them - they're all relevant
|
||||||
if (selectedUrl.startsWith('nostr:')) {
|
if (selectedUrl.startsWith('nostr:')) {
|
||||||
|
console.log('📌 Nostr article - returning all', highlights.length, 'highlights')
|
||||||
return highlights
|
return highlights
|
||||||
}
|
}
|
||||||
|
|
||||||
// For web URLs, filter by URL matching
|
// For web URLs, filter by URL matching
|
||||||
const normalizedSelected = normalizeUrl(selectedUrl)
|
const normalizedSelected = normalizeUrl(selectedUrl)
|
||||||
|
console.log('🔗 Normalized selected URL:', normalizedSelected)
|
||||||
|
|
||||||
return highlights.filter(h => {
|
const filtered = highlights.filter(h => {
|
||||||
if (!h.urlReference) return false
|
if (!h.urlReference) {
|
||||||
|
console.log('⚠️ Highlight has no urlReference:', h.id, 'eventReference:', h.eventReference)
|
||||||
|
return false
|
||||||
|
}
|
||||||
const normalizedRef = normalizeUrl(h.urlReference)
|
const normalizedRef = normalizeUrl(h.urlReference)
|
||||||
return normalizedSelected === normalizedRef ||
|
const matches = normalizedSelected === normalizedRef ||
|
||||||
normalizedSelected.includes(normalizedRef) ||
|
normalizedSelected.includes(normalizedRef) ||
|
||||||
normalizedRef.includes(normalizedSelected)
|
normalizedRef.includes(normalizedSelected)
|
||||||
|
|
||||||
|
if (matches) {
|
||||||
|
console.log('✅ URL match:', normalizedRef)
|
||||||
|
} else {
|
||||||
|
console.log('❌ URL mismatch:', normalizedRef, 'vs', normalizedSelected)
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches
|
||||||
})
|
})
|
||||||
|
|
||||||
|
console.log('📊 Filtered to', filtered.length, 'highlights')
|
||||||
|
return filtered
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user