mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
---
|
||||
|
||||
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.
|
||||
206
README.md
206
README.md
@@ -1,187 +1,57 @@
|
||||
# Boris
|
||||
|
||||
A minimal nostr client for bookmark management, built with [applesauce](https://github.com/hzrd149/applesauce).
|
||||
Your reading list for the Nostr world.
|
||||
|
||||
## Features
|
||||
Boris turns your Nostr bookmarks into a calm, fast, and focused reading experience. Connect your Nostr account and you’ll get a clean three‑pane reader: bookmarks on the left, the article in the middle, and highlights on the right.
|
||||
|
||||
- **Nostr Authentication**: Connect using your nostr account via browser extension
|
||||
- **Bookmark Display**: View your nostr bookmarks as per [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md)
|
||||
- **Content Classification**: Automatically detect and classify URLs (articles, videos, YouTube, images)
|
||||
- **Reader Mode**: View article content inline with readable formatting
|
||||
- **Collapsible Sidebar**: Expand/collapse bookmark list for focused reading
|
||||
- **Profile Integration**: Display user profile images using applesauce ProfileModel
|
||||
- **Relative Timestamps**: Human-friendly time display (e.g., "2 hours ago")
|
||||
- **Event Links**: Quick access to view bookmarks on search.dergigi.com
|
||||
- **Private Bookmarks**: Support for Amethyst-style hidden/encrypted bookmarks
|
||||
- **Highlights Panel**: View and manage your NIP-84 highlights in a dedicated collapsible panel
|
||||
- **Three-Pane Layout**: Bookmarks sidebar, content viewer, and highlights panel working together
|
||||
- **Minimal UI**: Clean, modern interface focused on bookmark management
|
||||
## What Boris does
|
||||
|
||||
## Getting Started
|
||||
- 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
|
||||
|
||||
### Prerequisites
|
||||
## How it works
|
||||
|
||||
- Node.js 18+
|
||||
- npm, pnpm, or yarn
|
||||
1. Connect your Nostr account.
|
||||
- Click “Connect” and approve with your usual Nostr signer.
|
||||
2. Browse your bookmarks.
|
||||
- Your lists and items appear on the left. Pick anything to read.
|
||||
3. Read in comfort.
|
||||
- The center panel renders a readable article view with images and headings.
|
||||
4. See what people highlighted.
|
||||
- The right panel shows highlights by level:
|
||||
- Mine (your highlights)
|
||||
- Friends (people you follow)
|
||||
- Nostrverse (everyone else)
|
||||
- Each level has its own color. Click any highlight to jump to that spot.
|
||||
5. Focus when you want.
|
||||
- Collapse one or both side panels. The layout adapts without wasting space.
|
||||
|
||||
### Installation
|
||||
## Why people like Boris
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone <your-repo-url>
|
||||
cd boris
|
||||
```
|
||||
- No noise: Just your saved links and the best excerpts others found
|
||||
- Fast by default: Opens instantly in your browser
|
||||
- Portable: Works with any Nostr account; your data travels with you
|
||||
- Designed for reading: Smooth navigation and instant scroll‑to‑highlight
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
# or
|
||||
pnpm install
|
||||
# or
|
||||
yarn install
|
||||
```
|
||||
## Tips
|
||||
|
||||
3. Start the development server:
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
yarn dev
|
||||
```
|
||||
- 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.
|
||||
|
||||
4. Open your browser and navigate to `http://localhost:3000`
|
||||
## Privacy and data
|
||||
|
||||
## Usage
|
||||
- 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.
|
||||
|
||||
1. **Connect**: Click "Connect with Nostr" to authenticate using your nostr account
|
||||
2. **View Bookmarks**: Once connected, you'll see all your nostr bookmarks in the left sidebar
|
||||
3. **View Highlights**: Your NIP-84 highlights appear in the right panel
|
||||
4. **Navigate**: Click on bookmark URLs to view content in the center panel
|
||||
5. **Collapse Panels**: Use the collapse buttons to hide/show sidebars for focused viewing
|
||||
## Troubleshooting
|
||||
|
||||
## Technical Details
|
||||
|
||||
- Built with React and TypeScript
|
||||
- Uses [applesauce-core](https://github.com/hzrd149/applesauce) for nostr functionality
|
||||
- Implements [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md) for bookmark management
|
||||
- Supports both individual bookmarks and bookmark lists
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
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)
|
||||
|
||||
We support Amethyst-style private (hidden) bookmark lists alongside public ones (NIP‑51):
|
||||
|
||||
- **Detection and unlock**
|
||||
- Use `Helpers.hasHiddenTags(evt)` and `Helpers.isHiddenTagsLocked(evt)` to detect hidden tags.
|
||||
- First try `Helpers.unlockHiddenTags(evt, signer)`; if that fails, try with `'nip44'`.
|
||||
- For events with encrypted `content` that aren’t recognized as supporting hidden tags (e.g. kind 30001), manually decrypt:
|
||||
- Prefer `signer.nip44.decrypt(evt.pubkey, evt.content)`, fallback to `signer.nip04.decrypt(evt.pubkey, evt.content)`.
|
||||
|
||||
- **Parsing and rendering**
|
||||
- Decrypted `content` is JSON `string[][]` (tags). Convert with `Helpers.parseBookmarkTags(hiddenTags)`.
|
||||
- Map to `IndividualBookmark[]` via our `processApplesauceBookmarks(..., isPrivate=true)` and append to the private list so they render immediately alongside public items.
|
||||
|
||||
- **Caching for downstream helpers**
|
||||
- Cache manual results on the event with `BookmarkHiddenSymbol` and also store the decrypted blob under `EncryptedContentSymbol` to aid debugging and hydration.
|
||||
|
||||
- **Structure**
|
||||
- `src/services/bookmarkService.ts`: orchestrates fetching, hydration, and assembling the final bookmark payload.
|
||||
- `src/services/bookmarkProcessing.ts`: decryption/collection pipeline (unlock, manual decrypt, parse, merge).
|
||||
- `src/services/bookmarkHelpers.ts`: shared types, guards, mapping, hydration, and symbols.
|
||||
- `src/services/bookmarkEvents.ts`: event type and de‑duplication for NIP‑51 lists/sets.
|
||||
|
||||
- **Notes**
|
||||
- We avoid `any` via narrow type guards for `nip44`/`nip04` decrypt functions.
|
||||
- Files are kept small and DRY per project rules.
|
||||
- Built on applesauce helpers (`Helpers.getPublicBookmarks`, `Helpers.getHiddenBookmarks`, etc.). See applesauce docs: https://hzrd149.github.io/applesauce/typedoc/modules.html
|
||||
|
||||
### Building for Production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
# or
|
||||
pnpm build
|
||||
# or
|
||||
yarn build
|
||||
```
|
||||
|
||||
## TODO
|
||||
|
||||
### High Priority
|
||||
- [ ] **Mobile Responsive Design**: Optimize sidebar and content panel for mobile devices
|
||||
- [ ] **Keyboard Shortcuts**: Add keyboard navigation (collapse sidebar, navigate bookmarks)
|
||||
- [ ] **Search & Filter**: Add ability to search bookmarks by title, URL, or content
|
||||
- [ ] **Error Handling**: Improve error states and retry logic for failed fetches
|
||||
- [ ] **Loading States**: Better skeleton screens and loading indicators
|
||||
|
||||
### Medium Priority
|
||||
- [ ] **Bookmark Creation**: Add ability to create new bookmarks
|
||||
- [ ] **Bookmark Editing**: Edit existing bookmark metadata and tags
|
||||
- [ ] **Bookmark Deletion**: Remove bookmarks from lists
|
||||
- [ ] **Sorting Options**: Sort by date, title, kind, or custom order
|
||||
- [ ] **Bulk Actions**: Select and perform actions on multiple bookmarks
|
||||
- [ ] **Video Embeds**: Inline YouTube and video playback for video bookmarks
|
||||
|
||||
### Nice to Have
|
||||
- [ ] **Dark/Light Mode Toggle**: User preference for color scheme
|
||||
- [ ] **Export Functionality**: Export bookmarks as JSON, CSV, or HTML
|
||||
- [ ] **Import Bookmarks**: Import from browser bookmarks or other formats
|
||||
- [ ] **Tags & Categories**: Better organization with custom tags
|
||||
- [ ] **Bookmark Collections**: Create and manage custom bookmark collections
|
||||
- [ ] **Offline Support**: Cache bookmarks for offline viewing
|
||||
- [ ] **Share Bookmarks**: Generate shareable links to bookmark lists
|
||||
- [ ] **Performance Optimization**: Virtual scrolling for large bookmark lists
|
||||
- [ ] **Browser Extension**: Quick bookmark saving from any page
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request. Make sure to:
|
||||
|
||||
- Follow the existing code style
|
||||
- Keep files under 210 lines
|
||||
- Use conventional commits
|
||||
- Run linter and type checks before submitting
|
||||
- If something looks empty, try opening another article and coming back — network data can arrive in bursts.
|
||||
- Not every article has highlights yet; they grow as the community reads.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
|
||||
4
dist/index.html
vendored
4
dist/index.html
vendored
@@ -5,8 +5,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Boris - Nostr Bookmarks</title>
|
||||
<script type="module" crossorigin src="/assets/index-8PiwZoBK.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Dljx1pJR.css">
|
||||
<script type="module" crossorigin src="/assets/index--wClm1wz.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bj-Uhit8.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.1.8",
|
||||
"version": "0.2.0",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
29
src/App.tsx
29
src/App.tsx
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||
import { EventStoreProvider, AccountsProvider } from 'applesauce-react'
|
||||
import { EventStore } from 'applesauce-core'
|
||||
import { AccountManager } from 'applesauce-accounts'
|
||||
@@ -8,6 +10,8 @@ import { RelayPool } from 'applesauce-relay'
|
||||
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
||||
import Login from './components/Login'
|
||||
import Bookmarks from './components/Bookmarks'
|
||||
import Toast from './components/Toast'
|
||||
import { useToast } from './hooks/useToast'
|
||||
|
||||
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
||||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
||||
@@ -16,6 +20,7 @@ function App() {
|
||||
const [eventStore, setEventStore] = useState<EventStore | null>(null)
|
||||
const [accountManager, setAccountManager] = useState<AccountManager | null>(null)
|
||||
const [relayPool, setRelayPool] = useState<RelayPool | null>(null)
|
||||
const { toastMessage, toastType, showToast, clearToast } = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize event store, account manager, and relay pool
|
||||
@@ -104,7 +109,11 @@ function App() {
|
||||
}, [])
|
||||
|
||||
if (!eventStore || !accountManager || !relayPool) {
|
||||
return <div>Loading...</div>
|
||||
return (
|
||||
<div className="loading">
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -118,15 +127,29 @@ function App() {
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={() => {}}
|
||||
onLogout={() => {
|
||||
if (accountManager) {
|
||||
accountManager.setActive(undefined as never)
|
||||
localStorage.removeItem('active')
|
||||
showToast('Logged out successfully')
|
||||
console.log('Logged out')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
|
||||
<Route path="/login" element={<Login onLogin={() => {}} />} />
|
||||
<Route path="/login" element={<Login onLogin={() => showToast('Logged in successfully')} />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
{toastMessage && (
|
||||
<Toast
|
||||
message={toastMessage}
|
||||
type={toastType}
|
||||
onClose={clearToast}
|
||||
/>
|
||||
)}
|
||||
</AccountsProvider>
|
||||
</EventStoreProvider>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronLeft, faBookmark } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Bookmark } from '../types/bookmarks'
|
||||
import { BookmarkItem } from './BookmarkItem'
|
||||
import { formatDate, renderParsedContent } from '../utils/bookmarkUtils'
|
||||
import SidebarHeader from './SidebarHeader'
|
||||
import IconButton from './IconButton'
|
||||
import { ViewMode } from './Bookmarks'
|
||||
|
||||
interface BookmarkListProps {
|
||||
@@ -17,6 +18,9 @@ interface BookmarkListProps {
|
||||
onViewModeChange: (mode: ViewMode) => void
|
||||
selectedUrl?: string
|
||||
onOpenSettings: () => void
|
||||
onRefresh?: () => void
|
||||
isRefreshing?: boolean
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
@@ -28,7 +32,10 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
selectedUrl,
|
||||
onOpenSettings
|
||||
onOpenSettings,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
loading = false
|
||||
}) => {
|
||||
if (isCollapsed) {
|
||||
// Check if the selected URL is in bookmarks
|
||||
@@ -57,12 +64,16 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
<SidebarHeader
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
onLogout={onLogout}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={onViewModeChange}
|
||||
onOpenSettings={onOpenSettings}
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
/>
|
||||
|
||||
{bookmarks.length === 0 ? (
|
||||
{loading ? (
|
||||
<div className="loading">
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
</div>
|
||||
) : bookmarks.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No bookmarks found.</p>
|
||||
<p>Add bookmarks using your nostr client to see them here.</p>
|
||||
@@ -139,6 +150,29 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="view-mode-controls">
|
||||
<IconButton
|
||||
icon={faList}
|
||||
onClick={() => onViewModeChange('compact')}
|
||||
title="Compact list view"
|
||||
ariaLabel="Compact list view"
|
||||
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faThLarge}
|
||||
onClick={() => onViewModeChange('cards')}
|
||||
title="Cards view"
|
||||
ariaLabel="Cards view"
|
||||
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faImage}
|
||||
onClick={() => onViewModeChange('large')}
|
||||
title="Large preview view"
|
||||
ariaLabel="Large preview view"
|
||||
variant={viewMode === 'large' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { useEventStore } from 'applesauce-react/hooks'
|
||||
@@ -8,6 +8,7 @@ import { Highlight } from '../types/highlights'
|
||||
import { BookmarkList } from './BookmarkList'
|
||||
import { fetchBookmarks } from '../services/bookmarkService'
|
||||
import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService'
|
||||
import { fetchContacts } from '../services/contactService'
|
||||
import ContentPanel from './ContentPanel'
|
||||
import { HighlightsPanel } from './HighlightsPanel'
|
||||
import { ReadableContent } from '../services/readerService'
|
||||
@@ -16,7 +17,11 @@ import Toast from './Toast'
|
||||
import { useSettings } from '../hooks/useSettings'
|
||||
import { useArticleLoader } from '../hooks/useArticleLoader'
|
||||
import { loadContent, BookmarkReference } from '../utils/contentLoader'
|
||||
import { HighlightMode } from './HighlightsPanel'
|
||||
import { HighlightVisibility } from './HighlightsPanel'
|
||||
import { HighlightButton, HighlightButtonRef } from './HighlightButton'
|
||||
import { createHighlight } from '../services/highlightCreationService'
|
||||
import { useRef, useCallback } from 'react'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
export type ViewMode = 'compact' | 'cards' | 'large'
|
||||
|
||||
interface BookmarksProps {
|
||||
@@ -27,6 +32,7 @@ interface BookmarksProps {
|
||||
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
const { naddr } = useParams<{ naddr?: string }>()
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||
const [bookmarksLoading, setBookmarksLoading] = useState(true)
|
||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||
const [highlightsLoading, setHighlightsLoading] = useState(true)
|
||||
const [selectedUrl, setSelectedUrl] = useState<string | undefined>(undefined)
|
||||
@@ -35,15 +41,23 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState(true) // Start collapsed
|
||||
const [isHighlightsCollapsed, setIsHighlightsCollapsed] = useState(true) // Start collapsed
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('compact')
|
||||
const [showUnderlines, setShowUnderlines] = useState(true)
|
||||
const [showHighlights, setShowHighlights] = useState(true)
|
||||
const [selectedHighlightId, setSelectedHighlightId] = useState<string | undefined>(undefined)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [currentArticleCoordinate, setCurrentArticleCoordinate] = useState<string | undefined>(undefined)
|
||||
const [currentArticleEventId, setCurrentArticleEventId] = useState<string | undefined>(undefined)
|
||||
const [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 accountManager = Hooks.useAccountManager()
|
||||
const eventStore = useEventStore()
|
||||
const highlightButtonRef = useRef<HighlightButtonRef>(null)
|
||||
|
||||
const { settings, saveSettings, toastMessage, toastType, clearToast } = useSettings({
|
||||
relayPool,
|
||||
@@ -60,32 +74,48 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
setIsCollapsed,
|
||||
setIsHighlightsCollapsed,
|
||||
setHighlights,
|
||||
setHighlightsLoading,
|
||||
setCurrentArticleCoordinate,
|
||||
setCurrentArticleEventId
|
||||
setCurrentArticleEventId,
|
||||
setCurrentArticle
|
||||
})
|
||||
|
||||
// Load initial data on login
|
||||
useEffect(() => {
|
||||
if (!relayPool || !activeAccount) return
|
||||
handleFetchBookmarks()
|
||||
handleFetchHighlights()
|
||||
// Avoid overwriting article-specific highlights during initial article load
|
||||
// If an article is being viewed (naddr present), let useArticleLoader own the first highlights set
|
||||
if (!naddr) {
|
||||
handleFetchHighlights()
|
||||
}
|
||||
handleFetchContacts()
|
||||
}, [relayPool, activeAccount?.pubkey])
|
||||
|
||||
const handleFetchContacts = async () => {
|
||||
if (!relayPool || !activeAccount) return
|
||||
const contacts = await fetchContacts(relayPool, activeAccount.pubkey)
|
||||
setFollowedPubkeys(contacts)
|
||||
}
|
||||
|
||||
// Apply UI settings
|
||||
useEffect(() => {
|
||||
if (settings.defaultViewMode) setViewMode(settings.defaultViewMode)
|
||||
if (settings.showUnderlines !== undefined) setShowUnderlines(settings.showUnderlines)
|
||||
if (settings.sidebarCollapsed !== undefined) setIsCollapsed(settings.sidebarCollapsed)
|
||||
if (settings.highlightsCollapsed !== undefined) setIsHighlightsCollapsed(settings.highlightsCollapsed)
|
||||
if (settings.showHighlights !== undefined) setShowHighlights(settings.showHighlights)
|
||||
// Always start with both panels collapsed on initial load
|
||||
// Don't apply saved collapse settings on initial load - let user control them
|
||||
}, [settings])
|
||||
|
||||
const handleFetchBookmarks = async () => {
|
||||
if (!relayPool || !activeAccount) return
|
||||
const fullAccount = accountManager.getActive()
|
||||
await fetchBookmarks(relayPool, fullAccount || activeAccount, setBookmarks)
|
||||
setBookmarksLoading(true)
|
||||
try {
|
||||
const fullAccount = accountManager.getActive()
|
||||
await fetchBookmarks(relayPool, fullAccount || activeAccount, setBookmarks)
|
||||
} finally {
|
||||
setBookmarksLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFetchHighlights = async () => {
|
||||
@@ -95,13 +125,18 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
try {
|
||||
// If we're viewing an article, fetch highlights for that article
|
||||
if (currentArticleCoordinate) {
|
||||
const fetchedHighlights = await fetchHighlightsForArticle(
|
||||
const highlightsList: Highlight[] = []
|
||||
await fetchHighlightsForArticle(
|
||||
relayPool,
|
||||
currentArticleCoordinate,
|
||||
currentArticleEventId
|
||||
currentArticleEventId,
|
||||
(highlight) => {
|
||||
// Render each highlight immediately as it arrives
|
||||
highlightsList.push(highlight)
|
||||
setHighlights([...highlightsList].sort((a, b) => b.created_at - a.created_at))
|
||||
}
|
||||
)
|
||||
console.log(`🔄 Refreshed ${fetchedHighlights.length} highlights for article`)
|
||||
setHighlights(fetchedHighlights)
|
||||
console.log(`🔄 Refreshed ${highlightsList.length} highlights for article`)
|
||||
}
|
||||
// Otherwise, if logged in, fetch user's own highlights
|
||||
else if (activeAccount) {
|
||||
@@ -115,18 +150,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) => {
|
||||
if (!relayPool) return
|
||||
|
||||
setSelectedUrl(url)
|
||||
setReaderLoading(true)
|
||||
setReaderContent(undefined)
|
||||
setCurrentArticle(undefined) // Clear previous article
|
||||
setShowSettings(false)
|
||||
if (settings.collapseOnArticleOpen !== false) setIsCollapsed(true)
|
||||
|
||||
try {
|
||||
const content = await loadContent(url, relayPool, bookmark)
|
||||
setReaderContent(content)
|
||||
|
||||
// Note: currentArticle is set by useArticleLoader when loading Nostr articles
|
||||
// For web bookmarks, there's no article event to set
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch content:', err)
|
||||
} finally {
|
||||
@@ -134,6 +201,54 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleHighlightCreated = async () => {
|
||||
// Refresh highlights after creating a new one
|
||||
if (!relayPool || !currentArticleCoordinate) return
|
||||
|
||||
try {
|
||||
const newHighlights = await fetchHighlightsForArticle(
|
||||
relayPool,
|
||||
currentArticleCoordinate,
|
||||
currentArticleEventId
|
||||
)
|
||||
setHighlights(newHighlights)
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh highlights:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTextSelection = useCallback((text: string) => {
|
||||
highlightButtonRef.current?.updateSelection(text)
|
||||
}, [])
|
||||
|
||||
const handleClearSelection = useCallback(() => {
|
||||
highlightButtonRef.current?.clearSelection()
|
||||
}, [])
|
||||
|
||||
const handleCreateHighlight = useCallback(async (text: string) => {
|
||||
if (!activeAccount || !relayPool || !currentArticle) {
|
||||
console.error('Missing requirements for highlight creation')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await createHighlight(
|
||||
text,
|
||||
currentArticle,
|
||||
activeAccount,
|
||||
relayPool
|
||||
)
|
||||
|
||||
console.log('✅ Highlight created successfully!')
|
||||
highlightButtonRef.current?.clearSelection()
|
||||
|
||||
// Trigger refresh of highlights
|
||||
handleHighlightCreated()
|
||||
} catch (error) {
|
||||
console.error('Failed to create highlight:', error)
|
||||
}
|
||||
}, [activeAccount, relayPool, currentArticle, handleHighlightCreated])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`three-pane ${isCollapsed ? 'sidebar-collapsed' : ''} ${isHighlightsCollapsed ? 'highlights-collapsed' : ''}`}>
|
||||
@@ -152,6 +267,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
setIsCollapsed(true)
|
||||
setIsHighlightsCollapsed(true)
|
||||
}}
|
||||
onRefresh={handleRefreshBookmarks}
|
||||
isRefreshing={isRefreshing}
|
||||
loading={bookmarksLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="pane main">
|
||||
@@ -169,8 +287,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
markdown={readerContent?.markdown}
|
||||
image={readerContent?.image}
|
||||
selectedUrl={selectedUrl}
|
||||
highlights={highlights}
|
||||
showUnderlines={showUnderlines}
|
||||
highlights={classifiedHighlights}
|
||||
showHighlights={showHighlights}
|
||||
highlightStyle={settings.highlightStyle || 'marker'}
|
||||
highlightColor={settings.highlightColor || '#ffff00'}
|
||||
onHighlightClick={(id) => {
|
||||
@@ -178,6 +296,11 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
if (isHighlightsCollapsed) setIsHighlightsCollapsed(false)
|
||||
}}
|
||||
selectedHighlightId={selectedHighlightId}
|
||||
highlightVisibility={highlightVisibility}
|
||||
onTextSelection={handleTextSelection}
|
||||
onClearSelection={handleClearSelection}
|
||||
currentUserPubkey={activeAccount?.pubkey}
|
||||
followedPubkeys={followedPubkeys}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -189,16 +312,24 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
onToggleCollapse={() => setIsHighlightsCollapsed(!isHighlightsCollapsed)}
|
||||
onSelectUrl={handleSelectUrl}
|
||||
selectedUrl={selectedUrl}
|
||||
onToggleUnderlines={setShowUnderlines}
|
||||
onToggleHighlights={setShowHighlights}
|
||||
selectedHighlightId={selectedHighlightId}
|
||||
onRefresh={handleFetchHighlights}
|
||||
onHighlightClick={setSelectedHighlightId}
|
||||
currentUserPubkey={activeAccount?.pubkey}
|
||||
highlightMode={highlightMode}
|
||||
onHighlightModeChange={setHighlightMode}
|
||||
highlightVisibility={highlightVisibility}
|
||||
onHighlightVisibilityChange={setHighlightVisibility}
|
||||
followedPubkeys={followedPubkeys}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{activeAccount && relayPool && (
|
||||
<HighlightButton
|
||||
ref={highlightButtonRef}
|
||||
onHighlight={handleCreateHighlight}
|
||||
highlightColor={settings.highlightColor || '#ffff00'}
|
||||
/>
|
||||
)}
|
||||
{toastMessage && (
|
||||
<Toast
|
||||
message={toastMessage}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import React, { useMemo, useEffect, useRef, useState } from 'react'
|
||||
import React, { useMemo, useEffect, useRef, useState, useCallback } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner, faHighlighter, faClock } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { applyHighlightsToHTML } from '../utils/highlightMatching'
|
||||
import { readingTime } from 'reading-time-estimator'
|
||||
import { filterHighlightsByUrl } from '../utils/urlHelpers'
|
||||
import { hexToRgb } from '../utils/colorHelpers'
|
||||
import ReaderHeader from './ReaderHeader'
|
||||
import { HighlightVisibility } from './HighlightsPanel'
|
||||
|
||||
interface ContentPanelProps {
|
||||
loading: boolean
|
||||
@@ -17,11 +19,17 @@ interface ContentPanelProps {
|
||||
selectedUrl?: string
|
||||
image?: string
|
||||
highlights?: Highlight[]
|
||||
showUnderlines?: boolean
|
||||
showHighlights?: boolean
|
||||
highlightStyle?: 'marker' | 'underline'
|
||||
highlightColor?: string
|
||||
onHighlightClick?: (highlightId: string) => void
|
||||
selectedHighlightId?: string
|
||||
highlightVisibility?: HighlightVisibility
|
||||
currentUserPubkey?: string
|
||||
followedPubkeys?: Set<string>
|
||||
// For highlight creation
|
||||
onTextSelection?: (text: string) => void
|
||||
onClearSelection?: () => void
|
||||
}
|
||||
|
||||
const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
@@ -32,17 +40,45 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
selectedUrl,
|
||||
image,
|
||||
highlights = [],
|
||||
showUnderlines = true,
|
||||
showHighlights = true,
|
||||
highlightStyle = 'marker',
|
||||
highlightColor = '#ffff00',
|
||||
onHighlightClick,
|
||||
selectedHighlightId
|
||||
selectedHighlightId,
|
||||
highlightVisibility = { nostrverse: true, friends: true, mine: true },
|
||||
currentUserPubkey,
|
||||
followedPubkeys = new Set(),
|
||||
// For highlight creation
|
||||
onTextSelection,
|
||||
onClearSelection
|
||||
}) => {
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const markdownPreviewRef = useRef<HTMLDivElement>(null)
|
||||
const [renderedHtml, setRenderedHtml] = useState<string>('')
|
||||
|
||||
const relevantHighlights = useMemo(() => filterHighlightsByUrl(highlights, selectedUrl), [selectedUrl, highlights])
|
||||
// Filter highlights by URL and visibility settings
|
||||
const relevantHighlights = useMemo(() => {
|
||||
const urlFiltered = filterHighlightsByUrl(highlights, selectedUrl)
|
||||
|
||||
// Apply visibility filtering
|
||||
return urlFiltered
|
||||
.map(h => {
|
||||
// Classify highlight level
|
||||
let level: 'mine' | 'friends' | 'nostrverse' = 'nostrverse'
|
||||
if (h.pubkey === currentUserPubkey) {
|
||||
level = 'mine'
|
||||
} else if (followedPubkeys.has(h.pubkey)) {
|
||||
level = 'friends'
|
||||
}
|
||||
return { ...h, level }
|
||||
})
|
||||
.filter(h => {
|
||||
// Filter by visibility settings
|
||||
if (h.level === 'mine') return highlightVisibility.mine
|
||||
if (h.level === 'friends') return highlightVisibility.friends
|
||||
return highlightVisibility.nostrverse
|
||||
})
|
||||
}, [selectedUrl, highlights, highlightVisibility, currentUserPubkey, followedPubkeys])
|
||||
|
||||
// Convert markdown to HTML when markdown content changes
|
||||
useEffect(() => {
|
||||
@@ -66,13 +102,14 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
const sourceHtml = markdown ? renderedHtml : html
|
||||
if (!sourceHtml) return ''
|
||||
|
||||
// Apply highlights if we have them and underlines are shown
|
||||
if (showUnderlines && relevantHighlights.length > 0) {
|
||||
// Apply highlights if we have them and highlights are enabled
|
||||
if (showHighlights && relevantHighlights.length > 0) {
|
||||
return applyHighlightsToHTML(sourceHtml, relevantHighlights, highlightStyle)
|
||||
}
|
||||
|
||||
return sourceHtml
|
||||
}, [html, renderedHtml, markdown, relevantHighlights, showUnderlines, highlightStyle])
|
||||
}, [html, renderedHtml, markdown, relevantHighlights, showHighlights, highlightStyle])
|
||||
|
||||
|
||||
// Attach click handlers to highlight marks
|
||||
useEffect(() => {
|
||||
@@ -127,6 +164,26 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
|
||||
const hasHighlights = relevantHighlights.length > 0
|
||||
|
||||
// Handle text selection for highlight creation
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setTimeout(() => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
onClearSelection?.()
|
||||
return
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
const text = selection.toString().trim()
|
||||
|
||||
if (text.length > 0 && contentRef.current?.contains(range.commonAncestorContainer)) {
|
||||
onTextSelection?.(text)
|
||||
} else {
|
||||
onClearSelection?.()
|
||||
}
|
||||
}, 10)
|
||||
}, [onTextSelection, onClearSelection])
|
||||
|
||||
if (!selectedUrl) {
|
||||
return (
|
||||
<div className="reader empty">
|
||||
@@ -140,7 +197,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
<div className="reader loading">
|
||||
<div className="loading-spinner">
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
<span>Loading content…</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -159,39 +215,40 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{image && (
|
||||
<div className="reader-hero-image">
|
||||
<img src={image} alt={title || 'Article image'} />
|
||||
</div>
|
||||
)}
|
||||
{title && (
|
||||
<div className="reader-header">
|
||||
<h2 className="reader-title">{title}</h2>
|
||||
<div className="reader-meta">
|
||||
{readingStats && (
|
||||
<div className="reading-time">
|
||||
<FontAwesomeIcon icon={faClock} />
|
||||
<span>{readingStats.text}</span>
|
||||
</div>
|
||||
)}
|
||||
{hasHighlights && (
|
||||
<div className="highlight-indicator">
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span>{relevantHighlights.length} highlight{relevantHighlights.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ReaderHeader
|
||||
title={title}
|
||||
image={image}
|
||||
readingTimeText={readingStats ? readingStats.text : null}
|
||||
hasHighlights={hasHighlights}
|
||||
highlightCount={relevantHighlights.length}
|
||||
/>
|
||||
{markdown || html ? (
|
||||
finalHtml ? (
|
||||
markdown ? (
|
||||
finalHtml ? (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="reader-markdown"
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml }}
|
||||
onMouseUp={handleMouseUp}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="reader-markdown"
|
||||
onMouseUp={handleMouseUp}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{markdown}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={markdown ? "reader-markdown" : "reader-html"}
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml }}
|
||||
className="reader-html"
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
|
||||
onMouseUp={handleMouseUp}
|
||||
/>
|
||||
) : (
|
||||
<div className="reader-markdown" ref={contentRef} />
|
||||
)
|
||||
) : (
|
||||
<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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faQuoteLeft, faLink, faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faQuoteLeft, faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
|
||||
interface HighlightWithLevel extends Highlight {
|
||||
level?: 'mine' | 'friends' | 'nostrverse'
|
||||
}
|
||||
|
||||
interface HighlightItemProps {
|
||||
highlight: Highlight
|
||||
highlight: HighlightWithLevel
|
||||
onSelectUrl?: (url: string) => void
|
||||
isSelected?: boolean
|
||||
onHighlightClick?: (highlightId: string) => void
|
||||
@@ -14,6 +20,16 @@ interface HighlightItemProps {
|
||||
export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelectUrl, isSelected, onHighlightClick }) => {
|
||||
const itemRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Resolve the profile of the user who made the highlight
|
||||
const profile = useEventModel(Models.ProfileModel, [highlight.pubkey])
|
||||
|
||||
// Get display name for the user
|
||||
const getUserDisplayName = () => {
|
||||
if (profile?.name) return profile.name
|
||||
if (profile?.display_name) return profile.display_name
|
||||
return `${highlight.pubkey.slice(0, 8)}...` // fallback to short pubkey
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelected && itemRef.current) {
|
||||
itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
@@ -45,7 +61,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelec
|
||||
return (
|
||||
<div
|
||||
ref={itemRef}
|
||||
className={`highlight-item ${isSelected ? 'selected' : ''}`}
|
||||
className={`highlight-item ${isSelected ? 'selected' : ''} ${highlight.level ? `level-${highlight.level}` : ''}`}
|
||||
data-highlight-id={highlight.id}
|
||||
onClick={handleItemClick}
|
||||
style={{ cursor: onHighlightClick ? 'pointer' : 'default' }}
|
||||
@@ -65,14 +81,12 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelec
|
||||
</div>
|
||||
)}
|
||||
|
||||
{highlight.context && (
|
||||
<details className="highlight-context">
|
||||
<summary>Show context</summary>
|
||||
<p className="context-text">{highlight.context}</p>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div className="highlight-meta">
|
||||
<span className="highlight-author">
|
||||
{getUserDisplayName()}
|
||||
</span>
|
||||
<span className="highlight-meta-separator">•</span>
|
||||
<span className="highlight-time">
|
||||
{formatDistanceToNow(new Date(highlight.created_at * 1000), { addSuffix: true })}
|
||||
</span>
|
||||
@@ -84,10 +98,9 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelec
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => highlight.urlReference && onSelectUrl ? handleLinkClick(highlight.urlReference, e) : undefined}
|
||||
className="highlight-source"
|
||||
title={highlight.eventReference ? 'View on Nostr' : 'View source'}
|
||||
title={highlight.eventReference ? 'Open on Nostr' : 'Open source'}
|
||||
>
|
||||
<FontAwesomeIcon icon={highlight.eventReference ? faLink : faExternalLinkAlt} />
|
||||
<span>{highlight.eventReference ? 'Nostr event' : 'Source'}</span>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
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, faGlobe } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { HighlightItem } from './HighlightItem'
|
||||
|
||||
export type HighlightMode = 'mine' | 'others'
|
||||
export interface HighlightVisibility {
|
||||
nostrverse: boolean
|
||||
friends: boolean
|
||||
mine: boolean
|
||||
}
|
||||
|
||||
interface HighlightsPanelProps {
|
||||
highlights: Highlight[]
|
||||
@@ -13,13 +17,14 @@ interface HighlightsPanelProps {
|
||||
onToggleCollapse: () => void
|
||||
onSelectUrl?: (url: string) => void
|
||||
selectedUrl?: string
|
||||
onToggleUnderlines?: (show: boolean) => void
|
||||
onToggleHighlights?: (show: boolean) => void
|
||||
selectedHighlightId?: string
|
||||
onRefresh?: () => void
|
||||
onHighlightClick?: (highlightId: string) => void
|
||||
currentUserPubkey?: string
|
||||
highlightMode?: HighlightMode
|
||||
onHighlightModeChange?: (mode: HighlightMode) => void
|
||||
highlightVisibility?: HighlightVisibility
|
||||
onHighlightVisibilityChange?: (visibility: HighlightVisibility) => void
|
||||
followedPubkeys?: Set<string>
|
||||
}
|
||||
|
||||
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
@@ -29,23 +34,24 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
onToggleCollapse,
|
||||
onSelectUrl,
|
||||
selectedUrl,
|
||||
onToggleUnderlines,
|
||||
onToggleHighlights,
|
||||
selectedHighlightId,
|
||||
onRefresh,
|
||||
onHighlightClick,
|
||||
currentUserPubkey,
|
||||
highlightMode = 'others',
|
||||
onHighlightModeChange
|
||||
highlightVisibility = { nostrverse: true, friends: true, mine: true },
|
||||
onHighlightVisibilityChange,
|
||||
followedPubkeys = new Set()
|
||||
}) => {
|
||||
const [showUnderlines, setShowUnderlines] = useState(true)
|
||||
const [showHighlights, setShowHighlights] = useState(true)
|
||||
|
||||
const handleToggleUnderlines = () => {
|
||||
const newValue = !showUnderlines
|
||||
setShowUnderlines(newValue)
|
||||
onToggleUnderlines?.(newValue)
|
||||
const handleToggleHighlights = () => {
|
||||
const newValue = !showHighlights
|
||||
setShowHighlights(newValue)
|
||||
onToggleHighlights?.(newValue)
|
||||
}
|
||||
|
||||
// Filter highlights based on mode and URL
|
||||
// Filter highlights based on visibility levels and URL
|
||||
const filteredHighlights = useMemo(() => {
|
||||
if (!selectedUrl) return highlights
|
||||
|
||||
@@ -75,18 +81,25 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
})
|
||||
}
|
||||
|
||||
// Filter by mode (mine vs others)
|
||||
if (!currentUserPubkey) {
|
||||
// If no user is logged in, show all highlights (others mode only makes sense)
|
||||
return urlFiltered
|
||||
}
|
||||
|
||||
if (highlightMode === 'mine') {
|
||||
return urlFiltered.filter(h => h.pubkey === currentUserPubkey)
|
||||
} else {
|
||||
return urlFiltered.filter(h => h.pubkey !== currentUserPubkey)
|
||||
}
|
||||
}, [highlights, selectedUrl, highlightMode, currentUserPubkey])
|
||||
// Classify and filter by visibility levels
|
||||
return urlFiltered
|
||||
.map(h => {
|
||||
// Classify highlight level
|
||||
let level: 'mine' | 'friends' | 'nostrverse' = 'nostrverse'
|
||||
if (h.pubkey === currentUserPubkey) {
|
||||
level = 'mine'
|
||||
} else if (followedPubkeys.has(h.pubkey)) {
|
||||
level = 'friends'
|
||||
}
|
||||
return { ...h, level }
|
||||
})
|
||||
.filter(h => {
|
||||
// Filter by visibility settings
|
||||
if (h.level === 'mine') return highlightVisibility.mine
|
||||
if (h.level === 'friends') return highlightVisibility.friends
|
||||
return highlightVisibility.nostrverse
|
||||
})
|
||||
}, [highlights, selectedUrl, highlightVisibility, currentUserPubkey, followedPubkeys])
|
||||
|
||||
if (isCollapsed) {
|
||||
const hasHighlights = filteredHighlights.length > 0
|
||||
@@ -109,53 +122,72 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
return (
|
||||
<div className="highlights-container">
|
||||
<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">
|
||||
{currentUserPubkey && onHighlightModeChange && (
|
||||
<div className="highlight-mode-toggle">
|
||||
<div className="highlights-actions-left">
|
||||
{onHighlightVisibilityChange && (
|
||||
<div className="highlight-level-toggles">
|
||||
<button
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
...highlightVisibility,
|
||||
nostrverse: !highlightVisibility.nostrverse
|
||||
})}
|
||||
className={`level-toggle-btn ${highlightVisibility.nostrverse ? 'active' : ''}`}
|
||||
title="Toggle nostrverse highlights"
|
||||
aria-label="Toggle nostrverse highlights"
|
||||
style={{ color: highlightVisibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faGlobe} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
...highlightVisibility,
|
||||
friends: !highlightVisibility.friends
|
||||
})}
|
||||
className={`level-toggle-btn ${highlightVisibility.friends ? 'active' : ''}`}
|
||||
title={currentUserPubkey ? "Toggle friends highlights" : "Login to see friends highlights"}
|
||||
aria-label="Toggle friends highlights"
|
||||
style={{ color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined }}
|
||||
disabled={!currentUserPubkey}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUserGroup} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
...highlightVisibility,
|
||||
mine: !highlightVisibility.mine
|
||||
})}
|
||||
className={`level-toggle-btn ${highlightVisibility.mine ? 'active' : ''}`}
|
||||
title={currentUserPubkey ? "Toggle my highlights" : "Login to see your highlights"}
|
||||
aria-label="Toggle my highlights"
|
||||
style={{ color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined }}
|
||||
disabled={!currentUserPubkey}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUser} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<button
|
||||
onClick={() => onHighlightModeChange('mine')}
|
||||
className={`mode-btn ${highlightMode === 'mine' ? 'active' : ''}`}
|
||||
title="My highlights"
|
||||
aria-label="Show my highlights"
|
||||
onClick={onRefresh}
|
||||
className="refresh-highlights-btn"
|
||||
title="Refresh highlights"
|
||||
aria-label="Refresh highlights"
|
||||
disabled={loading}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUser} />
|
||||
<FontAwesomeIcon icon={faRotate} spin={loading} />
|
||||
</button>
|
||||
)}
|
||||
{filteredHighlights.length > 0 && (
|
||||
<button
|
||||
onClick={() => onHighlightModeChange('others')}
|
||||
className={`mode-btn ${highlightMode === 'others' ? 'active' : ''}`}
|
||||
title="Other highlights"
|
||||
aria-label="Show highlights from others"
|
||||
onClick={handleToggleHighlights}
|
||||
className="toggle-highlight-display-btn"
|
||||
title={showHighlights ? 'Hide highlights' : 'Show highlights'}
|
||||
aria-label={showHighlights ? 'Hide highlights' : 'Show highlights'}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUserGroup} />
|
||||
<FontAwesomeIcon icon={showHighlights ? faEye : faEyeSlash} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="refresh-highlights-btn"
|
||||
title="Refresh highlights"
|
||||
aria-label="Refresh highlights"
|
||||
disabled={loading}
|
||||
>
|
||||
<FontAwesomeIcon icon={faRotate} spin={loading} />
|
||||
</button>
|
||||
)}
|
||||
{filteredHighlights.length > 0 && (
|
||||
<button
|
||||
onClick={handleToggleUnderlines}
|
||||
className="toggle-underlines-btn"
|
||||
title={showUnderlines ? 'Hide underlines' : 'Show underlines'}
|
||||
aria-label={showUnderlines ? 'Hide underlines' : 'Show underlines'}
|
||||
>
|
||||
<FontAwesomeIcon icon={showUnderlines ? faEye : faEyeSlash} />
|
||||
</button>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className="toggle-highlights-btn"
|
||||
@@ -167,9 +199,9 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
{loading && filteredHighlights.length === 0 ? (
|
||||
<div className="highlights-loading">
|
||||
<p>Loading highlights...</p>
|
||||
<FontAwesomeIcon icon={faHighlighter} spin />
|
||||
</div>
|
||||
) : filteredHighlights.length === 0 ? (
|
||||
<div className="highlights-empty">
|
||||
|
||||
@@ -9,6 +9,8 @@ interface IconButtonProps {
|
||||
ariaLabel?: string
|
||||
variant?: 'primary' | 'success' | 'ghost'
|
||||
size?: number
|
||||
disabled?: boolean
|
||||
spin?: boolean
|
||||
}
|
||||
|
||||
const IconButton: React.FC<IconButtonProps> = ({
|
||||
@@ -17,7 +19,9 @@ const IconButton: React.FC<IconButtonProps> = ({
|
||||
title,
|
||||
ariaLabel,
|
||||
variant = 'ghost',
|
||||
size = 33
|
||||
size = 33,
|
||||
disabled = false,
|
||||
spin = false
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
@@ -26,8 +30,9 @@ const IconButton: React.FC<IconButtonProps> = ({
|
||||
title={title}
|
||||
aria-label={ariaLabel || title}
|
||||
style={{ width: size, height: size }}
|
||||
disabled={disabled}
|
||||
>
|
||||
<FontAwesomeIcon icon={icon} />
|
||||
<FontAwesomeIcon icon={icon} spin={spin} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -29,9 +29,8 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
|
||||
useEffect(() => {
|
||||
// Load font for preview when it changes
|
||||
if (localSettings.readingFont) {
|
||||
loadFont(localSettings.readingFont)
|
||||
}
|
||||
const fontToLoad = localSettings.readingFont || 'source-serif-4'
|
||||
loadFont(fontToLoad)
|
||||
}, [localSettings.readingFont])
|
||||
|
||||
// Auto-save settings whenever they change (except on initial mount)
|
||||
@@ -44,7 +43,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
onSave(localSettings)
|
||||
}, [localSettings, onSave])
|
||||
|
||||
const previewFontFamily = getFontFamily(localSettings.readingFont)
|
||||
const previewFontFamily = getFontFamily(localSettings.readingFont || 'source-serif-4')
|
||||
|
||||
return (
|
||||
<div className="settings-view">
|
||||
@@ -66,7 +65,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
<div className="setting-group setting-inline">
|
||||
<label htmlFor="readingFont">Reading Font</label>
|
||||
<FontSelector
|
||||
value={localSettings.readingFont || 'system'}
|
||||
value={localSettings.readingFont || 'source-serif-4'}
|
||||
onChange={(font) => setLocalSettings({ ...localSettings, readingFont: font })}
|
||||
/>
|
||||
</div>
|
||||
@@ -78,7 +77,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
<button
|
||||
key={size}
|
||||
onClick={() => setLocalSettings({ ...localSettings, fontSize: size })}
|
||||
className={`font-size-btn ${(localSettings.fontSize || 16) === size ? 'active' : ''}`}
|
||||
className={`font-size-btn ${(localSettings.fontSize || 18) === size ? 'active' : ''}`}
|
||||
title={`${size}px`}
|
||||
style={{ fontSize: `${size - 2}px` }}
|
||||
>
|
||||
@@ -89,12 +88,12 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="showUnderlines" className="checkbox-label">
|
||||
<label htmlFor="showHighlights" className="checkbox-label">
|
||||
<input
|
||||
id="showUnderlines"
|
||||
id="showHighlights"
|
||||
type="checkbox"
|
||||
checked={localSettings.showUnderlines !== false}
|
||||
onChange={(e) => setLocalSettings({ ...localSettings, showUnderlines: e.target.checked })}
|
||||
checked={localSettings.showHighlights !== false}
|
||||
onChange={(e) => setLocalSettings({ ...localSettings, showHighlights: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Show highlights</span>
|
||||
@@ -121,12 +120,35 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Highlight Color</label>
|
||||
<ColorPicker
|
||||
selectedColor={localSettings.highlightColor || '#ffff00'}
|
||||
onColorChange={(color) => setLocalSettings({ ...localSettings, highlightColor: color })}
|
||||
/>
|
||||
<label className="setting-label">My Highlights</label>
|
||||
<div className="setting-control">
|
||||
<ColorPicker
|
||||
selectedColor={localSettings.highlightColorMine || '#ffff00'}
|
||||
onColorChange={(color) => setLocalSettings({ ...localSettings, highlightColorMine: color })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label 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 className="setting-preview">
|
||||
@@ -135,13 +157,15 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
className="preview-content"
|
||||
style={{
|
||||
fontFamily: previewFontFamily,
|
||||
fontSize: `${localSettings.fontSize || 16}px`,
|
||||
fontSize: `${localSettings.fontSize || 18}px`,
|
||||
'--highlight-rgb': hexToRgb(localSettings.highlightColor || '#ffff00')
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<h3>The Quick Brown Fox</h3>
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <span className={localSettings.showUnderlines !== false ? `content-highlight-${localSettings.highlightStyle || 'marker'}` : ""}>Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</span> Ut enim ad minim veniam.</p>
|
||||
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.</p>
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <span className={localSettings.showHighlights !== false ? `content-highlight-${localSettings.highlightStyle || 'marker'} level-mine` : ""}>Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</span> Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
|
||||
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. <span className={localSettings.showHighlights !== false ? `content-highlight-${localSettings.highlightStyle || 'marker'} level-friends` : ""}>Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</span> Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.</p>
|
||||
<p>Totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. <span className={localSettings.showHighlights !== false ? `content-highlight-${localSettings.highlightStyle || 'marker'} level-nostrverse` : ""}>Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</span> Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit.</p>
|
||||
<p>Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -180,7 +204,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
<input
|
||||
id="sidebarCollapsed"
|
||||
type="checkbox"
|
||||
checked={localSettings.sidebarCollapsed === true}
|
||||
checked={localSettings.sidebarCollapsed !== false}
|
||||
onChange={(e) => setLocalSettings({ ...localSettings, sidebarCollapsed: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
@@ -193,7 +217,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
<input
|
||||
id="highlightsCollapsed"
|
||||
type="checkbox"
|
||||
checked={localSettings.highlightsCollapsed === true}
|
||||
checked={localSettings.highlightsCollapsed !== false}
|
||||
onChange={(e) => setLocalSettings({ ...localSettings, highlightsCollapsed: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import React, { useState } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronRight, faRightFromBracket, faRightToBracket, faUser, faList, faThLarge, faImage, faGear } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faRotate } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
import { Accounts } from 'applesauce-accounts'
|
||||
import IconButton from './IconButton'
|
||||
import { ViewMode } from './Bookmarks'
|
||||
|
||||
interface SidebarHeaderProps {
|
||||
onToggleCollapse: () => void
|
||||
onLogout: () => void
|
||||
viewMode: ViewMode
|
||||
onViewModeChange: (mode: ViewMode) => void
|
||||
onOpenSettings: () => void
|
||||
onRefresh?: () => void
|
||||
isRefreshing?: boolean
|
||||
}
|
||||
|
||||
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, viewMode, onViewModeChange, onOpenSettings }) => {
|
||||
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, onRefresh, isRefreshing }) => {
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const accountManager = Hooks.useAccountManager()
|
||||
@@ -61,13 +60,18 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</button>
|
||||
<div className="profile-avatar" title={getUserDisplayName()}>
|
||||
{profileImage ? (
|
||||
<img src={profileImage} alt={getUserDisplayName()} />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faUser} />
|
||||
)}
|
||||
</div>
|
||||
<div className="sidebar-header-right">
|
||||
{onRefresh && (
|
||||
<IconButton
|
||||
icon={faRotate}
|
||||
onClick={onRefresh}
|
||||
title="Refresh bookmarks"
|
||||
ariaLabel="Refresh bookmarks"
|
||||
variant="ghost"
|
||||
disabled={isRefreshing}
|
||||
spin={isRefreshing}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
icon={faGear}
|
||||
onClick={onOpenSettings}
|
||||
@@ -75,6 +79,18 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
ariaLabel="Settings"
|
||||
variant="ghost"
|
||||
/>
|
||||
<div
|
||||
className="profile-avatar"
|
||||
title={activeAccount ? getUserDisplayName() : "Login"}
|
||||
onClick={!activeAccount ? (isConnecting ? () => {} : handleLogin) : undefined}
|
||||
style={{ cursor: !activeAccount ? 'pointer' : 'default' }}
|
||||
>
|
||||
{profileImage ? (
|
||||
<img src={profileImage} alt={getUserDisplayName()} />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faUserCircle} />
|
||||
)}
|
||||
</div>
|
||||
{activeAccount ? (
|
||||
<IconButton
|
||||
icon={faRightFromBracket}
|
||||
@@ -92,29 +108,7 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="view-mode-controls">
|
||||
<IconButton
|
||||
icon={faList}
|
||||
onClick={() => onViewModeChange('compact')}
|
||||
title="Compact list view"
|
||||
ariaLabel="Compact list view"
|
||||
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faThLarge}
|
||||
onClick={() => onViewModeChange('cards')}
|
||||
title="Cards view"
|
||||
ariaLabel="Cards view"
|
||||
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faImage}
|
||||
onClick={() => onViewModeChange('large')}
|
||||
title="Large preview view"
|
||||
ariaLabel="Large preview view"
|
||||
variant={viewMode === 'large' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { fetchArticleByNaddr } from '../services/articleService'
|
||||
import { fetchHighlightsForArticle } from '../services/highlightService'
|
||||
import { ReadableContent } from '../services/readerService'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
|
||||
interface UseArticleLoaderProps {
|
||||
naddr: string | undefined
|
||||
@@ -12,11 +13,11 @@ interface UseArticleLoaderProps {
|
||||
setReaderContent: (content: ReadableContent | undefined) => void
|
||||
setReaderLoading: (loading: boolean) => void
|
||||
setIsCollapsed: (collapsed: boolean) => void
|
||||
setIsHighlightsCollapsed: (collapsed: boolean) => void
|
||||
setHighlights: (highlights: Highlight[]) => void
|
||||
setHighlightsLoading: (loading: boolean) => void
|
||||
setCurrentArticleCoordinate: (coord: string | undefined) => void
|
||||
setCurrentArticleEventId: (id: string | undefined) => void
|
||||
setCurrentArticle?: (article: NostrEvent) => void
|
||||
}
|
||||
|
||||
export function useArticleLoader({
|
||||
@@ -26,11 +27,11 @@ export function useArticleLoader({
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
setIsCollapsed,
|
||||
setIsHighlightsCollapsed,
|
||||
setHighlights,
|
||||
setHighlightsLoading,
|
||||
setCurrentArticleCoordinate,
|
||||
setCurrentArticleEventId
|
||||
setCurrentArticleEventId,
|
||||
setCurrentArticle
|
||||
}: UseArticleLoaderProps) {
|
||||
useEffect(() => {
|
||||
if (!relayPool || !naddr) return
|
||||
@@ -40,7 +41,7 @@ export function useArticleLoader({
|
||||
setReaderContent(undefined)
|
||||
setSelectedUrl(`nostr:${naddr}`)
|
||||
setIsCollapsed(true)
|
||||
setIsHighlightsCollapsed(false)
|
||||
// Keep highlights panel collapsed by default - only open on user interaction
|
||||
|
||||
try {
|
||||
const article = await fetchArticleByNaddr(relayPool, naddr)
|
||||
@@ -56,19 +57,33 @@ export function useArticleLoader({
|
||||
|
||||
setCurrentArticleCoordinate(articleCoordinate)
|
||||
setCurrentArticleEventId(article.event.id)
|
||||
setCurrentArticle?.(article.event)
|
||||
|
||||
console.log('📰 Article loaded:', article.title)
|
||||
console.log('📍 Coordinate:', articleCoordinate)
|
||||
|
||||
// Set reader loading to false immediately after article content is ready
|
||||
// Don't wait for highlights to finish loading
|
||||
setReaderLoading(false)
|
||||
|
||||
// Fetch highlights asynchronously without blocking article display
|
||||
// Stream them as they arrive for instant rendering
|
||||
try {
|
||||
setHighlightsLoading(true)
|
||||
const fetchedHighlights = await fetchHighlightsForArticle(
|
||||
setHighlights([]) // Clear old highlights
|
||||
const highlightsList: Highlight[] = []
|
||||
|
||||
await fetchHighlightsForArticle(
|
||||
relayPool,
|
||||
articleCoordinate,
|
||||
article.event.id
|
||||
article.event.id,
|
||||
(highlight) => {
|
||||
// Render each highlight immediately as it arrives
|
||||
highlightsList.push(highlight)
|
||||
setHighlights([...highlightsList].sort((a, b) => b.created_at - a.created_at))
|
||||
}
|
||||
)
|
||||
console.log(`📌 Found ${fetchedHighlights.length} highlights`)
|
||||
setHighlights(fetchedHighlights)
|
||||
console.log(`📌 Found ${highlightsList.length} highlights`)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch highlights:', err)
|
||||
} finally {
|
||||
@@ -82,8 +97,6 @@ export function useArticleLoader({
|
||||
url: `nostr:${naddr}`
|
||||
})
|
||||
setReaderLoading(false)
|
||||
} finally {
|
||||
setReaderLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,12 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
||||
const fontKey = settings.readingFont || 'system'
|
||||
if (fontKey !== 'system') loadFont(fontKey)
|
||||
root.setProperty('--reading-font', getFontFamily(fontKey))
|
||||
root.setProperty('--reading-font-size', `${settings.fontSize || 16}px`)
|
||||
root.setProperty('--reading-font-size', `${settings.fontSize || 18}px`)
|
||||
|
||||
// Set highlight colors for three levels
|
||||
root.setProperty('--highlight-color-mine', settings.highlightColorMine || '#ffff00')
|
||||
root.setProperty('--highlight-color-friends', settings.highlightColorFriends || '#f97316')
|
||||
root.setProperty('--highlight-color-nostrverse', settings.highlightColorNostrverse || '#9333ea')
|
||||
}, [settings])
|
||||
|
||||
const saveSettingsWithToast = useCallback(async (newSettings: UserSettings) => {
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
381
src/index.css
381
src/index.css
@@ -13,8 +13,15 @@
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
|
||||
--reading-font: system-ui, -apple-system, sans-serif;
|
||||
--reading-font-size: 16px;
|
||||
--reading-font: 'Source Serif 4', serif;
|
||||
--reading-font-size: 18px;
|
||||
/* Layout variables */
|
||||
--sidebar-width: 320px;
|
||||
--sidebar-collapsed-width: 64px;
|
||||
--highlights-width: 360px;
|
||||
--highlights-collapsed-width: 56px;
|
||||
--main-max-width: 900px;
|
||||
--main-horizontal-padding: 1rem;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -24,9 +31,9 @@ body {
|
||||
}
|
||||
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.app {
|
||||
@@ -98,7 +105,32 @@ body {
|
||||
|
||||
/* Bookmarks Styles */
|
||||
.bookmarks-container {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
padding: 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 {
|
||||
@@ -109,8 +141,15 @@ body {
|
||||
padding: 0.75rem 1rem;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: 12px 12px 0 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.sidebar-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.view-mode-controls {
|
||||
@@ -179,8 +218,8 @@ body {
|
||||
.bookmarks-container.collapsed {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
padding: 0.75rem 0 0 0;
|
||||
justify-content: flex-start;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
@@ -188,16 +227,17 @@ body {
|
||||
.bookmarks-container.collapsed .toggle-sidebar-btn {
|
||||
background: #2a2a2a;
|
||||
color: #ddd;
|
||||
border: 1px solid #444;
|
||||
border: none;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
width: 48px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bookmarks-container.collapsed .toggle-sidebar-btn:hover {
|
||||
@@ -423,7 +463,7 @@ body {
|
||||
.two-pane {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
gap: 1rem;
|
||||
column-gap: 0;
|
||||
height: calc(100vh - 4rem);
|
||||
transition: grid-template-columns 0.3s ease;
|
||||
}
|
||||
@@ -435,22 +475,22 @@ body {
|
||||
/* Three-pane layout */
|
||||
.three-pane {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr 360px;
|
||||
gap: 1rem;
|
||||
height: calc(100vh - 4rem);
|
||||
grid-template-columns: var(--sidebar-width) 1fr var(--highlights-width);
|
||||
column-gap: 0;
|
||||
height: calc(100vh - 2rem);
|
||||
transition: grid-template-columns 0.3s ease;
|
||||
}
|
||||
|
||||
.three-pane.sidebar-collapsed {
|
||||
grid-template-columns: 60px 1fr 360px;
|
||||
grid-template-columns: var(--sidebar-collapsed-width) 1fr var(--highlights-width);
|
||||
}
|
||||
|
||||
.three-pane.highlights-collapsed {
|
||||
grid-template-columns: 360px 1fr 60px;
|
||||
grid-template-columns: var(--sidebar-width) 1fr var(--highlights-collapsed-width);
|
||||
}
|
||||
|
||||
.three-pane.sidebar-collapsed.highlights-collapsed {
|
||||
grid-template-columns: 60px 1fr 60px;
|
||||
grid-template-columns: var(--sidebar-collapsed-width) 1fr var(--highlights-collapsed-width);
|
||||
}
|
||||
|
||||
.pane.sidebar {
|
||||
@@ -461,9 +501,20 @@ body {
|
||||
.pane.main {
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
max-width: 900px;
|
||||
max-width: var(--main-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
padding: 0 var(--main-horizontal-padding);
|
||||
overflow-x: hidden;
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
/* Remove padding when sidebar is collapsed for zero gap */
|
||||
.three-pane.sidebar-collapsed .pane.main {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.three-pane.sidebar-collapsed.highlights-collapsed .pane.main {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.pane.highlights {
|
||||
@@ -474,9 +525,11 @@ body {
|
||||
.reader {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.reader.empty {
|
||||
@@ -699,6 +752,8 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.bookmarks-grid.bookmarks-compact {
|
||||
@@ -711,7 +766,7 @@ body {
|
||||
|
||||
.individual-bookmark {
|
||||
background: #2a2a2a;
|
||||
padding: 1.25rem;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid #333;
|
||||
@@ -728,11 +783,13 @@ body {
|
||||
|
||||
/* Compact view styles */
|
||||
.individual-bookmark.compact {
|
||||
padding: 0.4rem 0.75rem;
|
||||
padding: 0.3rem 0.25rem;
|
||||
background: transparent;
|
||||
border-bottom: 1px solid #333;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.individual-bookmark.compact:hover {
|
||||
@@ -746,6 +803,9 @@ body {
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
height: 28px;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.compact-row.clickable {
|
||||
@@ -766,7 +826,7 @@ body {
|
||||
}
|
||||
|
||||
.compact-text {
|
||||
flex: 1;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
color: #ccc;
|
||||
font-size: 0.85rem;
|
||||
@@ -774,6 +834,7 @@ body {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.bookmark-date-compact {
|
||||
@@ -784,8 +845,8 @@ body {
|
||||
}
|
||||
|
||||
.compact-read-btn {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
background: transparent;
|
||||
color: #888;
|
||||
border: none;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
@@ -797,11 +858,12 @@ body {
|
||||
width: 26px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.2s ease;
|
||||
margin-left: auto;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.compact-read-btn:hover {
|
||||
background: #218838;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.compact-read-btn:active {
|
||||
@@ -1171,13 +1233,14 @@ body {
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.highlights-container.collapsed {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
padding: 0.75rem 0 0 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
@@ -1185,9 +1248,9 @@ body {
|
||||
.highlights-container.collapsed .toggle-highlights-btn {
|
||||
background: #2a2a2a;
|
||||
color: #ddd;
|
||||
border: 1px solid #444;
|
||||
border: none;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
@@ -1233,10 +1296,23 @@ body {
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid #333;
|
||||
background: #1e1e1e;
|
||||
background: #1a1a1a;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
.highlights-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.highlights-actions-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.highlights-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1289,8 +1365,50 @@ body {
|
||||
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,
|
||||
.toggle-underlines-btn,
|
||||
.toggle-highlight-display-btn,
|
||||
.toggle-highlights-btn {
|
||||
background: transparent;
|
||||
color: #ddd;
|
||||
@@ -1307,14 +1425,14 @@ body {
|
||||
}
|
||||
|
||||
.refresh-highlights-btn:hover,
|
||||
.toggle-underlines-btn:hover,
|
||||
.toggle-highlight-display-btn:hover,
|
||||
.toggle-highlights-btn:hover {
|
||||
background: #2a2a2a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.refresh-highlights-btn:active,
|
||||
.toggle-underlines-btn:active,
|
||||
.toggle-highlight-display-btn:active,
|
||||
.toggle-highlights-btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
@@ -1380,6 +1498,22 @@ body {
|
||||
box-shadow: 0 0 0 2px rgba(100, 108, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Level colors in sidebar items */
|
||||
.highlight-item.level-mine {
|
||||
border-color: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 60%, #333);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-mine, #ffff00) 25%, transparent);
|
||||
}
|
||||
|
||||
.highlight-item.level-friends {
|
||||
border-color: color-mix(in srgb, var(--highlight-color-friends, #f97316) 60%, #333);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-friends, #f97316) 25%, transparent);
|
||||
}
|
||||
|
||||
.highlight-item.level-nostrverse {
|
||||
border-color: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 60%, #333);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 25%, transparent);
|
||||
}
|
||||
|
||||
.highlight-quote-icon {
|
||||
color: #646cff;
|
||||
font-size: 1.2rem;
|
||||
@@ -1387,6 +1521,19 @@ body {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Level-colored quote icon */
|
||||
.highlight-item.level-mine .highlight-quote-icon {
|
||||
color: var(--highlight-color-mine, #ffff00);
|
||||
}
|
||||
|
||||
.highlight-item.level-friends .highlight-quote-icon {
|
||||
color: var(--highlight-color-friends, #f97316);
|
||||
}
|
||||
|
||||
.highlight-item.level-nostrverse .highlight-quote-icon {
|
||||
color: var(--highlight-color-nostrverse, #9333ea);
|
||||
}
|
||||
|
||||
.highlight-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -1415,41 +1562,25 @@ body {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.highlight-context {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.highlight-context summary {
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: #888;
|
||||
user-select: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.highlight-context summary:hover {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.context-text {
|
||||
margin: 0.5rem 0 0 0;
|
||||
padding: 0.75rem;
|
||||
background: #252525;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
color: #aaa;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.highlight-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #888;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.highlight-author {
|
||||
color: #aaa;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.highlight-meta-separator {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.highlight-time {
|
||||
color: #888;
|
||||
}
|
||||
@@ -1482,6 +1613,7 @@ body {
|
||||
position: relative;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 8px rgba(var(--highlight-rgb, 255, 255, 0), 0.2);
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.content-highlight:hover,
|
||||
@@ -1501,6 +1633,7 @@ body {
|
||||
text-decoration-color: rgba(var(--highlight-rgb, 255, 255, 0), 0.8);
|
||||
text-decoration-thickness: 2px;
|
||||
text-underline-offset: 2px;
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.content-highlight-underline:hover {
|
||||
@@ -1549,6 +1682,68 @@ body {
|
||||
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 */
|
||||
@media (prefers-color-scheme: light) {
|
||||
.content-highlight,
|
||||
@@ -1571,6 +1766,55 @@ body {
|
||||
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 {
|
||||
background: rgba(100, 108, 255, 0.15);
|
||||
border-color: rgba(100, 108, 255, 0.4);
|
||||
@@ -1640,6 +1884,17 @@ body {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
text-align: left;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.setting-control {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.setting-group.setting-inline label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -80,9 +80,7 @@ export async function collectBookmarksFromEvents(
|
||||
privateItemsAll.push(...processApplesauceBookmarks(manualPrivate, activeAccount, true))
|
||||
Reflect.set(evt, BookmarkHiddenSymbol, manualPrivate)
|
||||
Reflect.set(evt, 'EncryptedContentSymbol', decryptedContent)
|
||||
if (!latestContent) {
|
||||
latestContent = decryptedContent
|
||||
}
|
||||
// Don't set latestContent to decrypted JSON - it's not user-facing content
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
67
src/services/highlightCreationService.ts
Normal file
67
src/services/highlightCreationService.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { EventFactory } from 'applesauce-factory'
|
||||
import { HighlightBlueprint } from 'applesauce-factory/blueprints'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { AddressPointer } from 'nostr-tools/nip19'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
|
||||
/**
|
||||
* Creates and publishes a highlight event (NIP-84)
|
||||
*/
|
||||
export async function createHighlight(
|
||||
selectedText: string,
|
||||
article: NostrEvent | null,
|
||||
account: IAccount,
|
||||
relayPool: RelayPool,
|
||||
comment?: string
|
||||
): Promise<void> {
|
||||
if (!selectedText || !article) {
|
||||
throw new Error('Missing required data to create highlight')
|
||||
}
|
||||
|
||||
// Create EventFactory with the account as signer
|
||||
const factory = new EventFactory({ signer: account })
|
||||
|
||||
// Parse article coordinate to get address pointer
|
||||
const addressPointer = parseArticleCoordinate(article)
|
||||
|
||||
// Create highlight event using the blueprint
|
||||
const highlightEvent = await factory.create(
|
||||
HighlightBlueprint,
|
||||
selectedText,
|
||||
addressPointer,
|
||||
comment ? { comment } : undefined
|
||||
)
|
||||
|
||||
// Sign the event
|
||||
const signedEvent = await factory.sign(highlightEvent)
|
||||
|
||||
// Publish to relays
|
||||
const relayUrls = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.nostr.band',
|
||||
'wss://relay.snort.social',
|
||||
'wss://purplepag.es'
|
||||
]
|
||||
|
||||
await relayPool.publish(relayUrls, signedEvent)
|
||||
|
||||
console.log('✅ Highlight published:', signedEvent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse article coordinate to create address pointer
|
||||
*/
|
||||
function parseArticleCoordinate(article: NostrEvent): AddressPointer {
|
||||
// Try to get identifier from article tags
|
||||
const identifier = article.tags.find(tag => tag[0] === 'd')?.[1] || ''
|
||||
|
||||
return {
|
||||
kind: article.kind,
|
||||
pubkey: article.pubkey,
|
||||
identifier,
|
||||
relays: [] // Optional relays hint
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { RelayPool, completeOnEose } from 'applesauce-relay'
|
||||
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
|
||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||
import { lastValueFrom, takeUntil, timer, tap, toArray } from 'rxjs'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import {
|
||||
getHighlightText,
|
||||
@@ -38,7 +38,8 @@ function dedupeHighlights(events: NostrEvent[]): NostrEvent[] {
|
||||
export const fetchHighlightsForArticle = async (
|
||||
relayPool: RelayPool,
|
||||
articleCoordinate: string,
|
||||
eventId?: string
|
||||
eventId?: string,
|
||||
onHighlight?: (highlight: Highlight) => void
|
||||
): Promise<Highlight[]> => {
|
||||
try {
|
||||
// Use well-known relays for highlights even if user isn't logged in
|
||||
@@ -54,12 +55,53 @@ export const fetchHighlightsForArticle = async (
|
||||
console.log('🔍 Event ID:', eventId || 'none')
|
||||
console.log('🔍 From relays:', highlightRelays)
|
||||
|
||||
const seenIds = new Set<string>()
|
||||
const processEvent = (event: NostrEvent): Highlight | null => {
|
||||
if (seenIds.has(event.id)) return null
|
||||
seenIds.add(event.id)
|
||||
|
||||
const highlightText = getHighlightText(event)
|
||||
const context = getHighlightContext(event)
|
||||
const comment = getHighlightComment(event)
|
||||
const sourceEventPointer = getHighlightSourceEventPointer(event)
|
||||
const sourceAddressPointer = getHighlightSourceAddressPointer(event)
|
||||
const sourceUrl = getHighlightSourceUrl(event)
|
||||
const attributions = getHighlightAttributions(event)
|
||||
|
||||
const author = attributions.find(a => a.role === 'author')?.pubkey
|
||||
const eventReference = sourceEventPointer?.id ||
|
||||
(sourceAddressPointer ? `${sourceAddressPointer.kind}:${sourceAddressPointer.pubkey}:${sourceAddressPointer.identifier}` : undefined)
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
created_at: event.created_at,
|
||||
content: highlightText,
|
||||
tags: event.tags,
|
||||
eventReference,
|
||||
urlReference: sourceUrl,
|
||||
author,
|
||||
context,
|
||||
comment
|
||||
}
|
||||
}
|
||||
|
||||
// Query for highlights that reference this article via the 'a' tag
|
||||
console.log('🔍 Filter 1 (a-tag):', JSON.stringify({ kinds: [9802], '#a': [articleCoordinate] }, null, 2))
|
||||
const aTagEvents = await lastValueFrom(
|
||||
relayPool
|
||||
.req(highlightRelays, { kinds: [9802], '#a': [articleCoordinate] })
|
||||
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
const highlight = processEvent(event)
|
||||
if (highlight && onHighlight) {
|
||||
onHighlight(highlight)
|
||||
}
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(10000)),
|
||||
toArray()
|
||||
)
|
||||
)
|
||||
|
||||
console.log('📊 Highlights via a-tag:', aTagEvents.length)
|
||||
@@ -67,11 +109,21 @@ export const fetchHighlightsForArticle = async (
|
||||
// If we have an event ID, also query for highlights that reference via the 'e' tag
|
||||
let eTagEvents: NostrEvent[] = []
|
||||
if (eventId) {
|
||||
console.log('🔍 Filter 2 (e-tag):', JSON.stringify({ kinds: [9802], '#e': [eventId] }, null, 2))
|
||||
eTagEvents = await lastValueFrom(
|
||||
relayPool
|
||||
.req(highlightRelays, { kinds: [9802], '#e': [eventId] })
|
||||
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
const highlight = processEvent(event)
|
||||
if (highlight && onHighlight) {
|
||||
onHighlight(highlight)
|
||||
}
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(10000)),
|
||||
toArray()
|
||||
)
|
||||
)
|
||||
console.log('📊 Highlights via e-tag:', eTagEvents.length)
|
||||
}
|
||||
@@ -135,30 +187,71 @@ export const fetchHighlightsForArticle = async (
|
||||
* Fetches highlights created by a specific user
|
||||
* @param relayPool - The relay pool to query
|
||||
* @param pubkey - The user's public key
|
||||
* @param onHighlight - Optional callback to receive highlights as they arrive
|
||||
*/
|
||||
export const fetchHighlights = async (
|
||||
relayPool: RelayPool,
|
||||
pubkey: string
|
||||
pubkey: string,
|
||||
onHighlight?: (highlight: Highlight) => void
|
||||
): Promise<Highlight[]> => {
|
||||
try {
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
|
||||
console.log('🔍 Fetching highlights (kind 9802) by author:', pubkey)
|
||||
|
||||
const seenIds = new Set<string>()
|
||||
const rawEvents = await lastValueFrom(
|
||||
relayPool
|
||||
.req(relayUrls, { kinds: [9802], authors: [pubkey] })
|
||||
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
if (!seenIds.has(event.id)) {
|
||||
seenIds.add(event.id)
|
||||
|
||||
const highlightText = getHighlightText(event)
|
||||
const context = getHighlightContext(event)
|
||||
const comment = getHighlightComment(event)
|
||||
const sourceEventPointer = getHighlightSourceEventPointer(event)
|
||||
const sourceAddressPointer = getHighlightSourceAddressPointer(event)
|
||||
const sourceUrl = getHighlightSourceUrl(event)
|
||||
const attributions = getHighlightAttributions(event)
|
||||
|
||||
const author = attributions.find(a => a.role === 'author')?.pubkey
|
||||
const eventReference = sourceEventPointer?.id ||
|
||||
(sourceAddressPointer ? `${sourceAddressPointer.kind}:${sourceAddressPointer.pubkey}:${sourceAddressPointer.identifier}` : undefined)
|
||||
|
||||
const highlight: Highlight = {
|
||||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
created_at: event.created_at,
|
||||
content: highlightText,
|
||||
tags: event.tags,
|
||||
eventReference,
|
||||
urlReference: sourceUrl,
|
||||
author,
|
||||
context,
|
||||
comment
|
||||
}
|
||||
|
||||
if (onHighlight) {
|
||||
onHighlight(highlight)
|
||||
}
|
||||
}
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(10000)),
|
||||
toArray()
|
||||
)
|
||||
)
|
||||
|
||||
console.log('📊 Raw highlight events fetched:', rawEvents.length)
|
||||
|
||||
// Deduplicate events by ID
|
||||
// Deduplicate and process events
|
||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||
console.log('📊 Unique highlight events after deduplication:', uniqueEvents.length)
|
||||
|
||||
const highlights: Highlight[] = uniqueEvents.map((event: NostrEvent) => {
|
||||
// Use applesauce helpers to extract highlight data
|
||||
const highlightText = getHighlightText(event)
|
||||
const context = getHighlightContext(event)
|
||||
const comment = getHighlightComment(event)
|
||||
@@ -167,10 +260,7 @@ export const fetchHighlights = async (
|
||||
const sourceUrl = getHighlightSourceUrl(event)
|
||||
const attributions = getHighlightAttributions(event)
|
||||
|
||||
// Get author from attributions
|
||||
const author = attributions.find(a => a.role === 'author')?.pubkey
|
||||
|
||||
// Get event reference (prefer event pointer, fallback to address pointer)
|
||||
const eventReference = sourceEventPointer?.id ||
|
||||
(sourceAddressPointer ? `${sourceAddressPointer.kind}:${sourceAddressPointer.pubkey}:${sourceAddressPointer.identifier}` : undefined)
|
||||
|
||||
|
||||
@@ -11,13 +11,17 @@ const SETTINGS_IDENTIFIER = 'com.dergigi.boris.user-settings'
|
||||
export interface UserSettings {
|
||||
collapseOnArticleOpen?: boolean
|
||||
defaultViewMode?: 'compact' | 'cards' | 'large'
|
||||
showUnderlines?: boolean
|
||||
showHighlights?: boolean
|
||||
sidebarCollapsed?: boolean
|
||||
highlightsCollapsed?: boolean
|
||||
readingFont?: string
|
||||
fontSize?: number
|
||||
highlightStyle?: 'marker' | 'underline'
|
||||
highlightColor?: string
|
||||
// Three-level highlight colors
|
||||
highlightColorNostrverse?: string
|
||||
highlightColorFriends?: string
|
||||
highlightColorMine?: string
|
||||
}
|
||||
|
||||
export async function loadSettings(
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// NIP-84 Highlight types
|
||||
export type HighlightLevel = 'nostrverse' | 'friends' | 'mine'
|
||||
|
||||
export interface Highlight {
|
||||
id: string
|
||||
pubkey: string
|
||||
@@ -11,5 +13,7 @@ export interface Highlight {
|
||||
author?: string // 'p' tag with 'author' role
|
||||
context?: string // surrounding text context
|
||||
comment?: string // optional comment about the highlight
|
||||
// Level classification (computed based on user's context)
|
||||
level?: HighlightLevel
|
||||
}
|
||||
|
||||
|
||||
@@ -73,11 +73,13 @@ export function applyHighlightsToText(
|
||||
|
||||
// Add the highlighted text
|
||||
const highlightedText = text.substring(match.startIndex, match.endIndex)
|
||||
const levelClass = match.highlight.level ? ` level-${match.highlight.level}` : ''
|
||||
result.push(
|
||||
<mark
|
||||
key={`highlight-${match.highlight.id}-${match.startIndex}`}
|
||||
className="content-highlight"
|
||||
className={`content-highlight${levelClass}`}
|
||||
data-highlight-id={match.highlight.id}
|
||||
data-highlight-level={match.highlight.level || 'nostrverse'}
|
||||
title={`Highlighted ${new Date(match.highlight.created_at * 1000).toLocaleDateString()}`}
|
||||
>
|
||||
{highlightedText}
|
||||
@@ -101,8 +103,10 @@ const normalizeWhitespace = (str: string) => str.replace(/\s+/g, ' ').trim()
|
||||
// Helper to create a mark element for a highlight
|
||||
function createMarkElement(highlight: Highlight, matchText: string, highlightStyle: 'marker' | 'underline' = 'marker'): HTMLElement {
|
||||
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-level', highlight.level || 'nostrverse')
|
||||
mark.setAttribute('title', `Highlighted ${new Date(highlight.created_at * 1000).toLocaleDateString()}`)
|
||||
mark.textContent = matchText
|
||||
return mark
|
||||
|
||||
Reference in New Issue
Block a user