mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
133 Commits
bunker-enc
...
v0.7.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d40c49edb0 | ||
|
|
ce5d97fb1f | ||
|
|
ffb8031a05 | ||
|
|
d54e1072b8 | ||
|
|
55defb645c | ||
|
|
1ba9595542 | ||
|
|
340913f15f | ||
|
|
1d6595f754 | ||
|
|
6099e3c6a4 | ||
|
|
ed75bc6059 | ||
|
|
dcfc08287e | ||
|
|
35b2168f9a | ||
|
|
f8a9079e5f | ||
|
|
780996c7c5 | ||
|
|
809437faa6 | ||
|
|
36f14811ae | ||
|
|
8b95af9c49 | ||
|
|
236ade3d2f | ||
|
|
c2e882ec31 | ||
|
|
0a382e77b9 | ||
|
|
a1fd4bfc94 | ||
|
|
530cc20cba | ||
|
|
a275c0a8e3 | ||
|
|
cb43b748e4 | ||
|
|
ff9ce46448 | ||
|
|
1e6718fe1e | ||
|
|
d6a913f2a6 | ||
|
|
8030e2fa00 | ||
|
|
1ff2f28566 | ||
|
|
78457335c6 | ||
|
|
553feb10df | ||
|
|
ba5d7df3bd | ||
|
|
cf3ca2d527 | ||
|
|
06763d5307 | ||
|
|
a08e4fdc24 | ||
|
|
bc7b4ae42d | ||
|
|
4dc1894ef3 | ||
|
|
f00f26dfe0 | ||
|
|
2e59bc9375 | ||
|
|
0d50d05245 | ||
|
|
90c74a8e9d | ||
|
|
a4bad34a90 | ||
|
|
84ff24e06a | ||
|
|
aaf8a9d4fc | ||
|
|
efa6d13726 | ||
|
|
6116dd12bc | ||
|
|
210cdd41ec | ||
|
|
9378b3c9a9 | ||
|
|
973409e82a | ||
|
|
5d6f48b9a8 | ||
|
|
4921427ad4 | ||
|
|
ad8cad29d3 | ||
|
|
8d4a4a04a3 | ||
|
|
1dc44930b4 | ||
|
|
c77907f87a | ||
|
|
9345228e66 | ||
|
|
811362175c | ||
|
|
3d22e7a3cb | ||
|
|
0b0d3c2859 | ||
|
|
1f8d18071c | ||
|
|
a4afe59437 | ||
|
|
1fe3786a3d | ||
|
|
42d265731f | ||
|
|
e4b4b97874 | ||
|
|
1870c307da | ||
|
|
bcb6cfbe97 | ||
|
|
6ba1ce27b7 | ||
|
|
2f620265f4 | ||
|
|
61ae31c6a2 | ||
|
|
b0fcb0e897 | ||
|
|
3b08cd5d23 | ||
|
|
a3a00b8456 | ||
|
|
7fecc0c0c3 | ||
|
|
93d0284fd6 | ||
|
|
94d5089e33 | ||
|
|
5965bc1747 | ||
|
|
0fbf80b04f | ||
|
|
2004ce76c9 | ||
|
|
90c79e34eb | ||
|
|
6ea0fd292c | ||
|
|
193c1f45d4 | ||
|
|
4da3a0347f | ||
|
|
795ef5016e | ||
|
|
83693f7fb0 | ||
|
|
c55e20f341 | ||
|
|
1430d2fc47 | ||
|
|
3f24ccff74 | ||
|
|
51b7e53385 | ||
|
|
8dbb18b1c8 | ||
|
|
88bc7f690e | ||
|
|
29ef21a1fa | ||
|
|
7a75982715 | ||
|
|
f95f8f4bf1 | ||
|
|
9eef5855a9 | ||
|
|
2e70745bab | ||
|
|
8a971dfe52 | ||
|
|
a004e96eca | ||
|
|
ce2432632c | ||
|
|
56b3100c8e | ||
|
|
327d65a128 | ||
|
|
e5a7a07deb | ||
|
|
5bd57573be | ||
|
|
c2223e6b08 | ||
|
|
d1ffc8c3f9 | ||
|
|
5a5cd14df5 | ||
|
|
2fb25da9d6 | ||
|
|
21228cd212 | ||
|
|
e0b86a84ba | ||
|
|
c3a4e41968 | ||
|
|
f3205843ac | ||
|
|
9a03dd312f | ||
|
|
b711b21048 | ||
|
|
8eaba04d91 | ||
|
|
0785b034e4 | ||
|
|
47e698f197 | ||
|
|
3a752a761a | ||
|
|
f6cc49c07a | ||
|
|
5c4fca9cc9 | ||
|
|
536a7ce1fa | ||
|
|
61072aef40 | ||
|
|
b7ec1fcf06 | ||
|
|
d2fd8fb8fe | ||
|
|
68ee1b3122 | ||
|
|
a37735fc1c | ||
|
|
de0f587174 | ||
|
|
f977561779 | ||
|
|
043ea168fb | ||
|
|
5336bafed4 | ||
|
|
c51291bf81 | ||
|
|
489e48fe4d | ||
|
|
744a145e9f | ||
|
|
7ad925dbd3 | ||
|
|
a69298a3a9 |
80
Amber.md
80
Amber.md
@@ -12,6 +12,13 @@
|
|||||||
- After deserialization, recreated the signer with pool context and merged its relays with app `RELAYS` (includes local relays).
|
- After deserialization, recreated the signer with pool context and merged its relays with app `RELAYS` (includes local relays).
|
||||||
- Opened the signer subscription and performed a guarded `connect()` with default permissions including `nip04_encrypt/decrypt` and `nip44_encrypt/decrypt`.
|
- Opened the signer subscription and performed a guarded `connect()` with default permissions including `nip04_encrypt/decrypt` and `nip44_encrypt/decrypt`.
|
||||||
|
|
||||||
|
- **Account queue disabling (CRITICAL)**
|
||||||
|
- `applesauce-accounts` `BaseAccount` queues requests by default - each request waits for the previous one to complete before being sent.
|
||||||
|
- This caused batch decrypt operations to hang: first request would timeout waiting for user interaction, blocking all subsequent requests in the queue.
|
||||||
|
- **Solution**: Set `accounts.disableQueue = true` globally on the `AccountManager` in `App.tsx` during initialization. This applies to all accounts.
|
||||||
|
- Without this, Amber never sees decrypt requests because they're stuck in the account's internal queue.
|
||||||
|
- Reference: https://hzrd149.github.io/applesauce/typedoc/classes/applesauce-accounts.BaseAccount.html#disablequeue
|
||||||
|
|
||||||
- **Probes and timeouts**
|
- **Probes and timeouts**
|
||||||
- Initial probe tried `decrypt('invalid-ciphertext')` → timed out.
|
- Initial probe tried `decrypt('invalid-ciphertext')` → timed out.
|
||||||
- Switched to roundtrip probes: `encrypt(self, ... )` then `decrypt(self, cipher)` for both nip-44 and nip-04.
|
- Switched to roundtrip probes: `encrypt(self, ... )` then `decrypt(self, cipher)` for both nip-44 and nip-04.
|
||||||
@@ -69,9 +76,80 @@ If DECRYPT entries still don’t appear:
|
|||||||
- Ensure the response event is published back to the same relays and correctly addressed to the client (`p` tag set and content encrypted back to client pubkey).
|
- Ensure the response event is published back to the same relays and correctly addressed to the client (`p` tag set and content encrypted back to client pubkey).
|
||||||
- Add activity logging for “Decrypt …” attempts and failures to surface denial/exception states.
|
- Add activity logging for “Decrypt …” attempts and failures to surface denial/exception states.
|
||||||
|
|
||||||
|
## Performance improvements (post-debugging)
|
||||||
|
|
||||||
|
### Non-blocking publish wiring
|
||||||
|
- **Problem**: Awaiting `pool.publish()` completion blocks until all relay sends finish (can take 30s+ with timeouts).
|
||||||
|
- **Solution**: Wrapped `NostrConnectSigner.publishMethod` at app startup to fire-and-forget publish Observable/Promise; responses still arrive via signer subscription.
|
||||||
|
- **Result**: Encrypt/decrypt operations complete in <2s as seen in `/debug` page (NIP-44: ~900ms enc, ~700ms dec; NIP-04: ~1s enc, ~2s dec).
|
||||||
|
|
||||||
|
### Bookmark decryption optimization
|
||||||
|
- **Problem #1**: Sequential decrypt of encrypted bookmark events blocks UI and takes long with multiple events.
|
||||||
|
- **Problem #2**: 30-second timeouts on `nip44.decrypt` meant waiting 30s per event if bunker didn't support nip44.
|
||||||
|
- **Problem #3**: Account request queue blocked all decrypt requests until first one completed (waiting for user interaction).
|
||||||
|
- **Solution**:
|
||||||
|
- Removed all artificial timeouts - let decrypt fail naturally like debug page does.
|
||||||
|
- Added smart encryption detection (NIP-04 has `?iv=`, NIP-44 doesn't) to try the right method first.
|
||||||
|
- **Disabled account queue globally** (`accounts.disableQueue = true`) in `App.tsx` so all requests are sent immediately.
|
||||||
|
- Process sequentially (removed concurrent `mapWithConcurrency` hack).
|
||||||
|
- **Result**: Bookmark decryption is near-instant, limited only by bunker response time and user approval speed.
|
||||||
|
|
||||||
|
## Amethyst-style bookmarks (kind:30001)
|
||||||
|
|
||||||
|
**Important**: Amethyst bookmarks are stored in a **SINGLE** `kind:30001` event with d-tag `"bookmark"` that contains BOTH public AND private bookmarks in different parts of the event.
|
||||||
|
|
||||||
|
### Event structure:
|
||||||
|
- **Event kind**: `30001` (NIP-51 bookmark set)
|
||||||
|
- **d-tag**: `"bookmark"` (identifies this as the Amethyst bookmark list)
|
||||||
|
- **Public bookmarks**: Stored in event `tags` (e.g., `["e", "..."]`, `["a", "..."]`)
|
||||||
|
- **Private bookmarks**: Stored in encrypted `content` field (NIP-04 or NIP-44)
|
||||||
|
|
||||||
|
### Example event:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": 30001,
|
||||||
|
"tags": [
|
||||||
|
["d", "bookmark"], // Identifies this as Amethyst bookmarks
|
||||||
|
["e", "102a2fe..."], // Public bookmark (76 total)
|
||||||
|
["a", "30023:..."] // Public bookmark
|
||||||
|
],
|
||||||
|
"content": "lvOfl7Qb...?iv=5KzDXv09..." // NIP-04 encrypted (416 private bookmarks)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Processing:
|
||||||
|
When this single event is processed:
|
||||||
|
1. **Public tags** → 76 bookmark items with `sourceKind: 30001, isPrivate: false, setName: "bookmark"`
|
||||||
|
2. **Encrypted content** → 416 bookmark items with `sourceKind: 30001, isPrivate: true, setName: "bookmark"`
|
||||||
|
3. Total: 492 bookmarks from one event
|
||||||
|
|
||||||
|
### Encryption detection:
|
||||||
|
- The encrypted `content` field contains a JSON array of private bookmark tags
|
||||||
|
- `Helpers.hasHiddenContent()` from `applesauce-core` only detects **NIP-44** encrypted content
|
||||||
|
- **NIP-04** encrypted content must be detected explicitly by checking for `?iv=` in the content string
|
||||||
|
- Both detection methods are needed in:
|
||||||
|
1. **Display logic** (`Debug.tsx` - `hasEncryptedContent()`) - to show padlock emoji and decrypt button
|
||||||
|
2. **Decryption logic** (`bookmarkProcessing.ts`) - to schedule decrypt jobs
|
||||||
|
|
||||||
|
### Grouping:
|
||||||
|
In the UI, these are separated into two groups:
|
||||||
|
- **Amethyst Lists**: `sourceKind === 30001 && !isPrivate && setName === 'bookmark'` (public items)
|
||||||
|
- **Amethyst Private**: `sourceKind === 30001 && isPrivate && setName === 'bookmark'` (private items)
|
||||||
|
|
||||||
|
Both groups come from the same event, separated by whether they were in public tags or encrypted content.
|
||||||
|
|
||||||
|
### Why this matters:
|
||||||
|
This dual-storage format (public + private in one event) is why we need explicit NIP-04 detection. Without it, `Helpers.hasHiddenContent()` returns `false` and the encrypted content is never decrypted, resulting in 0 private bookmarks despite having encrypted data.
|
||||||
|
|
||||||
## Current conclusion
|
## Current conclusion
|
||||||
|
|
||||||
- Client is configured and publishing requests correctly; encryption proves end‑to‑end path is alive.
|
- Client is configured and publishing requests correctly; encryption proves end‑to‑end path is alive.
|
||||||
- The missing DECRYPT activity in Amber is the blocker. Fixing Amber’s NIP‑46 decrypt handling should resolve bookmark decryption in Boris without further client changes.
|
- Non-blocking publish keeps operations fast (~1-2s for encrypt/decrypt).
|
||||||
|
- **Account queue is GLOBALLY DISABLED** - this was the primary cause of hangs/timeouts.
|
||||||
|
- Smart encryption detection (both NIP-04 and NIP-44) and no artificial timeouts make operations instant.
|
||||||
|
- Sequential processing is cleaner and more predictable than concurrent hacks.
|
||||||
|
- Relay queries now trust EOSE signals instead of arbitrary timeouts, completing in 1-2s instead of 6s.
|
||||||
|
- The missing DECRYPT activity in Amber was partially due to requests never being sent (stuck in queue). With queue disabled globally, Amber receives all decrypt requests immediately.
|
||||||
|
- **Amethyst-style bookmarks** require explicit NIP-04 detection (`?iv=` check) since `Helpers.hasHiddenContent()` only detects NIP-44.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
121
CHANGELOG.md
121
CHANGELOG.md
@@ -7,6 +7,124 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.7.0] - 2025-10-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Login with Bunker (NIP-46) authentication support
|
||||||
|
- Support for remote signing via Nostr Connect protocol
|
||||||
|
- Bunker URI input with validation and error handling
|
||||||
|
- Automatic reconnection on app restore with proper permissions
|
||||||
|
- Signer suggestions in error messages (Amber, nsec.app, Nostrum)
|
||||||
|
- Debug page (`/debug`) for diagnostics and testing
|
||||||
|
- Interactive NIP-04 and NIP-44 encryption/decryption testing
|
||||||
|
- Live performance timing with stopwatch display
|
||||||
|
- Bookmark loading and decryption diagnostics
|
||||||
|
- Real-time bunker logs with filtering and clearing
|
||||||
|
- Version and git commit footer
|
||||||
|
- Progressive bookmark loading with streaming updates
|
||||||
|
- Non-blocking, progressive bookmark updates via callback pattern
|
||||||
|
- Batched background hydration using EventLoader and AddressLoader
|
||||||
|
- Auto-decrypt bookmarks as they arrive from relays
|
||||||
|
- Individual decrypt buttons for encrypted bookmark events
|
||||||
|
- Bookmark grouping toggle (grouped by source vs flat chronological)
|
||||||
|
- Toggle between grouped view and flat chronological list
|
||||||
|
- Amethyst-style bookmark detection and grouping
|
||||||
|
- Display bookmarks even when they only have IDs (content loads in background)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved login UI with better copy and modern design
|
||||||
|
- Personable title and nostr-native language
|
||||||
|
- Highlighted 'your own highlights' in login copy
|
||||||
|
- Simplified button text to single words (Extension, Signer)
|
||||||
|
- Hide login button and user icon when logged out
|
||||||
|
- Hide Extension button when Bunker input is shown
|
||||||
|
- Auto-load bookmarks on login and page mount
|
||||||
|
- Enhanced bunker error messages
|
||||||
|
- Formatted error messages with signer suggestions
|
||||||
|
- Links to nos2x, Amber, nsec.app, and Nostrum signers
|
||||||
|
- Better error handling for missing signer extensions
|
||||||
|
- Centered and constrained bunker input field
|
||||||
|
- Centralized bookmark loading architecture
|
||||||
|
- Single shared bookmark controller for consistent loading
|
||||||
|
- Unified bookmark loading with streaming and auto-decrypt
|
||||||
|
- Consolidated bookmark loading into single centralized function
|
||||||
|
- Bookmarks passed as props throughout component tree
|
||||||
|
- Renamed UI elements for clarity
|
||||||
|
- "Bunker" button renamed to "Signer"
|
||||||
|
- Hide bookmark controls when logged out
|
||||||
|
- Settings version footer improvements
|
||||||
|
- Separate links for version (to GitHub release) and commit (to commit page)
|
||||||
|
- Proper spacing around middot separator
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- NIP-46 bunker signing and decryption
|
||||||
|
- NostrConnectSigner properly reconnects with permissions on app restore
|
||||||
|
- Bunker relays added to relay pool for signing requests
|
||||||
|
- Proper setup of pool and relays before bunker reconnection
|
||||||
|
- Expose nip04/nip44 on NostrConnectAccount for bookmark decryption
|
||||||
|
- Cache wrapped nip04/nip44 objects instead of using getters
|
||||||
|
- Wait for bunker relay connections before marking signer ready
|
||||||
|
- Validate bunker URI (remote must differ from user pubkey)
|
||||||
|
- Accept remote===pubkey for Amber compatibility
|
||||||
|
- Bookmark loading and decryption
|
||||||
|
- Bookmarks load and complete properly with streaming
|
||||||
|
- Auto-decrypt private bookmarks with NIP-04 detection
|
||||||
|
- Include decrypted private bookmarks in sidebar
|
||||||
|
- Skip background event fetching when there are too many IDs
|
||||||
|
- Only build bookmarks from ready events (unencrypted or decrypted)
|
||||||
|
- Restore Debug page decrypt display via onDecryptComplete callback
|
||||||
|
- Make controller onEvent non-blocking for queryEvents completion
|
||||||
|
- Proper timeout handling for bookmark decryption (no hanging)
|
||||||
|
- Smart encryption detection with consistent padlock display
|
||||||
|
- Sequential decryption instead of concurrent to avoid queue issues
|
||||||
|
- Add extraRelays to EventLoader and AddressLoader
|
||||||
|
- PWA cache limit increased to 3 MiB for larger bundles
|
||||||
|
- Extension login error messages with nos2x link
|
||||||
|
- TypeScript and linting errors throughout
|
||||||
|
- Replace empty catch blocks with warnings
|
||||||
|
- Fix explicit any types
|
||||||
|
- Add missing useEffect dependencies
|
||||||
|
- Resolve all linting issues in App.tsx, Debug.tsx, and async utilities
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Non-blocking NIP-46 operations
|
||||||
|
- Fire-and-forget NIP-46 publish for better UI responsiveness
|
||||||
|
- Non-blocking bookmark decryption with sequential processing
|
||||||
|
- Make controller onEvent non-blocking for queryEvents completion
|
||||||
|
- Optimized bookmark loading
|
||||||
|
- Batched background hydration using EventLoader and AddressLoader
|
||||||
|
- Progressive, non-blocking bookmark loading with streaming
|
||||||
|
- Shorter timeouts for debug page bookmark loading
|
||||||
|
- Remove artificial delays from bookmark decryption
|
||||||
|
|
||||||
|
### Refactored
|
||||||
|
|
||||||
|
- Centralized bookmark controller architecture
|
||||||
|
- Extract bookmark streaming helpers and centralize loading
|
||||||
|
- Consolidated bookmark loading into single function
|
||||||
|
- Remove deprecated bookmark service files
|
||||||
|
- Share bookmark controller between components
|
||||||
|
- Debug page organization
|
||||||
|
- Extract VersionFooter component to eliminate duplication
|
||||||
|
- Structured sections with proper layout and styling
|
||||||
|
- Apply settings page styling structure
|
||||||
|
- Simplified bunker implementation following applesauce patterns
|
||||||
|
- Clean up bunker implementation for better maintainability
|
||||||
|
- Import RELAYS from central config (DRY principle)
|
||||||
|
- Update RELAYS list with relay.nsec.app
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Comprehensive Amber.md documentation
|
||||||
|
- Amethyst-style bookmarks section
|
||||||
|
- Bunker decrypt investigation summary
|
||||||
|
- Critical queue disabling requirement
|
||||||
|
- NIP-46 setup and troubleshooting
|
||||||
|
|
||||||
## [0.6.24] - 2025-01-16
|
## [0.6.24] - 2025-01-16
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -1760,7 +1878,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Optimize relay usage following applesauce-relay best practices
|
- Optimize relay usage following applesauce-relay best practices
|
||||||
- Use applesauce-react event models for better profile handling
|
- Use applesauce-react event models for better profile handling
|
||||||
|
|
||||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.24...HEAD
|
[Unreleased]: https://github.com/dergigi/boris/compare/v0.7.0...HEAD
|
||||||
|
[0.7.0]: https://github.com/dergigi/boris/compare/v0.6.24...v0.7.0
|
||||||
[0.6.24]: https://github.com/dergigi/boris/compare/v0.6.23...v0.6.24
|
[0.6.24]: https://github.com/dergigi/boris/compare/v0.6.23...v0.6.24
|
||||||
[0.6.23]: https://github.com/dergigi/boris/compare/v0.6.22...v0.6.23
|
[0.6.23]: https://github.com/dergigi/boris/compare/v0.6.22...v0.6.23
|
||||||
[0.6.21]: https://github.com/dergigi/boris/compare/v0.6.20...v0.6.21
|
[0.6.21]: https://github.com/dergigi/boris/compare/v0.6.20...v0.6.21
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.6.24",
|
"version": "0.7.2",
|
||||||
"description": "A minimal nostr client for bookmark management",
|
"description": "A minimal nostr client for bookmark management",
|
||||||
"homepage": "https://read.withboris.com/",
|
"homepage": "https://read.withboris.com/",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
166
src/App.tsx
166
src/App.tsx
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||||
@@ -19,6 +19,10 @@ import { useOnlineStatus } from './hooks/useOnlineStatus'
|
|||||||
import { RELAYS } from './config/relays'
|
import { RELAYS } from './config/relays'
|
||||||
import { SkeletonThemeProvider } from './components/Skeletons'
|
import { SkeletonThemeProvider } from './components/Skeletons'
|
||||||
import { DebugBus } from './utils/debugBus'
|
import { DebugBus } from './utils/debugBus'
|
||||||
|
import { Bookmark } from './types/bookmarks'
|
||||||
|
import { bookmarkController } from './services/bookmarkController'
|
||||||
|
import { contactsController } from './services/contactsController'
|
||||||
|
import { highlightsController } from './services/highlightsController'
|
||||||
|
|
||||||
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
||||||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
||||||
@@ -26,15 +30,104 @@ const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
|||||||
// AppRoutes component that has access to hooks
|
// AppRoutes component that has access to hooks
|
||||||
function AppRoutes({
|
function AppRoutes({
|
||||||
relayPool,
|
relayPool,
|
||||||
|
eventStore,
|
||||||
showToast
|
showToast
|
||||||
}: {
|
}: {
|
||||||
relayPool: RelayPool
|
relayPool: RelayPool
|
||||||
|
eventStore: EventStore | null
|
||||||
showToast: (message: string) => void
|
showToast: (message: string) => void
|
||||||
}) {
|
}) {
|
||||||
const accountManager = Hooks.useAccountManager()
|
const accountManager = Hooks.useAccountManager()
|
||||||
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
|
|
||||||
|
// Centralized bookmark state (fed by controller)
|
||||||
|
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||||
|
const [bookmarksLoading, setBookmarksLoading] = useState(false)
|
||||||
|
|
||||||
|
// Centralized contacts state (fed by controller)
|
||||||
|
const [contacts, setContacts] = useState<Set<string>>(new Set())
|
||||||
|
const [contactsLoading, setContactsLoading] = useState(false)
|
||||||
|
|
||||||
|
// Subscribe to bookmark controller
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('[bookmark] 🎧 Subscribing to bookmark controller')
|
||||||
|
const unsubBookmarks = bookmarkController.onBookmarks((bookmarks) => {
|
||||||
|
console.log('[bookmark] 📥 Received bookmarks:', bookmarks.length)
|
||||||
|
setBookmarks(bookmarks)
|
||||||
|
})
|
||||||
|
const unsubLoading = bookmarkController.onLoading((loading) => {
|
||||||
|
console.log('[bookmark] 📥 Loading state:', loading)
|
||||||
|
setBookmarksLoading(loading)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.log('[bookmark] 🔇 Unsubscribing from bookmark controller')
|
||||||
|
unsubBookmarks()
|
||||||
|
unsubLoading()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Subscribe to contacts controller
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('[contacts] 🎧 Subscribing to contacts controller')
|
||||||
|
const unsubContacts = contactsController.onContacts((contacts) => {
|
||||||
|
console.log('[contacts] 📥 Received contacts:', contacts.size)
|
||||||
|
setContacts(contacts)
|
||||||
|
})
|
||||||
|
const unsubLoading = contactsController.onLoading((loading) => {
|
||||||
|
console.log('[contacts] 📥 Loading state:', loading)
|
||||||
|
setContactsLoading(loading)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.log('[contacts] 🔇 Unsubscribing from contacts controller')
|
||||||
|
unsubContacts()
|
||||||
|
unsubLoading()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
|
// Auto-load bookmarks, contacts, and highlights when account is ready (on login or page mount)
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeAccount && relayPool) {
|
||||||
|
const pubkey = (activeAccount as { pubkey?: string }).pubkey
|
||||||
|
|
||||||
|
// Load bookmarks
|
||||||
|
if (bookmarks.length === 0 && !bookmarksLoading) {
|
||||||
|
console.log('[bookmark] 🚀 Auto-loading bookmarks on mount/login')
|
||||||
|
bookmarkController.start({ relayPool, activeAccount, accountManager })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load contacts
|
||||||
|
if (pubkey && contacts.size === 0 && !contactsLoading) {
|
||||||
|
console.log('[contacts] 🚀 Auto-loading contacts on mount/login')
|
||||||
|
contactsController.start({ relayPool, pubkey })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load highlights (controller manages its own state)
|
||||||
|
if (pubkey && eventStore && !highlightsController.isLoadedFor(pubkey)) {
|
||||||
|
console.log('[highlights] 🚀 Auto-loading highlights on mount/login')
|
||||||
|
highlightsController.start({ relayPool, eventStore, pubkey })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [activeAccount, relayPool, eventStore, bookmarks.length, bookmarksLoading, contacts.size, contactsLoading, accountManager])
|
||||||
|
|
||||||
|
// Manual refresh (for sidebar button)
|
||||||
|
const handleRefreshBookmarks = useCallback(async () => {
|
||||||
|
if (!relayPool || !activeAccount) {
|
||||||
|
console.warn('[bookmark] Cannot refresh: missing relayPool or activeAccount')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log('[bookmark] 🔄 Manual refresh triggered')
|
||||||
|
bookmarkController.reset()
|
||||||
|
await bookmarkController.start({ relayPool, activeAccount, accountManager })
|
||||||
|
}, [relayPool, activeAccount, accountManager])
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
accountManager.clearActive()
|
accountManager.clearActive()
|
||||||
|
bookmarkController.reset() // Clear bookmarks via controller
|
||||||
|
contactsController.reset() // Clear contacts via controller
|
||||||
|
highlightsController.reset() // Clear highlights via controller
|
||||||
showToast('Logged out successfully')
|
showToast('Logged out successfully')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +139,9 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -55,6 +151,9 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -64,6 +163,9 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -73,6 +175,9 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -82,6 +187,9 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -91,6 +199,9 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -104,6 +215,9 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -113,6 +227,9 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -122,6 +239,9 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -131,6 +251,9 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -140,6 +263,9 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -149,6 +275,9 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -158,6 +287,9 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -167,10 +299,25 @@ function AppRoutes({
|
|||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/debug"
|
||||||
|
element={
|
||||||
|
<Debug
|
||||||
|
relayPool={relayPool}
|
||||||
|
eventStore={eventStore}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
onRefreshBookmarks={handleRefreshBookmarks}
|
||||||
|
onLogout={handleLogout}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="/debug" element={<Debug />} />
|
|
||||||
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
|
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
@@ -189,6 +336,10 @@ function App() {
|
|||||||
const store = new EventStore()
|
const store = new EventStore()
|
||||||
const accounts = new AccountManager()
|
const accounts = new AccountManager()
|
||||||
|
|
||||||
|
// Disable request queueing globally - makes all operations instant
|
||||||
|
// Queue causes requests to wait for user interaction which blocks batch operations
|
||||||
|
accounts.disableQueue = true
|
||||||
|
|
||||||
// Register common account types (needed for deserialization)
|
// Register common account types (needed for deserialization)
|
||||||
registerCommonAccountTypes(accounts)
|
registerCommonAccountTypes(accounts)
|
||||||
|
|
||||||
@@ -199,9 +350,13 @@ function App() {
|
|||||||
// wait for every relay send to finish. Responses still resolve the pending request.
|
// wait for every relay send to finish. Responses still resolve the pending request.
|
||||||
NostrConnectSigner.subscriptionMethod = pool.subscription.bind(pool)
|
NostrConnectSigner.subscriptionMethod = pool.subscription.bind(pool)
|
||||||
NostrConnectSigner.publishMethod = (relays: string[], event: unknown) => {
|
NostrConnectSigner.publishMethod = (relays: string[], event: unknown) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const result: any = pool.publish(relays, event as any)
|
const result: any = pool.publish(relays, event as any)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
if (result && typeof (result as any).subscribe === 'function') {
|
if (result && typeof (result as any).subscribe === 'function') {
|
||||||
try { (result as any).subscribe({ complete: () => {}, error: () => {} }) } catch {}
|
// Subscribe to the observable but ignore completion/errors (fire-and-forget)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
try { (result as any).subscribe({ complete: () => { /* noop */ }, error: () => { /* noop */ } }) } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
// Return an already-resolved promise so upstream await finishes immediately
|
// Return an already-resolved promise so upstream await finishes immediately
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
@@ -358,7 +513,8 @@ function App() {
|
|||||||
// Observable/Promise to upstream to avoid their awaiting of completion.
|
// Observable/Promise to upstream to avoid their awaiting of completion.
|
||||||
const result = originalPublish(relays, event)
|
const result = originalPublish(relays, event)
|
||||||
if (result && typeof (result as { subscribe?: unknown }).subscribe === 'function') {
|
if (result && typeof (result as { subscribe?: unknown }).subscribe === 'function') {
|
||||||
try { (result as { subscribe: (h: { complete?: () => void; error?: (e: unknown) => void }) => unknown }).subscribe({ complete: () => {}, error: () => {} }) } catch {}
|
// Subscribe to the observable but ignore completion/errors (fire-and-forget)
|
||||||
|
try { (result as { subscribe: (h: { complete?: () => void; error?: (e: unknown) => void }) => unknown }).subscribe({ complete: () => { /* noop */ }, error: () => { /* noop */ } }) } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
// If it's a Promise, simply ignore it (no await) so it resolves in the background.
|
// If it's a Promise, simply ignore it (no await) so it resolves in the background.
|
||||||
// Return a benign object so callers that probe for a "subscribe" property
|
// Return a benign object so callers that probe for a "subscribe" property
|
||||||
@@ -531,7 +687,7 @@ function App() {
|
|||||||
<AccountsProvider manager={accountManager}>
|
<AccountsProvider manager={accountManager}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<div className="min-h-screen p-0 max-w-none m-0 relative">
|
<div className="min-h-screen p-0 max-w-none m-0 relative">
|
||||||
<AppRoutes relayPool={relayPool} showToast={showToast} />
|
<AppRoutes relayPool={relayPool} eventStore={eventStore} showToast={showToast} />
|
||||||
<RouteDebug />
|
<RouteDebug />
|
||||||
</div>
|
</div>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useRef, useState } from 'react'
|
import React, { useRef, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate, faHeart, faPlus } from '@fortawesome/free-solid-svg-icons'
|
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate, faHeart, faPlus, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||||
@@ -65,8 +65,18 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
const friendsColor = settings?.highlightColorFriends || '#f97316'
|
const friendsColor = settings?.highlightColorFriends || '#f97316'
|
||||||
const [showAddModal, setShowAddModal] = useState(false)
|
const [showAddModal, setShowAddModal] = useState(false)
|
||||||
const [selectedFilter, setSelectedFilter] = useState<BookmarkFilterType>('all')
|
const [selectedFilter, setSelectedFilter] = useState<BookmarkFilterType>('all')
|
||||||
|
const [groupingMode, setGroupingMode] = useState<'grouped' | 'flat'>(() => {
|
||||||
|
const saved = localStorage.getItem('bookmarkGroupingMode')
|
||||||
|
return saved === 'flat' ? 'flat' : 'grouped'
|
||||||
|
})
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
|
|
||||||
|
const toggleGroupingMode = () => {
|
||||||
|
const newMode = groupingMode === 'grouped' ? 'flat' : 'grouped'
|
||||||
|
setGroupingMode(newMode)
|
||||||
|
localStorage.setItem('bookmarkGroupingMode', newMode)
|
||||||
|
}
|
||||||
|
|
||||||
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
|
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
|
||||||
if (!activeAccount || !relayPool) {
|
if (!activeAccount || !relayPool) {
|
||||||
throw new Error('Please login to create bookmarks')
|
throw new Error('Please login to create bookmarks')
|
||||||
@@ -98,14 +108,18 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
|
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
|
||||||
const bookmarkSets = getBookmarkSets(filteredBookmarks)
|
const bookmarkSets = getBookmarkSets(filteredBookmarks)
|
||||||
|
|
||||||
// Group non-set bookmarks as before
|
// Group non-set bookmarks by source or flatten based on mode
|
||||||
const groups = groupIndividualBookmarks(bookmarksWithoutSet)
|
const groups = groupIndividualBookmarks(bookmarksWithoutSet)
|
||||||
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
|
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
|
||||||
{ key: 'private', title: 'Private Bookmarks', items: groups.privateItems },
|
groupingMode === 'flat'
|
||||||
{ key: 'public', title: 'Public Bookmarks', items: groups.publicItems },
|
? [{ key: 'all', title: `All Bookmarks (${bookmarksWithoutSet.length})`, items: bookmarksWithoutSet }]
|
||||||
{ key: 'web', title: 'Web Bookmarks', items: groups.web },
|
: [
|
||||||
{ key: 'amethyst', title: 'Legacy Bookmarks', items: groups.amethyst }
|
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
|
||||||
]
|
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
|
||||||
|
{ key: 'amethyst-private', title: 'Amethyst Private', items: groups.amethystPrivate },
|
||||||
|
{ key: 'amethyst-public', title: 'Amethyst Lists', items: groups.amethystPublic },
|
||||||
|
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb }
|
||||||
|
]
|
||||||
|
|
||||||
// Add bookmark sets as additional sections
|
// Add bookmark sets as additional sections
|
||||||
bookmarkSets.forEach(set => {
|
bookmarkSets.forEach(set => {
|
||||||
@@ -224,40 +238,49 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
style={{ color: friendsColor }}
|
style={{ color: friendsColor }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="view-mode-right">
|
{activeAccount && (
|
||||||
{onRefresh && (
|
<div className="view-mode-right">
|
||||||
|
{onRefresh && (
|
||||||
|
<IconButton
|
||||||
|
icon={faRotate}
|
||||||
|
onClick={onRefresh}
|
||||||
|
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'}
|
||||||
|
ariaLabel="Refresh bookmarks"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={isRefreshing}
|
||||||
|
spin={isRefreshing}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={faRotate}
|
icon={groupingMode === 'grouped' ? faLayerGroup : faBars}
|
||||||
onClick={onRefresh}
|
onClick={toggleGroupingMode}
|
||||||
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'}
|
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
|
||||||
ariaLabel="Refresh bookmarks"
|
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
disabled={isRefreshing}
|
|
||||||
spin={isRefreshing}
|
|
||||||
/>
|
/>
|
||||||
)}
|
<IconButton
|
||||||
<IconButton
|
icon={faList}
|
||||||
icon={faList}
|
onClick={() => onViewModeChange('compact')}
|
||||||
onClick={() => onViewModeChange('compact')}
|
title="Compact list view"
|
||||||
title="Compact list view"
|
ariaLabel="Compact list view"
|
||||||
ariaLabel="Compact list view"
|
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
|
||||||
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
|
/>
|
||||||
/>
|
<IconButton
|
||||||
<IconButton
|
icon={faThLarge}
|
||||||
icon={faThLarge}
|
onClick={() => onViewModeChange('cards')}
|
||||||
onClick={() => onViewModeChange('cards')}
|
title="Cards view"
|
||||||
title="Cards view"
|
ariaLabel="Cards view"
|
||||||
ariaLabel="Cards view"
|
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
|
||||||
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
|
/>
|
||||||
/>
|
<IconButton
|
||||||
<IconButton
|
icon={faImage}
|
||||||
icon={faImage}
|
onClick={() => onViewModeChange('large')}
|
||||||
onClick={() => onViewModeChange('large')}
|
title="Large preview view"
|
||||||
title="Large preview view"
|
ariaLabel="Large preview view"
|
||||||
ariaLabel="Large preview view"
|
variant={viewMode === 'large' ? 'primary' : 'ghost'}
|
||||||
variant={viewMode === 'large' ? 'primary' : 'ghost'}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
{showAddModal && (
|
{showAddModal && (
|
||||||
<AddBookmarkModal
|
<AddBookmarkModal
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useHighlightCreation } from '../hooks/useHighlightCreation'
|
|||||||
import { useBookmarksUI } from '../hooks/useBookmarksUI'
|
import { useBookmarksUI } from '../hooks/useBookmarksUI'
|
||||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||||
import { useOfflineSync } from '../hooks/useOfflineSync'
|
import { useOfflineSync } from '../hooks/useOfflineSync'
|
||||||
|
import { Bookmark } from '../types/bookmarks'
|
||||||
import ThreePaneLayout from './ThreePaneLayout'
|
import ThreePaneLayout from './ThreePaneLayout'
|
||||||
import Explore from './Explore'
|
import Explore from './Explore'
|
||||||
import Me from './Me'
|
import Me from './Me'
|
||||||
@@ -24,9 +25,18 @@ export type ViewMode = 'compact' | 'cards' | 'large'
|
|||||||
interface BookmarksProps {
|
interface BookmarksProps {
|
||||||
relayPool: RelayPool | null
|
relayPool: RelayPool | null
|
||||||
onLogout: () => void
|
onLogout: () => void
|
||||||
|
bookmarks: Bookmark[]
|
||||||
|
bookmarksLoading: boolean
|
||||||
|
onRefreshBookmarks: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
const Bookmarks: React.FC<BookmarksProps> = ({
|
||||||
|
relayPool,
|
||||||
|
onLogout,
|
||||||
|
bookmarks,
|
||||||
|
bookmarksLoading,
|
||||||
|
onRefreshBookmarks
|
||||||
|
}) => {
|
||||||
const { naddr, npub } = useParams<{ naddr?: string; npub?: string }>()
|
const { naddr, npub } = useParams<{ naddr?: string; npub?: string }>()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -152,8 +162,6 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
}, [navigationState, setIsHighlightsCollapsed, setSelectedHighlightId, navigate, location.pathname])
|
}, [navigationState, setIsHighlightsCollapsed, setSelectedHighlightId, navigate, location.pathname])
|
||||||
|
|
||||||
const {
|
const {
|
||||||
bookmarks,
|
|
||||||
bookmarksLoading,
|
|
||||||
highlights,
|
highlights,
|
||||||
setHighlights,
|
setHighlights,
|
||||||
highlightsLoading,
|
highlightsLoading,
|
||||||
@@ -166,12 +174,13 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
} = useBookmarksData({
|
} = useBookmarksData({
|
||||||
relayPool,
|
relayPool,
|
||||||
activeAccount,
|
activeAccount,
|
||||||
accountManager,
|
|
||||||
naddr,
|
naddr,
|
||||||
externalUrl,
|
externalUrl,
|
||||||
currentArticleCoordinate,
|
currentArticleCoordinate,
|
||||||
currentArticleEventId,
|
currentArticleEventId,
|
||||||
settings
|
settings,
|
||||||
|
eventStore,
|
||||||
|
onRefreshBookmarks
|
||||||
})
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -234,6 +243,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
useExternalUrlLoader({
|
useExternalUrlLoader({
|
||||||
url: externalUrl,
|
url: externalUrl,
|
||||||
relayPool,
|
relayPool,
|
||||||
|
eventStore,
|
||||||
setSelectedUrl,
|
setSelectedUrl,
|
||||||
setReaderContent,
|
setReaderContent,
|
||||||
setReaderLoading,
|
setReaderLoading,
|
||||||
@@ -317,10 +327,10 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
|
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
|
||||||
) : undefined}
|
) : undefined}
|
||||||
me={showMe ? (
|
me={showMe ? (
|
||||||
relayPool ? <Me relayPool={relayPool} activeTab={meTab} /> : null
|
relayPool ? <Me relayPool={relayPool} eventStore={eventStore} activeTab={meTab} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} /> : null
|
||||||
) : undefined}
|
) : undefined}
|
||||||
profile={showProfile && profilePubkey ? (
|
profile={showProfile && profilePubkey ? (
|
||||||
relayPool ? <Me relayPool={relayPool} activeTab={profileTab} pubkey={profilePubkey} /> : null
|
relayPool ? <Me relayPool={relayPool} eventStore={eventStore} activeTab={profileTab} pubkey={profilePubkey} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} /> : null
|
||||||
) : undefined}
|
) : undefined}
|
||||||
support={showSupport ? (
|
support={showSupport ? (
|
||||||
relayPool ? <Support relayPool={relayPool} eventStore={eventStore} settings={settings} /> : null
|
relayPool ? <Support relayPool={relayPool} eventStore={eventStore} settings={settings} /> : null
|
||||||
|
|||||||
@@ -1,18 +1,61 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react'
|
import React, { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faClock, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
import { faClock, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import { Accounts } from 'applesauce-accounts'
|
import { Accounts } from 'applesauce-accounts'
|
||||||
import { NostrConnectSigner } from 'applesauce-signers'
|
import { NostrConnectSigner } from 'applesauce-signers'
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { Helpers, IEventStore } from 'applesauce-core'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
import { getDefaultBunkerPermissions } from '../services/nostrConnect'
|
import { getDefaultBunkerPermissions } from '../services/nostrConnect'
|
||||||
import { DebugBus, type DebugLogEntry } from '../utils/debugBus'
|
import { DebugBus, type DebugLogEntry } from '../utils/debugBus'
|
||||||
import VersionFooter from './VersionFooter'
|
import ThreePaneLayout from './ThreePaneLayout'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
import type { NostrEvent } from '../services/bookmarkHelpers'
|
||||||
|
import { Bookmark } from '../types/bookmarks'
|
||||||
|
import { useBookmarksUI } from '../hooks/useBookmarksUI'
|
||||||
|
import { useSettings } from '../hooks/useSettings'
|
||||||
|
import { fetchHighlights, fetchHighlightsFromAuthors } from '../services/highlightService'
|
||||||
|
import { contactsController } from '../services/contactsController'
|
||||||
|
|
||||||
const defaultPayload = 'The quick brown fox jumps over the lazy dog.'
|
const defaultPayload = 'The quick brown fox jumps over the lazy dog.'
|
||||||
|
|
||||||
const Debug: React.FC = () => {
|
interface DebugProps {
|
||||||
|
relayPool: RelayPool | null
|
||||||
|
eventStore: IEventStore | null
|
||||||
|
bookmarks: Bookmark[]
|
||||||
|
bookmarksLoading: boolean
|
||||||
|
onRefreshBookmarks: () => Promise<void>
|
||||||
|
onLogout: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Debug: React.FC<DebugProps> = ({
|
||||||
|
relayPool,
|
||||||
|
eventStore,
|
||||||
|
bookmarks,
|
||||||
|
bookmarksLoading,
|
||||||
|
onRefreshBookmarks,
|
||||||
|
onLogout
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate()
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
const accountManager = Hooks.useAccountManager()
|
const accountManager = Hooks.useAccountManager()
|
||||||
|
|
||||||
|
const { settings, saveSettings } = useSettings({
|
||||||
|
relayPool,
|
||||||
|
eventStore: eventStore!,
|
||||||
|
pubkey: activeAccount?.pubkey,
|
||||||
|
accountManager
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
isMobile,
|
||||||
|
isCollapsed,
|
||||||
|
setIsCollapsed,
|
||||||
|
viewMode,
|
||||||
|
setViewMode
|
||||||
|
} = useBookmarksUI({ settings })
|
||||||
const [payload, setPayload] = useState<string>(defaultPayload)
|
const [payload, setPayload] = useState<string>(defaultPayload)
|
||||||
const [cipher44, setCipher44] = useState<string>('')
|
const [cipher44, setCipher44] = useState<string>('')
|
||||||
const [cipher04, setCipher04] = useState<string>('')
|
const [cipher04, setCipher04] = useState<string>('')
|
||||||
@@ -30,11 +73,39 @@ const Debug: React.FC = () => {
|
|||||||
const [isBunkerLoading, setIsBunkerLoading] = useState<boolean>(false)
|
const [isBunkerLoading, setIsBunkerLoading] = useState<boolean>(false)
|
||||||
const [bunkerError, setBunkerError] = useState<string | null>(null)
|
const [bunkerError, setBunkerError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Bookmark loading state
|
||||||
|
const [bookmarkEvents, setBookmarkEvents] = useState<NostrEvent[]>([])
|
||||||
|
const [isLoadingBookmarks, setIsLoadingBookmarks] = useState(false)
|
||||||
|
const [bookmarkStats, setBookmarkStats] = useState<{ public: number; private: number } | null>(null)
|
||||||
|
const [tLoadBookmarks, setTLoadBookmarks] = useState<number | null>(null)
|
||||||
|
const [tDecryptBookmarks, setTDecryptBookmarks] = useState<number | null>(null)
|
||||||
|
const [tFirstBookmark, setTFirstBookmark] = useState<number | null>(null)
|
||||||
|
|
||||||
|
// Individual event decryption results
|
||||||
|
const [decryptedEvents, setDecryptedEvents] = useState<Map<string, { public: number; private: number }>>(new Map())
|
||||||
|
|
||||||
|
// Highlight loading state
|
||||||
|
const [highlightMode, setHighlightMode] = useState<'article' | 'url' | 'author'>('author')
|
||||||
|
const [highlightArticleCoord, setHighlightArticleCoord] = useState<string>('')
|
||||||
|
const [highlightUrl, setHighlightUrl] = useState<string>('')
|
||||||
|
const [highlightAuthor, setHighlightAuthor] = useState<string>('')
|
||||||
|
const [isLoadingHighlights, setIsLoadingHighlights] = useState(false)
|
||||||
|
const [highlightEvents, setHighlightEvents] = useState<NostrEvent[]>([])
|
||||||
|
const [tLoadHighlights, setTLoadHighlights] = useState<number | null>(null)
|
||||||
|
const [tFirstHighlight, setTFirstHighlight] = useState<number | null>(null)
|
||||||
|
|
||||||
// Live timing state
|
// Live timing state
|
||||||
const [liveTiming, setLiveTiming] = useState<{
|
const [liveTiming, setLiveTiming] = useState<{
|
||||||
nip44?: { type: 'encrypt' | 'decrypt'; startTime: number }
|
nip44?: { type: 'encrypt' | 'decrypt'; startTime: number }
|
||||||
nip04?: { type: 'encrypt' | 'decrypt'; startTime: number }
|
nip04?: { type: 'encrypt' | 'decrypt'; startTime: number }
|
||||||
|
loadBookmarks?: { startTime: number }
|
||||||
|
decryptBookmarks?: { startTime: number }
|
||||||
|
loadHighlights?: { startTime: number }
|
||||||
}>({})
|
}>({})
|
||||||
|
|
||||||
|
// Web of Trust state
|
||||||
|
const [friendsPubkeys, setFriendsPubkeys] = useState<Set<string>>(new Set())
|
||||||
|
const [friendsButtonLoading, setFriendsButtonLoading] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return DebugBus.subscribe((e) => setLogs(prev => [...prev, e].slice(-300)))
|
return DebugBus.subscribe((e) => setLogs(prev => [...prev, e].slice(-300)))
|
||||||
@@ -55,6 +126,63 @@ const Debug: React.FC = () => {
|
|||||||
const hasNip04 = typeof (signer as { nip04?: { encrypt?: unknown; decrypt?: unknown } } | undefined)?.nip04?.encrypt === 'function'
|
const hasNip04 = typeof (signer as { nip04?: { encrypt?: unknown; decrypt?: unknown } } | undefined)?.nip04?.encrypt === 'function'
|
||||||
const hasNip44 = typeof (signer as { nip44?: { encrypt?: unknown; decrypt?: unknown } } | undefined)?.nip44?.encrypt === 'function'
|
const hasNip44 = typeof (signer as { nip44?: { encrypt?: unknown; decrypt?: unknown } } | undefined)?.nip44?.encrypt === 'function'
|
||||||
|
|
||||||
|
const getKindName = (kind: number): string => {
|
||||||
|
switch (kind) {
|
||||||
|
case KINDS.ListSimple: return 'Simple List (10003)'
|
||||||
|
case KINDS.ListReplaceable: return 'Replaceable List (30003)'
|
||||||
|
case KINDS.List: return 'List (30001)'
|
||||||
|
case KINDS.WebBookmark: return 'Web Bookmark (39701)'
|
||||||
|
default: return `Kind ${kind}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEventSize = (evt: NostrEvent): number => {
|
||||||
|
const content = evt.content || ''
|
||||||
|
const tags = JSON.stringify(evt.tags || [])
|
||||||
|
return content.length + tags.length
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasEncryptedContent = (evt: NostrEvent): boolean => {
|
||||||
|
// Check for NIP-44 encrypted content (detected by Helpers)
|
||||||
|
if (Helpers.hasHiddenContent(evt)) return true
|
||||||
|
|
||||||
|
// Check for NIP-04 encrypted content (base64 with ?iv= suffix)
|
||||||
|
if (evt.content && evt.content.includes('?iv=')) return true
|
||||||
|
|
||||||
|
// Check for encrypted tags
|
||||||
|
if (Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt)) return true
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBookmarkCount = (evt: NostrEvent): { public: number; private: number } => {
|
||||||
|
const publicTags = (evt.tags || []).filter((t: string[]) => t[0] === 'e' || t[0] === 'a')
|
||||||
|
const hasEncrypted = hasEncryptedContent(evt)
|
||||||
|
return {
|
||||||
|
public: publicTags.length,
|
||||||
|
private: hasEncrypted ? 1 : 0 // Can't know exact count until decrypted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatBytes = (bytes: number): string => {
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEventKey = (evt: NostrEvent): string => {
|
||||||
|
if (evt.kind === 30003 || evt.kind === 30001) {
|
||||||
|
// Replaceable: kind:pubkey:dtag
|
||||||
|
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||||
|
return `${evt.kind}:${evt.pubkey}:${dTag}`
|
||||||
|
} else if (evt.kind === 10003) {
|
||||||
|
// Simple list: kind:pubkey
|
||||||
|
return `${evt.kind}:${evt.pubkey}`
|
||||||
|
}
|
||||||
|
// Web bookmarks: use event id (no deduplication)
|
||||||
|
return evt.id
|
||||||
|
}
|
||||||
|
|
||||||
const doEncrypt = async (mode: 'nip44' | 'nip04') => {
|
const doEncrypt = async (mode: 'nip44' | 'nip04') => {
|
||||||
if (!signer || !pubkey) return
|
if (!signer || !pubkey) return
|
||||||
try {
|
try {
|
||||||
@@ -123,6 +251,330 @@ const Debug: React.FC = () => {
|
|||||||
else localStorage.removeItem('debug')
|
else localStorage.removeItem('debug')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleLoadBookmarks = async () => {
|
||||||
|
if (!relayPool || !activeAccount) {
|
||||||
|
DebugBus.warn('debug', 'Cannot load bookmarks: missing relayPool or activeAccount')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoadingBookmarks(true)
|
||||||
|
setBookmarkStats(null)
|
||||||
|
setBookmarkEvents([]) // Clear existing events
|
||||||
|
setDecryptedEvents(new Map())
|
||||||
|
setTFirstBookmark(null)
|
||||||
|
DebugBus.info('debug', 'Loading bookmark events...')
|
||||||
|
|
||||||
|
// Start timing
|
||||||
|
const start = performance.now()
|
||||||
|
let firstEventTime: number | null = null
|
||||||
|
setLiveTiming(prev => ({ ...prev, loadBookmarks: { startTime: start } }))
|
||||||
|
|
||||||
|
// Import controller at runtime to avoid circular dependencies
|
||||||
|
const { bookmarkController } = await import('../services/bookmarkController')
|
||||||
|
|
||||||
|
// Subscribe to raw events for Debug UI display
|
||||||
|
const unsubscribeRaw = bookmarkController.onRawEvent((evt) => {
|
||||||
|
// Track time to first event
|
||||||
|
if (firstEventTime === null) {
|
||||||
|
firstEventTime = performance.now() - start
|
||||||
|
setTFirstBookmark(Math.round(firstEventTime))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event immediately with live deduplication
|
||||||
|
setBookmarkEvents(prev => {
|
||||||
|
const key = getEventKey(evt)
|
||||||
|
const existingIdx = prev.findIndex(e => getEventKey(e) === key)
|
||||||
|
|
||||||
|
if (existingIdx >= 0) {
|
||||||
|
const existing = prev[existingIdx]
|
||||||
|
if ((evt.created_at || 0) > (existing.created_at || 0)) {
|
||||||
|
const newEvents = [...prev]
|
||||||
|
newEvents[existingIdx] = evt
|
||||||
|
return newEvents
|
||||||
|
}
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...prev, evt]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Subscribe to decrypt complete events for Debug UI display
|
||||||
|
const unsubscribeDecrypt = bookmarkController.onDecryptComplete((eventId, publicCount, privateCount) => {
|
||||||
|
console.log('[bunker] ✅ Auto-decrypted:', eventId.slice(0, 8), {
|
||||||
|
public: publicCount,
|
||||||
|
private: privateCount
|
||||||
|
})
|
||||||
|
setDecryptedEvents(prev => new Map(prev).set(eventId, {
|
||||||
|
public: publicCount,
|
||||||
|
private: privateCount
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Start the controller (triggers app bookmark population too)
|
||||||
|
bookmarkController.reset()
|
||||||
|
await bookmarkController.start({ relayPool, activeAccount, accountManager })
|
||||||
|
|
||||||
|
// Clean up subscriptions
|
||||||
|
unsubscribeRaw()
|
||||||
|
unsubscribeDecrypt()
|
||||||
|
|
||||||
|
const ms = Math.round(performance.now() - start)
|
||||||
|
setLiveTiming(prev => ({ ...prev, loadBookmarks: undefined }))
|
||||||
|
setTLoadBookmarks(ms)
|
||||||
|
|
||||||
|
DebugBus.info('debug', `Loaded bookmark events`, { ms })
|
||||||
|
} catch (error) {
|
||||||
|
setLiveTiming(prev => ({ ...prev, loadBookmarks: undefined }))
|
||||||
|
DebugBus.error('debug', 'Failed to load bookmarks', error instanceof Error ? error.message : String(error))
|
||||||
|
} finally {
|
||||||
|
setIsLoadingBookmarks(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearBookmarks = () => {
|
||||||
|
setBookmarkEvents([])
|
||||||
|
setBookmarkStats(null)
|
||||||
|
setTLoadBookmarks(null)
|
||||||
|
setTDecryptBookmarks(null)
|
||||||
|
setTFirstBookmark(null)
|
||||||
|
setDecryptedEvents(new Map())
|
||||||
|
DebugBus.info('debug', 'Cleared bookmark data')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLoadHighlights = async () => {
|
||||||
|
if (!relayPool) {
|
||||||
|
DebugBus.warn('debug', 'Cannot load highlights: missing relayPool')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to logged-in user's highlights if no specific query provided
|
||||||
|
const getValue = () => {
|
||||||
|
if (highlightMode === 'article') return highlightArticleCoord.trim()
|
||||||
|
if (highlightMode === 'url') return highlightUrl.trim()
|
||||||
|
const authorValue = highlightAuthor.trim()
|
||||||
|
return authorValue || pubkey || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = getValue()
|
||||||
|
if (!value) {
|
||||||
|
DebugBus.warn('debug', 'Please provide a value to query or log in')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoadingHighlights(true)
|
||||||
|
setHighlightEvents([])
|
||||||
|
setTFirstHighlight(null)
|
||||||
|
DebugBus.info('debug', `Loading highlights (${highlightMode}: ${value})...`)
|
||||||
|
|
||||||
|
const start = performance.now()
|
||||||
|
setLiveTiming(prev => ({ ...prev, loadHighlights: { startTime: start } }))
|
||||||
|
|
||||||
|
let firstEventTime: number | null = null
|
||||||
|
const seenIds = new Set<string>()
|
||||||
|
|
||||||
|
// Import highlight services
|
||||||
|
const { queryEvents } = await import('../services/dataFetch')
|
||||||
|
const { KINDS } = await import('../config/kinds')
|
||||||
|
|
||||||
|
// Build filter based on mode
|
||||||
|
let filter: { kinds: number[]; '#a'?: string[]; '#r'?: string[]; authors?: string[] }
|
||||||
|
if (highlightMode === 'article') {
|
||||||
|
filter = { kinds: [KINDS.Highlights], '#a': [value] }
|
||||||
|
} else if (highlightMode === 'url') {
|
||||||
|
filter = { kinds: [KINDS.Highlights], '#r': [value] }
|
||||||
|
} else {
|
||||||
|
filter = { kinds: [KINDS.Highlights], authors: [value] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = await queryEvents(relayPool, filter, {
|
||||||
|
onEvent: (evt) => {
|
||||||
|
if (seenIds.has(evt.id)) return
|
||||||
|
seenIds.add(evt.id)
|
||||||
|
|
||||||
|
if (firstEventTime === null) {
|
||||||
|
firstEventTime = performance.now() - start
|
||||||
|
setTFirstHighlight(Math.round(firstEventTime))
|
||||||
|
}
|
||||||
|
|
||||||
|
setHighlightEvents(prev => [...prev, evt])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const elapsed = Math.round(performance.now() - start)
|
||||||
|
setTLoadHighlights(elapsed)
|
||||||
|
setLiveTiming(prev => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
||||||
|
const { loadHighlights, ...rest } = prev
|
||||||
|
return rest
|
||||||
|
})
|
||||||
|
|
||||||
|
DebugBus.info('debug', `Loaded ${events.length} highlight events in ${elapsed}ms`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load highlights:', err)
|
||||||
|
DebugBus.error('debug', `Failed to load highlights: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
|
} finally {
|
||||||
|
setIsLoadingHighlights(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearHighlights = () => {
|
||||||
|
setHighlightEvents([])
|
||||||
|
setTLoadHighlights(null)
|
||||||
|
setTFirstHighlight(null)
|
||||||
|
DebugBus.info('debug', 'Cleared highlight data')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLoadMyHighlights = async () => {
|
||||||
|
if (!relayPool || !activeAccount?.pubkey) {
|
||||||
|
DebugBus.warn('debug', 'Please log in to load your highlights')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const start = performance.now()
|
||||||
|
setHighlightEvents([])
|
||||||
|
setIsLoadingHighlights(true)
|
||||||
|
setTLoadHighlights(null)
|
||||||
|
setTFirstHighlight(null)
|
||||||
|
DebugBus.info('debug', 'Loading my highlights...')
|
||||||
|
try {
|
||||||
|
let firstEventTime: number | null = null
|
||||||
|
await fetchHighlights(relayPool, activeAccount.pubkey, (h) => {
|
||||||
|
if (firstEventTime === null) {
|
||||||
|
firstEventTime = performance.now() - start
|
||||||
|
setTFirstHighlight(Math.round(firstEventTime))
|
||||||
|
}
|
||||||
|
setHighlightEvents(prev => {
|
||||||
|
if (prev.some(x => x.id === h.id)) return prev
|
||||||
|
const next = [...prev, { ...h, pubkey: h.pubkey, created_at: h.created_at, id: h.id, kind: 9802, tags: [], content: h.content, sig: '' } as NostrEvent]
|
||||||
|
return next.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
})
|
||||||
|
}, settings, false, eventStore || undefined)
|
||||||
|
} finally {
|
||||||
|
setIsLoadingHighlights(false)
|
||||||
|
const elapsed = Math.round(performance.now() - start)
|
||||||
|
setTLoadHighlights(elapsed)
|
||||||
|
DebugBus.info('debug', `Loaded my highlights in ${elapsed}ms`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLoadFriendsHighlights = async () => {
|
||||||
|
if (!relayPool || !activeAccount?.pubkey) {
|
||||||
|
DebugBus.warn('debug', 'Please log in to load friends highlights')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get contacts from centralized controller (should already be loaded by App.tsx)
|
||||||
|
const contacts = contactsController.getContacts()
|
||||||
|
if (contacts.size === 0) {
|
||||||
|
DebugBus.warn('debug', 'No friends found. Make sure you have contacts loaded.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = performance.now()
|
||||||
|
setHighlightEvents([])
|
||||||
|
setIsLoadingHighlights(true)
|
||||||
|
setTLoadHighlights(null)
|
||||||
|
setTFirstHighlight(null)
|
||||||
|
DebugBus.info('debug', `Loading highlights from ${contacts.size} friends (using cached contacts)...`)
|
||||||
|
|
||||||
|
let firstEventTime: number | null = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchHighlightsFromAuthors(relayPool, Array.from(contacts), (h) => {
|
||||||
|
if (firstEventTime === null) {
|
||||||
|
firstEventTime = performance.now() - start
|
||||||
|
setTFirstHighlight(Math.round(firstEventTime))
|
||||||
|
}
|
||||||
|
setHighlightEvents(prev => {
|
||||||
|
if (prev.some(x => x.id === h.id)) return prev
|
||||||
|
const next = [...prev, { ...h, pubkey: h.pubkey, created_at: h.created_at, id: h.id, kind: 9802, tags: [], content: h.content, sig: '' } as NostrEvent]
|
||||||
|
return next.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
})
|
||||||
|
}, eventStore || undefined)
|
||||||
|
} finally {
|
||||||
|
setIsLoadingHighlights(false)
|
||||||
|
const elapsed = Math.round(performance.now() - start)
|
||||||
|
setTLoadHighlights(elapsed)
|
||||||
|
DebugBus.info('debug', `Loaded friends highlights in ${elapsed}ms`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLoadNostrverseHighlights = async () => {
|
||||||
|
if (!relayPool) {
|
||||||
|
DebugBus.warn('debug', 'Relay pool not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const start = performance.now()
|
||||||
|
setHighlightEvents([])
|
||||||
|
setIsLoadingHighlights(true)
|
||||||
|
setTLoadHighlights(null)
|
||||||
|
setTFirstHighlight(null)
|
||||||
|
DebugBus.info('debug', 'Loading nostrverse highlights (kind:9802)...')
|
||||||
|
try {
|
||||||
|
let firstEventTime: number | null = null
|
||||||
|
const seenIds = new Set<string>()
|
||||||
|
const { queryEvents } = await import('../services/dataFetch')
|
||||||
|
|
||||||
|
const events = await queryEvents(relayPool, { kinds: [9802], limit: 500 }, {
|
||||||
|
onEvent: (evt) => {
|
||||||
|
if (seenIds.has(evt.id)) return
|
||||||
|
seenIds.add(evt.id)
|
||||||
|
if (firstEventTime === null) {
|
||||||
|
firstEventTime = performance.now() - start
|
||||||
|
setTFirstHighlight(Math.round(firstEventTime))
|
||||||
|
}
|
||||||
|
setHighlightEvents(prev => [...prev, evt])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
DebugBus.info('debug', `Loaded ${events.length} nostrverse highlights`)
|
||||||
|
} finally {
|
||||||
|
setIsLoadingHighlights(false)
|
||||||
|
const elapsed = Math.round(performance.now() - start)
|
||||||
|
setTLoadHighlights(elapsed)
|
||||||
|
DebugBus.info('debug', `Loaded nostrverse highlights in ${elapsed}ms`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLoadFriendsList = async () => {
|
||||||
|
if (!relayPool || !activeAccount?.pubkey) {
|
||||||
|
DebugBus.warn('debug', 'Please log in to load friends list')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setFriendsButtonLoading(true)
|
||||||
|
DebugBus.info('debug', 'Loading friends list via controller...')
|
||||||
|
|
||||||
|
// Clear current list
|
||||||
|
setFriendsPubkeys(new Set())
|
||||||
|
|
||||||
|
// Subscribe to controller updates to see streaming
|
||||||
|
const unsubscribe = contactsController.onContacts((contacts) => {
|
||||||
|
console.log('[debug] Received contacts update:', contacts.size)
|
||||||
|
setFriendsPubkeys(new Set(contacts))
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Force reload to see streaming behavior
|
||||||
|
await contactsController.start({ relayPool, pubkey: activeAccount.pubkey, force: true })
|
||||||
|
const final = contactsController.getContacts()
|
||||||
|
setFriendsPubkeys(new Set(final))
|
||||||
|
DebugBus.info('debug', `Loaded ${final.size} friends from controller`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[debug] Failed to load friends:', err)
|
||||||
|
DebugBus.error('debug', `Failed to load friends: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
|
} finally {
|
||||||
|
unsubscribe()
|
||||||
|
setFriendsButtonLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const friendsNpubs = useMemo(() => {
|
||||||
|
return Array.from(friendsPubkeys).map(pk => nip19.npubEncode(pk))
|
||||||
|
}, [friendsPubkeys])
|
||||||
|
|
||||||
const handleBunkerLogin = async () => {
|
const handleBunkerLogin = async () => {
|
||||||
if (!bunkerUri.trim()) {
|
if (!bunkerUri.trim()) {
|
||||||
setBunkerError('Please enter a bunker URI')
|
setBunkerError('Please enter a bunker URI')
|
||||||
@@ -184,13 +636,23 @@ const Debug: React.FC = () => {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const Stat = ({ label, value, mode, type }: {
|
const getBookmarkLiveTiming = (operation: 'loadBookmarks' | 'decryptBookmarks' | 'loadHighlights') => {
|
||||||
|
const timing = liveTiming[operation]
|
||||||
|
if (timing) {
|
||||||
|
const elapsed = Math.round(performance.now() - timing.startTime)
|
||||||
|
return elapsed
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const Stat = ({ label, value, mode, type, bookmarkOp }: {
|
||||||
label: string;
|
label: string;
|
||||||
value?: string | number | null;
|
value?: string | number | null;
|
||||||
mode?: 'nip44' | 'nip04';
|
mode?: 'nip44' | 'nip04';
|
||||||
type?: 'encrypt' | 'decrypt';
|
type?: 'encrypt' | 'decrypt';
|
||||||
|
bookmarkOp?: 'loadBookmarks' | 'decryptBookmarks' | 'loadHighlights';
|
||||||
}) => {
|
}) => {
|
||||||
const liveValue = mode && type ? getLiveTiming(mode, type) : null
|
const liveValue = bookmarkOp ? getBookmarkLiveTiming(bookmarkOp) : (mode && type ? getLiveTiming(mode, type) : null)
|
||||||
const isLive = !!liveValue
|
const isLive = !!liveValue
|
||||||
|
|
||||||
let displayValue: string
|
let displayValue: string
|
||||||
@@ -214,7 +676,7 @@ const Debug: React.FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const debugContent = (
|
||||||
<div className="settings-view">
|
<div className="settings-view">
|
||||||
<div className="settings-header">
|
<div className="settings-header">
|
||||||
<h2>Debug</h2>
|
<h2>Debug</h2>
|
||||||
@@ -225,9 +687,17 @@ const Debug: React.FC = () => {
|
|||||||
|
|
||||||
<div className="settings-content">
|
<div className="settings-content">
|
||||||
|
|
||||||
{/* Bunker Login Section */}
|
{/* Account Connection Section */}
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h3 className="section-title">Bunker Connection</h3>
|
<h3 className="section-title">
|
||||||
|
{activeAccount
|
||||||
|
? activeAccount.type === 'extension'
|
||||||
|
? 'Browser Extension'
|
||||||
|
: activeAccount.type === 'nostr-connect'
|
||||||
|
? 'Bunker Connection'
|
||||||
|
: 'Account Connection'
|
||||||
|
: 'Account Connection'}
|
||||||
|
</h3>
|
||||||
{!activeAccount ? (
|
{!activeAccount ? (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm opacity-70 mb-3">Connect to your bunker (Nostr Connect signer) to enable encryption/decryption testing</div>
|
<div className="text-sm opacity-70 mb-3">Connect to your bunker (Nostr Connect signer) to enable encryption/decryption testing</div>
|
||||||
@@ -255,7 +725,13 @@ const Debug: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm opacity-70">Connected to bunker</div>
|
<div className="text-sm opacity-70">
|
||||||
|
{activeAccount.type === 'extension'
|
||||||
|
? 'Connected via browser extension'
|
||||||
|
: activeAccount.type === 'nostr-connect'
|
||||||
|
? 'Connected to bunker'
|
||||||
|
: 'Connected'}
|
||||||
|
</div>
|
||||||
<div className="text-sm font-mono">{pubkey}</div>
|
<div className="text-sm font-mono">{pubkey}</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -350,6 +826,286 @@ const Debug: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bookmark Loading Section */}
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Bookmark Loading</h3>
|
||||||
|
<div className="text-sm opacity-70 mb-3">Test bookmark loading with auto-decryption (kinds: 10003, 30003, 30001, 39701)</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mb-3 items-center">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleLoadBookmarks}
|
||||||
|
disabled={isLoadingBookmarks || !relayPool || !activeAccount}
|
||||||
|
>
|
||||||
|
{isLoadingBookmarks ? (
|
||||||
|
<>
|
||||||
|
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-2" />
|
||||||
|
Loading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Load Bookmarks'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary ml-auto"
|
||||||
|
onClick={handleClearBookmarks}
|
||||||
|
disabled={bookmarkEvents.length === 0 && !bookmarkStats}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3 flex gap-2 flex-wrap">
|
||||||
|
<Stat label="total" value={tLoadBookmarks} bookmarkOp="loadBookmarks" />
|
||||||
|
<Stat label="first event" value={tFirstBookmark} />
|
||||||
|
<Stat label="decrypt" value={tDecryptBookmarks} bookmarkOp="decryptBookmarks" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bookmarkStats && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="text-sm opacity-70 mb-2">Decrypted Bookmarks:</div>
|
||||||
|
<div className="font-mono text-xs p-2 bg-gray-100 dark:bg-gray-800 rounded">
|
||||||
|
<div>Public: {bookmarkStats.public}</div>
|
||||||
|
<div>Private: {bookmarkStats.private}</div>
|
||||||
|
<div className="font-semibold mt-1">Total: {bookmarkStats.public + bookmarkStats.private}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bookmarkEvents.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="text-sm opacity-70 mb-2">Loaded Events ({bookmarkEvents.length}):</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{bookmarkEvents.map((evt, idx) => {
|
||||||
|
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1]
|
||||||
|
const titleTag = evt.tags?.find((t: string[]) => t[0] === 'title')?.[1]
|
||||||
|
const size = getEventSize(evt)
|
||||||
|
const counts = getBookmarkCount(evt)
|
||||||
|
const hasEncrypted = hasEncryptedContent(evt)
|
||||||
|
const decryptResult = decryptedEvents.get(evt.id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={idx} className="font-mono text-xs p-2 bg-gray-100 dark:bg-gray-800 rounded">
|
||||||
|
<div className="font-semibold mb-1">{getKindName(evt.kind)}</div>
|
||||||
|
{dTag && <div className="opacity-70">d-tag: {dTag}</div>}
|
||||||
|
{titleTag && <div className="opacity-70">title: {titleTag}</div>}
|
||||||
|
<div className="mt-1">
|
||||||
|
<div>Size: {formatBytes(size)}</div>
|
||||||
|
<div>Public: {counts.public}</div>
|
||||||
|
{hasEncrypted && <div>🔒 Has encrypted content</div>}
|
||||||
|
</div>
|
||||||
|
{decryptResult && (
|
||||||
|
<div className="mt-1 text-[11px] opacity-80">
|
||||||
|
<div>✓ Decrypted: {decryptResult.public} public, {decryptResult.private} private</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="opacity-50 mt-1 text-[10px] break-all">ID: {evt.id}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Highlight Loading Section */}
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Highlight Loading</h3>
|
||||||
|
<div className="text-sm opacity-70 mb-3">Test highlight loading with EOSE-based queryEvents (kind: 9802). Author mode defaults to your highlights.</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="text-sm opacity-70 mb-2">Query Mode:</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={highlightMode === 'article'}
|
||||||
|
onChange={() => setHighlightMode('article')}
|
||||||
|
/>
|
||||||
|
<span>Article (#a)</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={highlightMode === 'url'}
|
||||||
|
onChange={() => setHighlightMode('url')}
|
||||||
|
/>
|
||||||
|
<span>URL (#r)</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={highlightMode === 'author'}
|
||||||
|
onChange={() => setHighlightMode('author')}
|
||||||
|
/>
|
||||||
|
<span>Author</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
{highlightMode === 'article' && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input w-full"
|
||||||
|
placeholder="30023:pubkey:identifier"
|
||||||
|
value={highlightArticleCoord}
|
||||||
|
onChange={(e) => setHighlightArticleCoord(e.target.value)}
|
||||||
|
disabled={isLoadingHighlights}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{highlightMode === 'url' && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input w-full"
|
||||||
|
placeholder="https://example.com/article"
|
||||||
|
value={highlightUrl}
|
||||||
|
onChange={(e) => setHighlightUrl(e.target.value)}
|
||||||
|
disabled={isLoadingHighlights}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{highlightMode === 'author' && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input w-full"
|
||||||
|
placeholder={pubkey ? `${pubkey.slice(0, 16)}... (logged-in user)` : 'pubkey (hex)'}
|
||||||
|
value={highlightAuthor}
|
||||||
|
onChange={(e) => setHighlightAuthor(e.target.value)}
|
||||||
|
disabled={isLoadingHighlights}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mb-3 items-center">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleLoadHighlights}
|
||||||
|
disabled={isLoadingHighlights || !relayPool}
|
||||||
|
>
|
||||||
|
{isLoadingHighlights ? (
|
||||||
|
<>
|
||||||
|
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-2" />
|
||||||
|
Loading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Load Highlights'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary ml-auto"
|
||||||
|
onClick={handleClearHighlights}
|
||||||
|
disabled={highlightEvents.length === 0}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3 text-sm opacity-70">Quick load options:</div>
|
||||||
|
<div className="flex gap-2 mb-3 flex-wrap">
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary text-sm"
|
||||||
|
onClick={handleLoadMyHighlights}
|
||||||
|
disabled={isLoadingHighlights || !relayPool || !activeAccount}
|
||||||
|
>
|
||||||
|
Load My Highlights
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary text-sm"
|
||||||
|
onClick={handleLoadFriendsHighlights}
|
||||||
|
disabled={isLoadingHighlights || !relayPool || !activeAccount}
|
||||||
|
>
|
||||||
|
Load Friends Highlights
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary text-sm"
|
||||||
|
onClick={handleLoadNostrverseHighlights}
|
||||||
|
disabled={isLoadingHighlights || !relayPool}
|
||||||
|
>
|
||||||
|
Load Nostrverse Highlights
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3 flex gap-2 flex-wrap">
|
||||||
|
<Stat label="total" value={tLoadHighlights} bookmarkOp="loadHighlights" />
|
||||||
|
<Stat label="first event" value={tFirstHighlight} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{highlightEvents.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="text-sm opacity-70 mb-2">Loaded Highlights ({highlightEvents.length}):</div>
|
||||||
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||||
|
{highlightEvents.map((evt, idx) => {
|
||||||
|
const content = evt.content || ''
|
||||||
|
const shortContent = content.length > 100 ? content.substring(0, 100) + '...' : content
|
||||||
|
const aTag = evt.tags?.find((t: string[]) => t[0] === 'a')?.[1]
|
||||||
|
const rTag = evt.tags?.find((t: string[]) => t[0] === 'r')?.[1]
|
||||||
|
const eTag = evt.tags?.find((t: string[]) => t[0] === 'e')?.[1]
|
||||||
|
const contextTag = evt.tags?.find((t: string[]) => t[0] === 'context')?.[1]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={idx} className="font-mono text-xs p-2 bg-gray-100 dark:bg-gray-800 rounded">
|
||||||
|
<div className="font-semibold mb-1">Highlight #{idx + 1}</div>
|
||||||
|
<div className="opacity-70 mb-1">
|
||||||
|
<div>Author: {evt.pubkey.slice(0, 16)}...</div>
|
||||||
|
<div>Created: {new Date(evt.created_at * 1000).toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="font-semibold text-[11px]">Content:</div>
|
||||||
|
<div className="italic">"{shortContent}"</div>
|
||||||
|
</div>
|
||||||
|
{contextTag && (
|
||||||
|
<div className="mt-1 text-[11px] opacity-70">
|
||||||
|
<div>Context: {contextTag.substring(0, 60)}...</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{aTag && <div className="mt-1 text-[11px] opacity-70">#a: {aTag}</div>}
|
||||||
|
{rTag && <div className="mt-1 text-[11px] opacity-70">#r: {rTag}</div>}
|
||||||
|
{eTag && <div className="mt-1 text-[11px] opacity-70">#e: {eTag.slice(0, 16)}...</div>}
|
||||||
|
<div className="opacity-50 mt-1 text-[10px] break-all">ID: {evt.id}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Web of Trust Section */}
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Web of Trust</h3>
|
||||||
|
<div className="text-sm opacity-70 mb-3">Load your followed contacts (friends) for highlight fetching:</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleLoadFriendsList}
|
||||||
|
disabled={friendsButtonLoading || !relayPool || !activeAccount}
|
||||||
|
>
|
||||||
|
{friendsButtonLoading ? (
|
||||||
|
<>
|
||||||
|
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-2" />
|
||||||
|
Loading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Load Friends'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{friendsPubkeys.size > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="text-sm opacity-70 mb-2">Friends Count: {friendsNpubs.length}</div>
|
||||||
|
<div className="font-mono text-xs max-h-48 overflow-y-auto bg-gray-100 dark:bg-gray-800 p-3 rounded space-y-1">
|
||||||
|
{friendsNpubs.map(npub => (
|
||||||
|
<div key={npub} title={npub} className="truncate hover:text-clip hover:whitespace-normal cursor-pointer">
|
||||||
|
{npub}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Debug Logs Section */}
|
{/* Debug Logs Section */}
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h3 className="section-title">Debug Logs</h3>
|
<h3 className="section-title">Debug Logs</h3>
|
||||||
@@ -386,10 +1142,61 @@ const Debug: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VersionFooter />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThreePaneLayout
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
isHighlightsCollapsed={true}
|
||||||
|
isSidebarOpen={false}
|
||||||
|
showSettings={false}
|
||||||
|
showSupport={true}
|
||||||
|
bookmarks={bookmarks}
|
||||||
|
bookmarksLoading={bookmarksLoading}
|
||||||
|
viewMode={viewMode}
|
||||||
|
isRefreshing={false}
|
||||||
|
lastFetchTime={null}
|
||||||
|
onToggleSidebar={isMobile ? () => {} : () => setIsCollapsed(!isCollapsed)}
|
||||||
|
onLogout={onLogout}
|
||||||
|
onViewModeChange={setViewMode}
|
||||||
|
onOpenSettings={() => navigate('/settings')}
|
||||||
|
onRefresh={onRefreshBookmarks}
|
||||||
|
relayPool={relayPool}
|
||||||
|
eventStore={eventStore}
|
||||||
|
readerLoading={false}
|
||||||
|
readerContent={undefined}
|
||||||
|
selectedUrl={undefined}
|
||||||
|
settings={settings}
|
||||||
|
onSaveSettings={saveSettings}
|
||||||
|
onCloseSettings={() => navigate('/')}
|
||||||
|
classifiedHighlights={[]}
|
||||||
|
showHighlights={false}
|
||||||
|
selectedHighlightId={undefined}
|
||||||
|
highlightVisibility={{ nostrverse: true, friends: true, mine: true }}
|
||||||
|
onHighlightClick={() => {}}
|
||||||
|
onTextSelection={() => {}}
|
||||||
|
onClearSelection={() => {}}
|
||||||
|
currentUserPubkey={activeAccount?.pubkey}
|
||||||
|
followedPubkeys={new Set()}
|
||||||
|
activeAccount={activeAccount}
|
||||||
|
currentArticle={null}
|
||||||
|
highlights={[]}
|
||||||
|
highlightsLoading={false}
|
||||||
|
onToggleHighlightsPanel={() => {}}
|
||||||
|
onSelectUrl={() => {}}
|
||||||
|
onToggleHighlights={() => {}}
|
||||||
|
onRefreshHighlights={() => {}}
|
||||||
|
onHighlightVisibilityChange={() => {}}
|
||||||
|
highlightButtonRef={{ current: null }}
|
||||||
|
onCreateHighlight={() => {}}
|
||||||
|
hasActiveAccount={!!activeAccount}
|
||||||
|
toastMessage={undefined}
|
||||||
|
toastType={undefined}
|
||||||
|
onClearToast={() => {}}
|
||||||
|
support={debugContent}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Debug
|
export default Debug
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react'
|
import React, { useState, useEffect, useMemo, useCallback } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
|
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { IEventStore } from 'applesauce-core'
|
import { IEventStore, Helpers } from 'applesauce-core'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19, NostrEvent } from 'nostr-tools'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { fetchContacts } from '../services/contactService'
|
import { fetchContacts } from '../services/contactService'
|
||||||
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
||||||
import { fetchHighlightsFromAuthors } from '../services/highlightService'
|
import { fetchHighlightsFromAuthors } from '../services/highlightService'
|
||||||
import { fetchProfiles } from '../services/profileService'
|
import { fetchProfiles } from '../services/profileService'
|
||||||
import { fetchNostrverseBlogPosts, fetchNostrverseHighlights } from '../services/nostrverseService'
|
import { fetchNostrverseBlogPosts, fetchNostrverseHighlights } from '../services/nostrverseService'
|
||||||
|
import { highlightsController } from '../services/highlightsController'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
import BlogPostCard from './BlogPostCard'
|
import BlogPostCard from './BlogPostCard'
|
||||||
@@ -22,6 +23,12 @@ import { usePullToRefresh } from 'use-pull-to-refresh'
|
|||||||
import RefreshIndicator from './RefreshIndicator'
|
import RefreshIndicator from './RefreshIndicator'
|
||||||
import { classifyHighlights } from '../utils/highlightClassification'
|
import { classifyHighlights } from '../utils/highlightClassification'
|
||||||
import { HighlightVisibility } from './HighlightsPanel'
|
import { HighlightVisibility } from './HighlightsPanel'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { eventToHighlight } from '../services/highlightEventProcessor'
|
||||||
|
import { useStoreTimeline } from '../hooks/useStoreTimeline'
|
||||||
|
import { dedupeHighlightsById, dedupeWritingsByReplaceable } from '../utils/dedupe'
|
||||||
|
|
||||||
|
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||||
|
|
||||||
interface ExploreProps {
|
interface ExploreProps {
|
||||||
relayPool: RelayPool
|
relayPool: RelayPool
|
||||||
@@ -42,13 +49,41 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||||
|
|
||||||
// Visibility filters (defaults from settings, or friends only)
|
// Get myHighlights directly from controller
|
||||||
|
const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
|
||||||
|
const [myHighlightsLoading, setMyHighlightsLoading] = useState(false)
|
||||||
|
|
||||||
|
// Load cached content from event store (instant display)
|
||||||
|
const cachedHighlights = useStoreTimeline(eventStore, { kinds: [KINDS.Highlights] }, eventToHighlight, [])
|
||||||
|
|
||||||
|
const toBlogPostPreview = useCallback((event: NostrEvent): BlogPostPreview => ({
|
||||||
|
event,
|
||||||
|
title: getArticleTitle(event) || 'Untitled',
|
||||||
|
summary: getArticleSummary(event),
|
||||||
|
image: getArticleImage(event),
|
||||||
|
published: getArticlePublished(event),
|
||||||
|
author: event.pubkey
|
||||||
|
}), [])
|
||||||
|
|
||||||
|
const cachedWritings = useStoreTimeline(eventStore, { kinds: [30023] }, toBlogPostPreview, [])
|
||||||
|
|
||||||
|
// Visibility filters (defaults from settings)
|
||||||
const [visibility, setVisibility] = useState<HighlightVisibility>({
|
const [visibility, setVisibility] = useState<HighlightVisibility>({
|
||||||
nostrverse: settings?.defaultHighlightVisibilityNostrverse ?? false,
|
nostrverse: settings?.defaultExploreScopeNostrverse ?? false,
|
||||||
friends: settings?.defaultHighlightVisibilityFriends ?? true,
|
friends: settings?.defaultExploreScopeFriends ?? true,
|
||||||
mine: settings?.defaultHighlightVisibilityMine ?? false
|
mine: settings?.defaultExploreScopeMine ?? false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Subscribe to highlights controller
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubHighlights = highlightsController.onHighlights(setMyHighlights)
|
||||||
|
const unsubLoading = highlightsController.onLoading(setMyHighlightsLoading)
|
||||||
|
return () => {
|
||||||
|
unsubHighlights()
|
||||||
|
unsubLoading()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Update local state when prop changes
|
// Update local state when prop changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (propActiveTab) {
|
if (propActiveTab) {
|
||||||
@@ -68,14 +103,34 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
// Seed from in-memory cache if available to avoid empty flash
|
// Seed from in-memory cache if available to avoid empty flash
|
||||||
// Use functional update to check current state without creating dependency
|
const memoryCachedPosts = getCachedPosts(activeAccount.pubkey)
|
||||||
const cachedPosts = getCachedPosts(activeAccount.pubkey)
|
if (memoryCachedPosts && memoryCachedPosts.length > 0) {
|
||||||
if (cachedPosts && cachedPosts.length > 0) {
|
setBlogPosts(prev => prev.length === 0 ? memoryCachedPosts : prev)
|
||||||
setBlogPosts(prev => prev.length === 0 ? cachedPosts : prev)
|
|
||||||
}
|
}
|
||||||
const cachedHighlights = getCachedHighlights(activeAccount.pubkey)
|
const memoryCachedHighlights = getCachedHighlights(activeAccount.pubkey)
|
||||||
if (cachedHighlights && cachedHighlights.length > 0) {
|
if (memoryCachedHighlights && memoryCachedHighlights.length > 0) {
|
||||||
setHighlights(prev => prev.length === 0 ? cachedHighlights : prev)
|
setHighlights(prev => prev.length === 0 ? memoryCachedHighlights : prev)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed with cached content from event store (instant display)
|
||||||
|
if (cachedHighlights.length > 0 || myHighlights.length > 0) {
|
||||||
|
const merged = dedupeHighlightsById([...cachedHighlights, ...myHighlights])
|
||||||
|
setHighlights(prev => {
|
||||||
|
const all = dedupeHighlightsById([...prev, ...merged])
|
||||||
|
return all.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed with cached writings from event store
|
||||||
|
if (cachedWritings.length > 0) {
|
||||||
|
setBlogPosts(prev => {
|
||||||
|
const all = dedupeWritingsByReplaceable([...prev, ...cachedWritings])
|
||||||
|
return all.sort((a, b) => {
|
||||||
|
const timeA = a.published || a.event.created_at
|
||||||
|
const timeB = b.published || b.event.created_at
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the user's contacts (friends)
|
// Fetch the user's contacts (friends)
|
||||||
@@ -97,8 +152,31 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
relayUrls,
|
relayUrls,
|
||||||
(post) => {
|
(post) => {
|
||||||
setBlogPosts((prev) => {
|
setBlogPosts((prev) => {
|
||||||
const exists = prev.some(p => p.event.id === post.event.id)
|
// Deduplicate by author:d-tag (replaceable event key)
|
||||||
if (exists) return prev
|
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const key = `${post.author}:${dTag}`
|
||||||
|
const existingIndex = prev.findIndex(p => {
|
||||||
|
const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
return `${p.author}:${pDTag}` === key
|
||||||
|
})
|
||||||
|
|
||||||
|
// If exists, only replace if this one is newer
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
const existing = prev[existingIndex]
|
||||||
|
if (post.event.created_at <= existing.event.created_at) {
|
||||||
|
return prev // Keep existing (newer or same)
|
||||||
|
}
|
||||||
|
// Replace with newer version
|
||||||
|
const next = [...prev]
|
||||||
|
next[existingIndex] = post
|
||||||
|
return next.sort((a, b) => {
|
||||||
|
const timeA = a.published || a.event.created_at
|
||||||
|
const timeB = b.published || b.event.created_at
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// New post, add it
|
||||||
const next = [...prev, post]
|
const next = [...prev, post]
|
||||||
return next.sort((a, b) => {
|
return next.sort((a, b) => {
|
||||||
const timeA = a.published || a.event.created_at
|
const timeA = a.published || a.event.created_at
|
||||||
@@ -110,9 +188,27 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
}
|
}
|
||||||
).then((all) => {
|
).then((all) => {
|
||||||
setBlogPosts((prev) => {
|
setBlogPosts((prev) => {
|
||||||
const byId = new Map(prev.map(p => [p.event.id, p]))
|
// Deduplicate by author:d-tag (replaceable event key)
|
||||||
for (const post of all) byId.set(post.event.id, post)
|
const byKey = new Map<string, BlogPostPreview>()
|
||||||
const merged = Array.from(byId.values()).sort((a, b) => {
|
|
||||||
|
// Add existing posts
|
||||||
|
for (const p of prev) {
|
||||||
|
const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const key = `${p.author}:${dTag}`
|
||||||
|
byKey.set(key, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge in new posts (keeping newer versions)
|
||||||
|
for (const post of all) {
|
||||||
|
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const key = `${post.author}:${dTag}`
|
||||||
|
const existing = byKey.get(key)
|
||||||
|
if (!existing || post.event.created_at > existing.event.created_at) {
|
||||||
|
byKey.set(key, post)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = Array.from(byKey.values()).sort((a, b) => {
|
||||||
const timeA = a.published || a.event.created_at
|
const timeA = a.published || a.event.created_at
|
||||||
const timeB = b.published || b.event.created_at
|
const timeB = b.published || b.event.created_at
|
||||||
return timeB - timeA
|
return timeB - timeA
|
||||||
@@ -160,33 +256,21 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
const [friendsPosts, friendsHighlights, nostrversePosts, nostriverseHighlights] = await Promise.all([
|
const [friendsPosts, friendsHighlights, nostrversePosts, nostriverseHighlights] = await Promise.all([
|
||||||
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls),
|
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls),
|
||||||
fetchHighlightsFromAuthors(relayPool, contactsArray),
|
fetchHighlightsFromAuthors(relayPool, contactsArray),
|
||||||
fetchNostrverseBlogPosts(relayPool, relayUrls, 50),
|
fetchNostrverseBlogPosts(relayPool, relayUrls, 50, eventStore || undefined),
|
||||||
fetchNostrverseHighlights(relayPool, 100)
|
fetchNostrverseHighlights(relayPool, 100, eventStore || undefined)
|
||||||
])
|
])
|
||||||
|
|
||||||
// Merge and deduplicate all posts
|
// Merge and deduplicate all posts
|
||||||
const allPosts = [...friendsPosts, ...nostrversePosts]
|
const allPosts = [...friendsPosts, ...nostrversePosts]
|
||||||
const postsByKey = new Map<string, BlogPostPreview>()
|
const uniquePosts = dedupeWritingsByReplaceable(allPosts).sort((a, b) => {
|
||||||
for (const post of allPosts) {
|
|
||||||
const key = `${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1] || ''}`
|
|
||||||
const existing = postsByKey.get(key)
|
|
||||||
if (!existing || post.event.created_at > existing.event.created_at) {
|
|
||||||
postsByKey.set(key, post)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const uniquePosts = Array.from(postsByKey.values()).sort((a, b) => {
|
|
||||||
const timeA = a.published || a.event.created_at
|
const timeA = a.published || a.event.created_at
|
||||||
const timeB = b.published || b.event.created_at
|
const timeB = b.published || b.event.created_at
|
||||||
return timeB - timeA
|
return timeB - timeA
|
||||||
})
|
})
|
||||||
|
|
||||||
// Merge and deduplicate all highlights
|
// Merge and deduplicate all highlights (mine from controller + friends + nostrverse)
|
||||||
const allHighlights = [...friendsHighlights, ...nostriverseHighlights]
|
const allHighlights = [...myHighlights, ...friendsHighlights, ...nostriverseHighlights]
|
||||||
const highlightsByKey = new Map<string, Highlight>()
|
const uniqueHighlights = dedupeHighlightsById(allHighlights).sort((a, b) => b.created_at - a.created_at)
|
||||||
for (const highlight of allHighlights) {
|
|
||||||
highlightsByKey.set(highlight.id, highlight)
|
|
||||||
}
|
|
||||||
const uniqueHighlights = Array.from(highlightsByKey.values()).sort((a, b) => b.created_at - a.created_at)
|
|
||||||
|
|
||||||
// Fetch profiles for all blog post authors to cache them
|
// Fetch profiles for all blog post authors to cache them
|
||||||
if (uniquePosts.length > 0) {
|
if (uniquePosts.length > 0) {
|
||||||
@@ -211,7 +295,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadData()
|
loadData()
|
||||||
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings])
|
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings, myHighlights, cachedHighlights, cachedWritings])
|
||||||
|
|
||||||
// Pull-to-refresh
|
// Pull-to-refresh
|
||||||
const { isRefreshing, pullPosition } = usePullToRefresh({
|
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||||
@@ -340,7 +424,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
|
|
||||||
// Show content progressively - no blocking error screens
|
// Show content progressively - no blocking error screens
|
||||||
const hasData = highlights.length > 0 || blogPosts.length > 0
|
const hasData = highlights.length > 0 || blogPosts.length > 0
|
||||||
const showSkeletons = loading && !hasData
|
const showSkeletons = (loading || myHighlightsLoading) && !hasData
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="explore-container">
|
<div className="explore-container">
|
||||||
@@ -422,7 +506,9 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{renderTabContent()}
|
<div key={activeTab}>
|
||||||
|
{renderTabContent()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faPuzzlePiece, faShieldHalved, faCircleInfo } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import { Accounts } from 'applesauce-accounts'
|
import { Accounts } from 'applesauce-accounts'
|
||||||
import { NostrConnectSigner } from 'applesauce-signers'
|
import { NostrConnectSigner } from 'applesauce-signers'
|
||||||
@@ -9,7 +11,7 @@ const LoginOptions: React.FC = () => {
|
|||||||
const [showBunkerInput, setShowBunkerInput] = useState(false)
|
const [showBunkerInput, setShowBunkerInput] = useState(false)
|
||||||
const [bunkerUri, setBunkerUri] = useState('')
|
const [bunkerUri, setBunkerUri] = useState('')
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<React.ReactNode | null>(null)
|
||||||
|
|
||||||
const handleExtensionLogin = async () => {
|
const handleExtensionLogin = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -20,7 +22,24 @@ const LoginOptions: React.FC = () => {
|
|||||||
accountManager.setActive(account)
|
accountManager.setActive(account)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Extension login failed:', err)
|
console.error('Extension login failed:', err)
|
||||||
setError('Login failed. Please install a nostr browser extension and try again.')
|
const errorMessage = err instanceof Error ? err.message : String(err)
|
||||||
|
|
||||||
|
// Check if extension is not installed
|
||||||
|
if (errorMessage.includes('Signer extension missing') || errorMessage.includes('window.nostr') || errorMessage.includes('not found') || errorMessage.includes('undefined') || errorMessage.toLowerCase().includes('extension missing')) {
|
||||||
|
setError(
|
||||||
|
<>
|
||||||
|
No browser extension found. Please install{' '}
|
||||||
|
<a href="https://chromewebstore.google.com/detail/nos2x/kpgefcfmnafjgpblomihpgmejjdanjjp" target="_blank" rel="noopener noreferrer">
|
||||||
|
nos2x
|
||||||
|
</a>
|
||||||
|
{' '}or another nostr extension.
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
} else if (errorMessage.includes('denied') || errorMessage.includes('rejected') || errorMessage.includes('cancel')) {
|
||||||
|
setError('Authentication was cancelled or denied.')
|
||||||
|
} else {
|
||||||
|
setError(`Authentication failed: ${errorMessage}`)
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
@@ -33,7 +52,19 @@ const LoginOptions: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!bunkerUri.startsWith('bunker://')) {
|
if (!bunkerUri.startsWith('bunker://')) {
|
||||||
setError('Invalid bunker URI. Must start with bunker://')
|
setError(
|
||||||
|
<>
|
||||||
|
Invalid bunker URI. Must start with bunker://. Don't have a signer? Give{' '}
|
||||||
|
<a href="https://github.com/greenart7c3/Amber" target="_blank" rel="noopener noreferrer">
|
||||||
|
Amber
|
||||||
|
</a>
|
||||||
|
{' '}or{' '}
|
||||||
|
<a href="https://testflight.apple.com/join/DUzVMDMK" target="_blank" rel="noopener noreferrer">
|
||||||
|
Aegis
|
||||||
|
</a>
|
||||||
|
{' '}a try.
|
||||||
|
</>
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +97,22 @@ const LoginOptions: React.FC = () => {
|
|||||||
if (errorMessage.toLowerCase().includes('permission') || errorMessage.toLowerCase().includes('unauthorized')) {
|
if (errorMessage.toLowerCase().includes('permission') || errorMessage.toLowerCase().includes('unauthorized')) {
|
||||||
setError('Your bunker connection is missing signing permissions. Reconnect and approve signing.')
|
setError('Your bunker connection is missing signing permissions. Reconnect and approve signing.')
|
||||||
} else {
|
} else {
|
||||||
setError(errorMessage)
|
// Show helpful message for bunker connection failures
|
||||||
|
setError(
|
||||||
|
<>
|
||||||
|
Failed: {errorMessage}
|
||||||
|
<br /><br />
|
||||||
|
Don't have a signer? Give{' '}
|
||||||
|
<a href="https://github.com/greenart7c3/Amber" target="_blank" rel="noopener noreferrer">
|
||||||
|
Amber
|
||||||
|
</a>
|
||||||
|
{' '}or{' '}
|
||||||
|
<a href="https://testflight.apple.com/join/DUzVMDMK" target="_blank" rel="noopener noreferrer">
|
||||||
|
Aegis
|
||||||
|
</a>
|
||||||
|
{' '}a try.
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
@@ -74,103 +120,87 @@ const LoginOptions: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="empty-state">
|
<div className="empty-state login-container">
|
||||||
<p style={{ marginBottom: '1rem' }}>Login with:</p>
|
<div className="login-content">
|
||||||
|
<h2 className="login-title">Hi! I'm Boris.</h2>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', maxWidth: '300px', margin: '0 auto' }}>
|
<p className="login-description">
|
||||||
<button
|
Connect your npub to see your bookmarks, explore long-form articles, and create <mark className="login-highlight">your own highlights</mark>.
|
||||||
onClick={handleExtensionLogin}
|
</p>
|
||||||
disabled={isLoading}
|
|
||||||
style={{
|
|
||||||
padding: '0.75rem 1.5rem',
|
|
||||||
fontSize: '1rem',
|
|
||||||
cursor: isLoading ? 'wait' : 'pointer',
|
|
||||||
opacity: isLoading ? 0.6 : 1
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isLoading && !showBunkerInput ? 'Connecting...' : 'Extension'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{!showBunkerInput ? (
|
<div className="login-buttons">
|
||||||
<button
|
{!showBunkerInput && (
|
||||||
onClick={() => setShowBunkerInput(true)}
|
<button
|
||||||
disabled={isLoading}
|
onClick={handleExtensionLogin}
|
||||||
style={{
|
|
||||||
padding: '0.75rem 1.5rem',
|
|
||||||
fontSize: '1rem',
|
|
||||||
cursor: isLoading ? 'wait' : 'pointer',
|
|
||||||
opacity: isLoading ? 0.6 : 1
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Bunker
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="bunker://..."
|
|
||||||
value={bunkerUri}
|
|
||||||
onChange={(e) => setBunkerUri(e.target.value)}
|
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
style={{
|
className="login-button login-button-primary"
|
||||||
padding: '0.75rem',
|
>
|
||||||
fontSize: '0.9rem',
|
<FontAwesomeIcon icon={faPuzzlePiece} />
|
||||||
width: '100%',
|
<span>{isLoading ? 'Connecting...' : 'Extension'}</span>
|
||||||
boxSizing: 'border-box'
|
</button>
|
||||||
}}
|
)}
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
{!showBunkerInput ? (
|
||||||
handleBunkerLogin()
|
<button
|
||||||
}
|
onClick={() => setShowBunkerInput(true)}
|
||||||
}}
|
disabled={isLoading}
|
||||||
/>
|
className="login-button login-button-secondary"
|
||||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
>
|
||||||
<button
|
<FontAwesomeIcon icon={faShieldHalved} />
|
||||||
onClick={handleBunkerLogin}
|
<span>Signer</span>
|
||||||
disabled={isLoading || !bunkerUri.trim()}
|
</button>
|
||||||
style={{
|
) : (
|
||||||
padding: '0.5rem 1rem',
|
<div className="bunker-input-container">
|
||||||
fontSize: '0.9rem',
|
<input
|
||||||
flex: 1,
|
type="text"
|
||||||
cursor: isLoading || !bunkerUri.trim() ? 'not-allowed' : 'pointer',
|
placeholder="bunker://..."
|
||||||
opacity: isLoading || !bunkerUri.trim() ? 0.6 : 1
|
value={bunkerUri}
|
||||||
}}
|
onChange={(e) => setBunkerUri(e.target.value)}
|
||||||
>
|
|
||||||
{isLoading && showBunkerInput ? 'Connecting...' : 'Connect'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowBunkerInput(false)
|
|
||||||
setBunkerUri('')
|
|
||||||
setError(null)
|
|
||||||
}}
|
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
style={{
|
className="bunker-input"
|
||||||
padding: '0.5rem 1rem',
|
onKeyDown={(e) => {
|
||||||
fontSize: '0.9rem',
|
if (e.key === 'Enter') {
|
||||||
cursor: isLoading ? 'not-allowed' : 'pointer',
|
handleBunkerLogin()
|
||||||
opacity: isLoading ? 0.6 : 1
|
}
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
Cancel
|
<div className="bunker-actions">
|
||||||
</button>
|
<button
|
||||||
|
onClick={handleBunkerLogin}
|
||||||
|
disabled={isLoading || !bunkerUri.trim()}
|
||||||
|
className="bunker-button bunker-connect"
|
||||||
|
>
|
||||||
|
{isLoading && showBunkerInput ? 'Connecting...' : 'Connect'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowBunkerInput(false)
|
||||||
|
setBunkerUri('')
|
||||||
|
setError(null)
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="bunker-button bunker-cancel"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="login-error">
|
||||||
|
<FontAwesomeIcon icon={faCircleInfo} />
|
||||||
|
<span>{error}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
<p className="login-footer">
|
||||||
{error && (
|
New to nostr? Start here:{' '}
|
||||||
<p style={{ color: 'var(--color-error, #ef4444)', marginTop: '1rem', fontSize: '0.9rem' }}>
|
<a href="https://nstart.me/" target="_blank" rel="noopener noreferrer">
|
||||||
{error}
|
nstart.me
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
<p style={{ marginTop: '1.5rem', fontSize: '0.9rem' }}>
|
|
||||||
If you aren't on nostr yet, start here:{' '}
|
|
||||||
<a href="https://nstart.me/" target="_blank" rel="noopener noreferrer">
|
|
||||||
nstart.me
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect, useMemo } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare, faLink } from '@fortawesome/free-solid-svg-icons'
|
import { faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare, faLink, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
|
import { IEventStore, Helpers } from 'applesauce-core'
|
||||||
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
|
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19, NostrEvent } from 'nostr-tools'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { HighlightItem } from './HighlightItem'
|
import { HighlightItem } from './HighlightItem'
|
||||||
import { fetchHighlights } from '../services/highlightService'
|
import { fetchHighlights } from '../services/highlightService'
|
||||||
import { fetchBookmarks } from '../services/bookmarkService'
|
import { highlightsController } from '../services/highlightsController'
|
||||||
import { fetchAllReads, ReadItem } from '../services/readsService'
|
import { fetchAllReads, ReadItem } from '../services/readsService'
|
||||||
import { fetchLinks } from '../services/linksService'
|
import { fetchLinks } from '../services/linksService'
|
||||||
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
|
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
|
||||||
@@ -32,11 +33,19 @@ import { filterByReadingProgress } from '../utils/readingProgressUtils'
|
|||||||
import { deriveReadsFromBookmarks } from '../utils/readsFromBookmarks'
|
import { deriveReadsFromBookmarks } from '../utils/readsFromBookmarks'
|
||||||
import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks'
|
import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks'
|
||||||
import { mergeReadItem } from '../utils/readItemMerge'
|
import { mergeReadItem } from '../utils/readItemMerge'
|
||||||
|
import { useStoreTimeline } from '../hooks/useStoreTimeline'
|
||||||
|
import { eventToHighlight } from '../services/highlightEventProcessor'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
|
||||||
|
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||||
|
|
||||||
interface MeProps {
|
interface MeProps {
|
||||||
relayPool: RelayPool
|
relayPool: RelayPool
|
||||||
|
eventStore: IEventStore
|
||||||
activeTab?: TabType
|
activeTab?: TabType
|
||||||
pubkey?: string // Optional pubkey for viewing other users' profiles
|
pubkey?: string // Optional pubkey for viewing other users' profiles
|
||||||
|
bookmarks: Bookmark[] // From centralized App.tsx state
|
||||||
|
bookmarksLoading?: boolean // From centralized App.tsx state (reserved for future use)
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
|
type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
|
||||||
@@ -44,7 +53,13 @@ type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
|
|||||||
// Valid reading progress filters
|
// Valid reading progress filters
|
||||||
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed']
|
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed']
|
||||||
|
|
||||||
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => {
|
const Me: React.FC<MeProps> = ({
|
||||||
|
relayPool,
|
||||||
|
eventStore,
|
||||||
|
activeTab: propActiveTab,
|
||||||
|
pubkey: propPubkey,
|
||||||
|
bookmarks
|
||||||
|
}) => {
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { filter: urlFilter } = useParams<{ filter?: string }>()
|
const { filter: urlFilter } = useParams<{ filter?: string }>()
|
||||||
@@ -54,7 +69,6 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
const viewingPubkey = propPubkey || activeAccount?.pubkey
|
const viewingPubkey = propPubkey || activeAccount?.pubkey
|
||||||
const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey)
|
const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey)
|
||||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
|
||||||
const [reads, setReads] = useState<ReadItem[]>([])
|
const [reads, setReads] = useState<ReadItem[]>([])
|
||||||
const [, setReadsMap] = useState<Map<string, ReadItem>>(new Map())
|
const [, setReadsMap] = useState<Map<string, ReadItem>>(new Map())
|
||||||
const [links, setLinks] = useState<ReadItem[]>([])
|
const [links, setLinks] = useState<ReadItem[]>([])
|
||||||
@@ -62,9 +76,47 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
const [writings, setWritings] = useState<BlogPostPreview[]>([])
|
const [writings, setWritings] = useState<BlogPostPreview[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [loadedTabs, setLoadedTabs] = useState<Set<TabType>>(new Set())
|
const [loadedTabs, setLoadedTabs] = useState<Set<TabType>>(new Set())
|
||||||
|
|
||||||
|
// Get myHighlights directly from controller
|
||||||
|
const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
|
||||||
|
const [myHighlightsLoading, setMyHighlightsLoading] = useState(false)
|
||||||
|
|
||||||
|
// Load cached data from event store for OTHER profiles (not own)
|
||||||
|
const cachedHighlights = useStoreTimeline(
|
||||||
|
eventStore,
|
||||||
|
!isOwnProfile && viewingPubkey ? { kinds: [KINDS.Highlights], authors: [viewingPubkey] } : { kinds: [KINDS.Highlights], limit: 0 },
|
||||||
|
eventToHighlight,
|
||||||
|
[viewingPubkey, isOwnProfile]
|
||||||
|
)
|
||||||
|
|
||||||
|
const toBlogPostPreview = useMemo(() => (event: NostrEvent): BlogPostPreview => ({
|
||||||
|
event,
|
||||||
|
title: getArticleTitle(event) || 'Untitled',
|
||||||
|
summary: getArticleSummary(event),
|
||||||
|
image: getArticleImage(event),
|
||||||
|
published: getArticlePublished(event),
|
||||||
|
author: event.pubkey
|
||||||
|
}), [])
|
||||||
|
|
||||||
|
const cachedWritings = useStoreTimeline(
|
||||||
|
eventStore,
|
||||||
|
!isOwnProfile && viewingPubkey ? { kinds: [30023], authors: [viewingPubkey] } : { kinds: [30023], limit: 0 },
|
||||||
|
toBlogPostPreview,
|
||||||
|
[viewingPubkey, isOwnProfile]
|
||||||
|
)
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||||
const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all')
|
const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all')
|
||||||
|
const [groupingMode, setGroupingMode] = useState<'grouped' | 'flat'>(() => {
|
||||||
|
const saved = localStorage.getItem('bookmarkGroupingMode')
|
||||||
|
return saved === 'flat' ? 'flat' : 'grouped'
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleGroupingMode = () => {
|
||||||
|
const newMode = groupingMode === 'grouped' ? 'flat' : 'grouped'
|
||||||
|
setGroupingMode(newMode)
|
||||||
|
localStorage.setItem('bookmarkGroupingMode', newMode)
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize reading progress filter from URL param
|
// Initialize reading progress filter from URL param
|
||||||
const initialFilter = urlFilter && VALID_FILTERS.includes(urlFilter as ReadingProgressFilterType)
|
const initialFilter = urlFilter && VALID_FILTERS.includes(urlFilter as ReadingProgressFilterType)
|
||||||
@@ -72,6 +124,20 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
: 'all'
|
: 'all'
|
||||||
const [readingProgressFilter, setReadingProgressFilter] = useState<ReadingProgressFilterType>(initialFilter)
|
const [readingProgressFilter, setReadingProgressFilter] = useState<ReadingProgressFilterType>(initialFilter)
|
||||||
|
|
||||||
|
// Subscribe to highlights controller
|
||||||
|
useEffect(() => {
|
||||||
|
// Get initial state immediately
|
||||||
|
setMyHighlights(highlightsController.getHighlights())
|
||||||
|
|
||||||
|
// Subscribe to updates
|
||||||
|
const unsubHighlights = highlightsController.onHighlights(setMyHighlights)
|
||||||
|
const unsubLoading = highlightsController.onLoading(setMyHighlightsLoading)
|
||||||
|
return () => {
|
||||||
|
unsubHighlights()
|
||||||
|
unsubLoading()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Update local state when prop changes
|
// Update local state when prop changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (propActiveTab) {
|
if (propActiveTab) {
|
||||||
@@ -108,8 +174,20 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (!hasBeenLoaded) setLoading(true)
|
if (!hasBeenLoaded) setLoading(true)
|
||||||
const userHighlights = await fetchHighlights(relayPool, viewingPubkey)
|
|
||||||
setHighlights(userHighlights)
|
// For own profile, highlights come from controller subscription (sync effect handles it)
|
||||||
|
// For viewing other users, seed with cached data then fetch fresh
|
||||||
|
if (!isOwnProfile) {
|
||||||
|
// Seed with cached highlights first
|
||||||
|
if (cachedHighlights.length > 0) {
|
||||||
|
setHighlights(cachedHighlights.sort((a, b) => b.created_at - a.created_at))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch fresh highlights
|
||||||
|
const userHighlights = await fetchHighlights(relayPool, viewingPubkey)
|
||||||
|
setHighlights(userHighlights)
|
||||||
|
}
|
||||||
|
|
||||||
setLoadedTabs(prev => new Set(prev).add('highlights'))
|
setLoadedTabs(prev => new Set(prev).add('highlights'))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load highlights:', err)
|
console.error('Failed to load highlights:', err)
|
||||||
@@ -125,6 +203,17 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (!hasBeenLoaded) setLoading(true)
|
if (!hasBeenLoaded) setLoading(true)
|
||||||
|
|
||||||
|
// Seed with cached writings first
|
||||||
|
if (!isOwnProfile && cachedWritings.length > 0) {
|
||||||
|
setWritings(cachedWritings.sort((a, b) => {
|
||||||
|
const timeA = a.published || a.event.created_at
|
||||||
|
const timeB = b.published || b.event.created_at
|
||||||
|
return timeB - timeA
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch fresh writings
|
||||||
const userWritings = await fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
|
const userWritings = await fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
|
||||||
setWritings(userWritings)
|
setWritings(userWritings)
|
||||||
setLoadedTabs(prev => new Set(prev).add('writings'))
|
setLoadedTabs(prev => new Set(prev).add('writings'))
|
||||||
@@ -142,14 +231,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (!hasBeenLoaded) setLoading(true)
|
if (!hasBeenLoaded) setLoading(true)
|
||||||
try {
|
// Bookmarks come from centralized loading in App.tsx
|
||||||
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
|
|
||||||
setBookmarks(newBookmarks)
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Failed to load bookmarks:', err)
|
|
||||||
setBookmarks([])
|
|
||||||
}
|
|
||||||
setLoadedTabs(prev => new Set(prev).add('reading-list'))
|
setLoadedTabs(prev => new Set(prev).add('reading-list'))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load reading list:', err)
|
console.error('Failed to load reading list:', err)
|
||||||
@@ -166,22 +248,8 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
try {
|
try {
|
||||||
if (!hasBeenLoaded) setLoading(true)
|
if (!hasBeenLoaded) setLoading(true)
|
||||||
|
|
||||||
// Ensure bookmarks are loaded
|
// Derive reads from bookmarks immediately (bookmarks come from centralized loading in App.tsx)
|
||||||
let fetchedBookmarks: Bookmark[] = bookmarks
|
const initialReads = deriveReadsFromBookmarks(bookmarks)
|
||||||
if (bookmarks.length === 0) {
|
|
||||||
try {
|
|
||||||
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
|
|
||||||
fetchedBookmarks = newBookmarks
|
|
||||||
setBookmarks(newBookmarks)
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Failed to load bookmarks:', err)
|
|
||||||
fetchedBookmarks = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive reads from bookmarks immediately
|
|
||||||
const initialReads = deriveReadsFromBookmarks(fetchedBookmarks)
|
|
||||||
const initialMap = new Map(initialReads.map(item => [item.id, item]))
|
const initialMap = new Map(initialReads.map(item => [item.id, item]))
|
||||||
setReadsMap(initialMap)
|
setReadsMap(initialMap)
|
||||||
setReads(initialReads)
|
setReads(initialReads)
|
||||||
@@ -190,7 +258,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
|
|
||||||
// Background enrichment: merge reading progress and mark-as-read
|
// Background enrichment: merge reading progress and mark-as-read
|
||||||
// Only update items that are already in our map
|
// Only update items that are already in our map
|
||||||
fetchAllReads(relayPool, viewingPubkey, fetchedBookmarks, (item) => {
|
fetchAllReads(relayPool, viewingPubkey, bookmarks, (item) => {
|
||||||
console.log('📈 [Reads] Enrichment item received:', {
|
console.log('📈 [Reads] Enrichment item received:', {
|
||||||
id: item.id.slice(0, 20) + '...',
|
id: item.id.slice(0, 20) + '...',
|
||||||
progress: item.readingProgress,
|
progress: item.readingProgress,
|
||||||
@@ -230,22 +298,8 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
try {
|
try {
|
||||||
if (!hasBeenLoaded) setLoading(true)
|
if (!hasBeenLoaded) setLoading(true)
|
||||||
|
|
||||||
// Ensure bookmarks are loaded
|
// Derive links from bookmarks immediately (bookmarks come from centralized loading in App.tsx)
|
||||||
let fetchedBookmarks: Bookmark[] = bookmarks
|
const initialLinks = deriveLinksFromBookmarks(bookmarks)
|
||||||
if (bookmarks.length === 0) {
|
|
||||||
try {
|
|
||||||
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
|
|
||||||
fetchedBookmarks = newBookmarks
|
|
||||||
setBookmarks(newBookmarks)
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Failed to load bookmarks:', err)
|
|
||||||
fetchedBookmarks = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive links from bookmarks immediately
|
|
||||||
const initialLinks = deriveLinksFromBookmarks(fetchedBookmarks)
|
|
||||||
const initialMap = new Map(initialLinks.map(item => [item.id, item]))
|
const initialMap = new Map(initialLinks.map(item => [item.id, item]))
|
||||||
setLinksMap(initialMap)
|
setLinksMap(initialMap)
|
||||||
setLinks(initialLinks)
|
setLinks(initialLinks)
|
||||||
@@ -287,7 +341,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
const cached = getCachedMeData(viewingPubkey)
|
const cached = getCachedMeData(viewingPubkey)
|
||||||
if (cached) {
|
if (cached) {
|
||||||
setHighlights(cached.highlights)
|
setHighlights(cached.highlights)
|
||||||
setBookmarks(cached.bookmarks)
|
// Bookmarks come from App.tsx centralized state, no local caching needed
|
||||||
setReads(cached.reads || [])
|
setReads(cached.reads || [])
|
||||||
setLinks(cached.links || [])
|
setLinks(cached.links || [])
|
||||||
}
|
}
|
||||||
@@ -314,6 +368,12 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [activeTab, viewingPubkey, refreshTrigger])
|
}, [activeTab, viewingPubkey, refreshTrigger])
|
||||||
|
|
||||||
|
// Sync myHighlights from controller when viewing own profile
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOwnProfile) {
|
||||||
|
setHighlights(myHighlights)
|
||||||
|
}
|
||||||
|
}, [isOwnProfile, myHighlights])
|
||||||
|
|
||||||
// Pull-to-refresh - reload active tab without clearing state
|
// Pull-to-refresh - reload active tab without clearing state
|
||||||
const { isRefreshing, pullPosition } = usePullToRefresh({
|
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||||
@@ -421,16 +481,20 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
// Apply reading progress filter
|
// Apply reading progress filter
|
||||||
const filteredReads = filterByReadingProgress(reads, readingProgressFilter)
|
const filteredReads = filterByReadingProgress(reads, readingProgressFilter)
|
||||||
const filteredLinks = filterByReadingProgress(links, readingProgressFilter)
|
const filteredLinks = filterByReadingProgress(links, readingProgressFilter)
|
||||||
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
|
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
|
||||||
{ key: 'private', title: 'Private Bookmarks', items: groups.privateItems },
|
groupingMode === 'flat'
|
||||||
{ key: 'public', title: 'Public Bookmarks', items: groups.publicItems },
|
? [{ key: 'all', title: `All Bookmarks (${filteredBookmarks.length})`, items: filteredBookmarks }]
|
||||||
{ key: 'web', title: 'Web Bookmarks', items: groups.web },
|
: [
|
||||||
{ key: 'amethyst', title: 'Legacy Bookmarks', items: groups.amethyst }
|
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
|
||||||
]
|
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
|
||||||
|
{ key: 'amethyst-private', title: 'Amethyst Private', items: groups.amethystPrivate },
|
||||||
|
{ key: 'amethyst-public', title: 'Amethyst Lists', items: groups.amethystPublic },
|
||||||
|
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb }
|
||||||
|
]
|
||||||
|
|
||||||
// Show content progressively - no blocking error screens
|
// Show content progressively - no blocking error screens
|
||||||
const hasData = highlights.length > 0 || bookmarks.length > 0 || reads.length > 0 || links.length > 0 || writings.length > 0
|
const hasData = highlights.length > 0 || bookmarks.length > 0 || reads.length > 0 || links.length > 0 || writings.length > 0
|
||||||
const showSkeletons = loading && !hasData
|
const showSkeletons = (loading || (isOwnProfile && myHighlightsLoading)) && !hasData
|
||||||
|
|
||||||
const renderTabContent = () => {
|
const renderTabContent = () => {
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
@@ -444,7 +508,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return highlights.length === 0 && !loading ? (
|
return highlights.length === 0 && !loading && !(isOwnProfile && myHighlightsLoading) ? (
|
||||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
No highlights yet.
|
No highlights yet.
|
||||||
</div>
|
</div>
|
||||||
@@ -514,6 +578,13 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
marginTop: '1rem',
|
marginTop: '1rem',
|
||||||
borderTop: '1px solid var(--border-color)'
|
borderTop: '1px solid var(--border-color)'
|
||||||
}}>
|
}}>
|
||||||
|
<IconButton
|
||||||
|
icon={groupingMode === 'grouped' ? faLayerGroup : faBars}
|
||||||
|
onClick={toggleGroupingMode}
|
||||||
|
title={groupingMode === 'grouped' ? 'Show flat chronological list' : 'Show grouped by source'}
|
||||||
|
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={faList}
|
icon={faList}
|
||||||
onClick={() => setViewMode('compact')}
|
onClick={() => setViewMode('compact')}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import IconButton from './IconButton'
|
|||||||
import { loadFont } from '../utils/fontLoader'
|
import { loadFont } from '../utils/fontLoader'
|
||||||
import ThemeSettings from './Settings/ThemeSettings'
|
import ThemeSettings from './Settings/ThemeSettings'
|
||||||
import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
|
import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
|
||||||
|
import ExploreSettings from './Settings/ExploreSettings'
|
||||||
import LayoutBehaviorSettings from './Settings/LayoutBehaviorSettings'
|
import LayoutBehaviorSettings from './Settings/LayoutBehaviorSettings'
|
||||||
import ZapSettings from './Settings/ZapSettings'
|
import ZapSettings from './Settings/ZapSettings'
|
||||||
import RelaySettings from './Settings/RelaySettings'
|
import RelaySettings from './Settings/RelaySettings'
|
||||||
@@ -29,6 +30,9 @@ const DEFAULT_SETTINGS: UserSettings = {
|
|||||||
defaultHighlightVisibilityNostrverse: true,
|
defaultHighlightVisibilityNostrverse: true,
|
||||||
defaultHighlightVisibilityFriends: true,
|
defaultHighlightVisibilityFriends: true,
|
||||||
defaultHighlightVisibilityMine: true,
|
defaultHighlightVisibilityMine: true,
|
||||||
|
defaultExploreScopeNostrverse: false,
|
||||||
|
defaultExploreScopeFriends: true,
|
||||||
|
defaultExploreScopeMine: false,
|
||||||
zapSplitHighlighterWeight: 50,
|
zapSplitHighlighterWeight: 50,
|
||||||
zapSplitBorisWeight: 2.1,
|
zapSplitBorisWeight: 2.1,
|
||||||
zapSplitAuthorWeight: 50,
|
zapSplitAuthorWeight: 50,
|
||||||
@@ -163,6 +167,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
|
|||||||
<div className="settings-content">
|
<div className="settings-content">
|
||||||
<ThemeSettings settings={localSettings} onUpdate={handleUpdate} />
|
<ThemeSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
|
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
|
<ExploreSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
|
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
<LayoutBehaviorSettings settings={localSettings} onUpdate={handleUpdate} />
|
<LayoutBehaviorSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
<PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
|
<PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
|
||||||
|
|||||||
59
src/components/Settings/ExploreSettings.tsx
Normal file
59
src/components/Settings/ExploreSettings.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { UserSettings } from '../../services/settingsService'
|
||||||
|
import IconButton from '../IconButton'
|
||||||
|
|
||||||
|
interface ExploreSettingsProps {
|
||||||
|
settings: UserSettings
|
||||||
|
onUpdate: (updates: Partial<UserSettings>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExploreSettings: React.FC<ExploreSettingsProps> = ({ settings, onUpdate }) => {
|
||||||
|
return (
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Explore</h3>
|
||||||
|
|
||||||
|
<div className="setting-group setting-inline">
|
||||||
|
<label>Default Explore Scope</label>
|
||||||
|
<div className="highlight-level-toggles">
|
||||||
|
<IconButton
|
||||||
|
icon={faNetworkWired}
|
||||||
|
onClick={() => onUpdate({ defaultExploreScopeNostrverse: !(settings.defaultExploreScopeNostrverse !== false) })}
|
||||||
|
title="Nostrverse content"
|
||||||
|
ariaLabel="Toggle nostrverse content by default in explore"
|
||||||
|
variant="ghost"
|
||||||
|
style={{
|
||||||
|
color: (settings.defaultExploreScopeNostrverse !== false) ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined,
|
||||||
|
opacity: (settings.defaultExploreScopeNostrverse !== false) ? 1 : 0.4
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faUserGroup}
|
||||||
|
onClick={() => onUpdate({ defaultExploreScopeFriends: !(settings.defaultExploreScopeFriends !== false) })}
|
||||||
|
title="Friends content"
|
||||||
|
ariaLabel="Toggle friends content by default in explore"
|
||||||
|
variant="ghost"
|
||||||
|
style={{
|
||||||
|
color: (settings.defaultExploreScopeFriends !== false) ? 'var(--highlight-color-friends, #f97316)' : undefined,
|
||||||
|
opacity: (settings.defaultExploreScopeFriends !== false) ? 1 : 0.4
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faUser}
|
||||||
|
onClick={() => onUpdate({ defaultExploreScopeMine: !(settings.defaultExploreScopeMine !== false) })}
|
||||||
|
title="My content"
|
||||||
|
ariaLabel="Toggle my content by default in explore"
|
||||||
|
variant="ghost"
|
||||||
|
style={{
|
||||||
|
color: (settings.defaultExploreScopeMine !== false) ? 'var(--highlight-color-mine, #eab308)' : undefined,
|
||||||
|
opacity: (settings.defaultExploreScopeMine !== false) ? 1 : 0.4
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ExploreSettings
|
||||||
|
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import React, { useState } from 'react'
|
import React from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faHome, faNewspaper, faTimes } from '@fortawesome/free-solid-svg-icons'
|
import { faChevronRight, faRightFromBracket, faUserCircle, faGear, faHome, faNewspaper, faTimes } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
import { Models } from 'applesauce-core'
|
import { Models } from 'applesauce-core'
|
||||||
import { Accounts } from 'applesauce-accounts'
|
|
||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
|
|
||||||
interface SidebarHeaderProps {
|
interface SidebarHeaderProps {
|
||||||
@@ -16,26 +15,10 @@ interface SidebarHeaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, isMobile = false }) => {
|
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, isMobile = false }) => {
|
||||||
const [isConnecting, setIsConnecting] = useState(false)
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
const accountManager = Hooks.useAccountManager()
|
|
||||||
const profile = useEventModel(Models.ProfileModel, activeAccount ? [activeAccount.pubkey] : null)
|
const profile = useEventModel(Models.ProfileModel, activeAccount ? [activeAccount.pubkey] : null)
|
||||||
|
|
||||||
const handleLogin = async () => {
|
|
||||||
try {
|
|
||||||
setIsConnecting(true)
|
|
||||||
const account = await Accounts.ExtensionAccount.fromExtension()
|
|
||||||
accountManager.addAccount(account)
|
|
||||||
accountManager.setActive(account)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Login failed:', error)
|
|
||||||
alert('Login failed. Please install a nostr browser extension and try again.\n\nIf you aren\'t on nostr yet, start here: https://nstart.me/')
|
|
||||||
} finally {
|
|
||||||
setIsConnecting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getProfileImage = () => {
|
const getProfileImage = () => {
|
||||||
return profile?.picture || null
|
return profile?.picture || null
|
||||||
}
|
}
|
||||||
@@ -73,22 +56,20 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="sidebar-header-right">
|
<div className="sidebar-header-right">
|
||||||
<div
|
{activeAccount && (
|
||||||
className="profile-avatar"
|
<div
|
||||||
title={activeAccount ? getUserDisplayName() : "Login"}
|
className="profile-avatar"
|
||||||
onClick={
|
title={getUserDisplayName()}
|
||||||
activeAccount
|
onClick={() => navigate('/me')}
|
||||||
? () => navigate('/me')
|
style={{ cursor: 'pointer' }}
|
||||||
: (isConnecting ? () => {} : handleLogin)
|
>
|
||||||
}
|
{profileImage ? (
|
||||||
style={{ cursor: 'pointer' }}
|
<img src={profileImage} alt={getUserDisplayName()} />
|
||||||
>
|
) : (
|
||||||
{profileImage ? (
|
<FontAwesomeIcon icon={faUserCircle} />
|
||||||
<img src={profileImage} alt={getUserDisplayName()} />
|
)}
|
||||||
) : (
|
</div>
|
||||||
<FontAwesomeIcon icon={faUserCircle} />
|
)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={faHome}
|
icon={faHome}
|
||||||
onClick={() => navigate('/')}
|
onClick={() => navigate('/')}
|
||||||
@@ -110,7 +91,7 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
|||||||
ariaLabel="Settings"
|
ariaLabel="Settings"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
/>
|
/>
|
||||||
{activeAccount ? (
|
{activeAccount && (
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={faRightFromBracket}
|
icon={faRightFromBracket}
|
||||||
onClick={onLogout}
|
onClick={onLogout}
|
||||||
@@ -118,14 +99,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
|||||||
ariaLabel="Logout"
|
ariaLabel="Logout"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<IconButton
|
|
||||||
icon={faRightToBracket}
|
|
||||||
onClick={isConnecting ? () => {} : handleLogin}
|
|
||||||
title={isConnecting ? "Connecting..." : "Login"}
|
|
||||||
ariaLabel="Login"
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -414,7 +414,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{props.hasActiveAccount && (
|
{props.hasActiveAccount && props.readerContent && (
|
||||||
<HighlightButton
|
<HighlightButton
|
||||||
ref={props.highlightButtonRef}
|
ref={props.highlightButtonRef}
|
||||||
onHighlight={props.onCreateHighlight}
|
onHighlight={props.onCreateHighlight}
|
||||||
|
|||||||
@@ -1,62 +1,83 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { IAccount, AccountManager } from 'applesauce-accounts'
|
import { IAccount } from 'applesauce-accounts'
|
||||||
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { Bookmark } from '../types/bookmarks'
|
import { Bookmark } from '../types/bookmarks'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { fetchBookmarks } from '../services/bookmarkService'
|
import { fetchHighlightsForArticle } from '../services/highlightService'
|
||||||
import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService'
|
|
||||||
import { fetchContacts } from '../services/contactService'
|
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
import { highlightsController } from '../services/highlightsController'
|
||||||
|
import { contactsController } from '../services/contactsController'
|
||||||
|
import { useStoreTimeline } from './useStoreTimeline'
|
||||||
|
import { eventToHighlight } from '../services/highlightEventProcessor'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
|
||||||
interface UseBookmarksDataParams {
|
interface UseBookmarksDataParams {
|
||||||
relayPool: RelayPool | null
|
relayPool: RelayPool | null
|
||||||
activeAccount: IAccount | undefined
|
activeAccount: IAccount | undefined
|
||||||
accountManager: AccountManager
|
|
||||||
naddr?: string
|
naddr?: string
|
||||||
externalUrl?: string
|
externalUrl?: string
|
||||||
currentArticleCoordinate?: string
|
currentArticleCoordinate?: string
|
||||||
currentArticleEventId?: string
|
currentArticleEventId?: string
|
||||||
settings?: UserSettings
|
settings?: UserSettings
|
||||||
|
eventStore?: IEventStore | null
|
||||||
|
bookmarks: Bookmark[] // Passed from App.tsx (centralized loading)
|
||||||
|
bookmarksLoading: boolean // Passed from App.tsx (centralized loading)
|
||||||
|
onRefreshBookmarks: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useBookmarksData = ({
|
export const useBookmarksData = ({
|
||||||
relayPool,
|
relayPool,
|
||||||
activeAccount,
|
activeAccount,
|
||||||
accountManager,
|
|
||||||
naddr,
|
naddr,
|
||||||
externalUrl,
|
externalUrl,
|
||||||
currentArticleCoordinate,
|
currentArticleCoordinate,
|
||||||
currentArticleEventId,
|
currentArticleEventId,
|
||||||
settings
|
settings,
|
||||||
}: UseBookmarksDataParams) => {
|
eventStore,
|
||||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
onRefreshBookmarks
|
||||||
const [bookmarksLoading, setBookmarksLoading] = useState(true)
|
}: Omit<UseBookmarksDataParams, 'bookmarks' | 'bookmarksLoading'>) => {
|
||||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
|
||||||
|
const [articleHighlights, setArticleHighlights] = useState<Highlight[]>([])
|
||||||
const [highlightsLoading, setHighlightsLoading] = useState(true)
|
const [highlightsLoading, setHighlightsLoading] = useState(true)
|
||||||
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||||
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null)
|
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null)
|
||||||
|
|
||||||
const handleFetchContacts = useCallback(async () => {
|
// Load cached article-specific highlights from event store
|
||||||
if (!relayPool || !activeAccount) return
|
const articleFilter = useMemo(() => {
|
||||||
const contacts = await fetchContacts(relayPool, activeAccount.pubkey)
|
if (!currentArticleCoordinate) return null
|
||||||
setFollowedPubkeys(contacts)
|
return {
|
||||||
}, [relayPool, activeAccount])
|
kinds: [KINDS.Highlights],
|
||||||
|
'#a': [currentArticleCoordinate],
|
||||||
const handleFetchBookmarks = useCallback(async () => {
|
...(currentArticleEventId ? { '#e': [currentArticleEventId] } : {})
|
||||||
if (!relayPool || !activeAccount) return
|
|
||||||
// don't clear existing bookmarks: we keep UI stable and show spinner unobtrusively
|
|
||||||
setBookmarksLoading(true)
|
|
||||||
try {
|
|
||||||
const fullAccount = accountManager.getActive()
|
|
||||||
// merge-friendly: updater form that preserves visible list until replacement
|
|
||||||
await fetchBookmarks(relayPool, fullAccount || activeAccount, (next) => {
|
|
||||||
setBookmarks(() => next)
|
|
||||||
}, settings)
|
|
||||||
} finally {
|
|
||||||
setBookmarksLoading(false)
|
|
||||||
}
|
}
|
||||||
}, [relayPool, activeAccount, accountManager, settings])
|
}, [currentArticleCoordinate, currentArticleEventId])
|
||||||
|
|
||||||
|
const cachedArticleHighlights = useStoreTimeline(
|
||||||
|
eventStore || null,
|
||||||
|
articleFilter || { kinds: [KINDS.Highlights], limit: 0 }, // empty filter if no article
|
||||||
|
eventToHighlight,
|
||||||
|
[currentArticleCoordinate, currentArticleEventId]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Subscribe to centralized controllers
|
||||||
|
useEffect(() => {
|
||||||
|
// Get initial state immediately
|
||||||
|
setMyHighlights(highlightsController.getHighlights())
|
||||||
|
setFollowedPubkeys(new Set(contactsController.getContacts()))
|
||||||
|
|
||||||
|
// Subscribe to updates
|
||||||
|
const unsubHighlights = highlightsController.onHighlights(setMyHighlights)
|
||||||
|
const unsubContacts = contactsController.onContacts((contacts) => {
|
||||||
|
setFollowedPubkeys(new Set(contacts))
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubHighlights()
|
||||||
|
unsubContacts()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleFetchHighlights = useCallback(async () => {
|
const handleFetchHighlights = useCallback(async () => {
|
||||||
if (!relayPool) return
|
if (!relayPool) return
|
||||||
@@ -64,7 +85,16 @@ export const useBookmarksData = ({
|
|||||||
setHighlightsLoading(true)
|
setHighlightsLoading(true)
|
||||||
try {
|
try {
|
||||||
if (currentArticleCoordinate) {
|
if (currentArticleCoordinate) {
|
||||||
|
// Seed with cached highlights first
|
||||||
|
if (cachedArticleHighlights.length > 0) {
|
||||||
|
setArticleHighlights(cachedArticleHighlights.sort((a, b) => b.created_at - a.created_at))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch fresh article-specific highlights (from all users)
|
||||||
const highlightsMap = new Map<string, Highlight>()
|
const highlightsMap = new Map<string, Highlight>()
|
||||||
|
// Seed map with cached highlights
|
||||||
|
cachedArticleHighlights.forEach(h => highlightsMap.set(h.id, h))
|
||||||
|
|
||||||
await fetchHighlightsForArticle(
|
await fetchHighlightsForArticle(
|
||||||
relayPool,
|
relayPool,
|
||||||
currentArticleCoordinate,
|
currentArticleCoordinate,
|
||||||
@@ -74,68 +104,67 @@ export const useBookmarksData = ({
|
|||||||
if (!highlightsMap.has(highlight.id)) {
|
if (!highlightsMap.has(highlight.id)) {
|
||||||
highlightsMap.set(highlight.id, highlight)
|
highlightsMap.set(highlight.id, highlight)
|
||||||
const highlightsList = Array.from(highlightsMap.values())
|
const highlightsList = Array.from(highlightsMap.values())
|
||||||
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
|
setArticleHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
settings
|
settings,
|
||||||
|
false, // force
|
||||||
|
eventStore || undefined
|
||||||
)
|
)
|
||||||
console.log(`🔄 Refreshed ${highlightsMap.size} highlights for article`)
|
} else {
|
||||||
} else if (activeAccount) {
|
// No article selected - clear article highlights
|
||||||
const fetchedHighlights = await fetchHighlights(relayPool, activeAccount.pubkey, undefined, settings)
|
setArticleHighlights([])
|
||||||
setHighlights(fetchedHighlights)
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch highlights:', err)
|
console.error('Failed to fetch highlights:', err)
|
||||||
} finally {
|
} finally {
|
||||||
setHighlightsLoading(false)
|
setHighlightsLoading(false)
|
||||||
}
|
}
|
||||||
}, [relayPool, activeAccount, currentArticleCoordinate, currentArticleEventId, settings])
|
}, [relayPool, currentArticleCoordinate, currentArticleEventId, settings, eventStore, cachedArticleHighlights])
|
||||||
|
|
||||||
const handleRefreshAll = useCallback(async () => {
|
const handleRefreshAll = useCallback(async () => {
|
||||||
if (!relayPool || !activeAccount || isRefreshing) return
|
if (!relayPool || !activeAccount || isRefreshing) return
|
||||||
|
|
||||||
setIsRefreshing(true)
|
setIsRefreshing(true)
|
||||||
try {
|
try {
|
||||||
await handleFetchBookmarks()
|
await onRefreshBookmarks()
|
||||||
await handleFetchHighlights()
|
await handleFetchHighlights()
|
||||||
await handleFetchContacts()
|
// Contacts and own highlights are managed by controllers
|
||||||
setLastFetchTime(Date.now())
|
setLastFetchTime(Date.now())
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to refresh data:', err)
|
console.error('Failed to refresh data:', err)
|
||||||
} finally {
|
} finally {
|
||||||
setIsRefreshing(false)
|
setIsRefreshing(false)
|
||||||
}
|
}
|
||||||
}, [relayPool, activeAccount, isRefreshing, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
|
}, [relayPool, activeAccount, isRefreshing, onRefreshBookmarks, handleFetchHighlights])
|
||||||
|
|
||||||
// Load initial data (avoid clearing on route-only changes)
|
// Fetch article-specific highlights when viewing an article
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!relayPool || !activeAccount) return
|
if (!relayPool || !activeAccount) return
|
||||||
// Only (re)fetch bookmarks when account or relayPool changes, not on naddr route changes
|
// Fetch article-specific highlights when viewing an article
|
||||||
handleFetchBookmarks()
|
|
||||||
}, [relayPool, activeAccount, handleFetchBookmarks])
|
|
||||||
|
|
||||||
// Fetch highlights/contacts independently to avoid disturbing bookmarks
|
|
||||||
useEffect(() => {
|
|
||||||
if (!relayPool || !activeAccount) return
|
|
||||||
// Only fetch general highlights when not viewing an article (naddr) or external URL
|
|
||||||
// External URLs have their highlights fetched by useExternalUrlLoader
|
// External URLs have their highlights fetched by useExternalUrlLoader
|
||||||
if (!naddr && !externalUrl) {
|
if (currentArticleCoordinate && !externalUrl) {
|
||||||
handleFetchHighlights()
|
handleFetchHighlights()
|
||||||
|
} else if (!naddr && !externalUrl) {
|
||||||
|
// Clear article highlights when not viewing an article
|
||||||
|
setArticleHighlights([])
|
||||||
|
setHighlightsLoading(false)
|
||||||
}
|
}
|
||||||
handleFetchContacts()
|
}, [relayPool, activeAccount, currentArticleCoordinate, naddr, externalUrl, handleFetchHighlights])
|
||||||
}, [relayPool, activeAccount, naddr, externalUrl, handleFetchHighlights, handleFetchContacts])
|
|
||||||
|
// Merge highlights from controller with article-specific highlights
|
||||||
|
const highlights = [...myHighlights, ...articleHighlights]
|
||||||
|
.filter((h, i, arr) => arr.findIndex(x => x.id === h.id) === i) // Deduplicate
|
||||||
|
.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bookmarks,
|
|
||||||
bookmarksLoading,
|
|
||||||
highlights,
|
highlights,
|
||||||
setHighlights,
|
setHighlights: setArticleHighlights, // For external updates (like from useExternalUrlLoader)
|
||||||
highlightsLoading,
|
highlightsLoading,
|
||||||
setHighlightsLoading,
|
setHighlightsLoading,
|
||||||
followedPubkeys,
|
followedPubkeys,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
lastFetchTime,
|
lastFetchTime,
|
||||||
handleFetchBookmarks,
|
|
||||||
handleFetchHighlights,
|
handleFetchHighlights,
|
||||||
handleRefreshAll
|
handleRefreshAll
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect, useMemo } from 'react'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { fetchReadableContent, ReadableContent } from '../services/readerService'
|
import { fetchReadableContent, ReadableContent } from '../services/readerService'
|
||||||
import { fetchHighlightsForUrl } from '../services/highlightService'
|
import { fetchHighlightsForUrl } from '../services/highlightService'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
|
import { useStoreTimeline } from './useStoreTimeline'
|
||||||
|
import { eventToHighlight } from '../services/highlightEventProcessor'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
|
||||||
// Helper to extract filename from URL
|
// Helper to extract filename from URL
|
||||||
function getFilenameFromUrl(url: string): string {
|
function getFilenameFromUrl(url: string): string {
|
||||||
@@ -20,6 +24,7 @@ function getFilenameFromUrl(url: string): string {
|
|||||||
interface UseExternalUrlLoaderProps {
|
interface UseExternalUrlLoaderProps {
|
||||||
url: string | undefined
|
url: string | undefined
|
||||||
relayPool: RelayPool | null
|
relayPool: RelayPool | null
|
||||||
|
eventStore?: IEventStore | null
|
||||||
setSelectedUrl: (url: string) => void
|
setSelectedUrl: (url: string) => void
|
||||||
setReaderContent: (content: ReadableContent | undefined) => void
|
setReaderContent: (content: ReadableContent | undefined) => void
|
||||||
setReaderLoading: (loading: boolean) => void
|
setReaderLoading: (loading: boolean) => void
|
||||||
@@ -33,6 +38,7 @@ interface UseExternalUrlLoaderProps {
|
|||||||
export function useExternalUrlLoader({
|
export function useExternalUrlLoader({
|
||||||
url,
|
url,
|
||||||
relayPool,
|
relayPool,
|
||||||
|
eventStore,
|
||||||
setSelectedUrl,
|
setSelectedUrl,
|
||||||
setReaderContent,
|
setReaderContent,
|
||||||
setReaderLoading,
|
setReaderLoading,
|
||||||
@@ -42,6 +48,19 @@ export function useExternalUrlLoader({
|
|||||||
setCurrentArticleCoordinate,
|
setCurrentArticleCoordinate,
|
||||||
setCurrentArticleEventId
|
setCurrentArticleEventId
|
||||||
}: UseExternalUrlLoaderProps) {
|
}: UseExternalUrlLoaderProps) {
|
||||||
|
// Load cached URL-specific highlights from event store
|
||||||
|
const urlFilter = useMemo(() => {
|
||||||
|
if (!url) return null
|
||||||
|
return { kinds: [KINDS.Highlights], '#r': [url] }
|
||||||
|
}, [url])
|
||||||
|
|
||||||
|
const cachedUrlHighlights = useStoreTimeline(
|
||||||
|
eventStore || null,
|
||||||
|
urlFilter || { kinds: [KINDS.Highlights], limit: 0 },
|
||||||
|
eventToHighlight,
|
||||||
|
[url]
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!relayPool || !url) return
|
if (!relayPool || !url) return
|
||||||
|
|
||||||
@@ -66,11 +85,20 @@ export function useExternalUrlLoader({
|
|||||||
// Fetch highlights for this URL asynchronously
|
// Fetch highlights for this URL asynchronously
|
||||||
try {
|
try {
|
||||||
setHighlightsLoading(true)
|
setHighlightsLoading(true)
|
||||||
setHighlights([])
|
|
||||||
|
// Seed with cached highlights first
|
||||||
|
if (cachedUrlHighlights.length > 0) {
|
||||||
|
setHighlights(cachedUrlHighlights.sort((a, b) => b.created_at - a.created_at))
|
||||||
|
} else {
|
||||||
|
setHighlights([])
|
||||||
|
}
|
||||||
|
|
||||||
// Check if fetchHighlightsForUrl exists, otherwise skip
|
// Check if fetchHighlightsForUrl exists, otherwise skip
|
||||||
if (typeof fetchHighlightsForUrl === 'function') {
|
if (typeof fetchHighlightsForUrl === 'function') {
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
|
// Seed with cached IDs
|
||||||
|
cachedUrlHighlights.forEach(h => seen.add(h.id))
|
||||||
|
|
||||||
await fetchHighlightsForUrl(
|
await fetchHighlightsForUrl(
|
||||||
relayPool,
|
relayPool,
|
||||||
url,
|
url,
|
||||||
@@ -82,13 +110,11 @@ export function useExternalUrlLoader({
|
|||||||
const next = [...prev, highlight]
|
const next = [...prev, highlight]
|
||||||
return next.sort((a, b) => b.created_at - a.created_at)
|
return next.sort((a, b) => b.created_at - a.created_at)
|
||||||
})
|
})
|
||||||
}
|
},
|
||||||
|
undefined, // settings
|
||||||
|
false, // force
|
||||||
|
eventStore || undefined
|
||||||
)
|
)
|
||||||
// Highlights are already set via the streaming callback
|
|
||||||
// No need to set them again as that could cause a flash/disappearance
|
|
||||||
console.log(`📌 Finished fetching highlights for URL`)
|
|
||||||
} else {
|
|
||||||
console.log('📌 Highlight fetching for URLs not yet implemented')
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch highlights:', err)
|
console.error('Failed to fetch highlights:', err)
|
||||||
@@ -109,6 +135,6 @@ export function useExternalUrlLoader({
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadExternalUrl()
|
loadExternalUrl()
|
||||||
}, [url, relayPool, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId])
|
}, [url, relayPool, eventStore, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId, cachedUrlHighlights])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
33
src/hooks/useStoreTimeline.ts
Normal file
33
src/hooks/useStoreTimeline.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useObservableMemo } from 'applesauce-react/hooks'
|
||||||
|
import { startWith } from 'rxjs'
|
||||||
|
import type { IEventStore } from 'applesauce-core'
|
||||||
|
import type { Filter, NostrEvent } from 'nostr-tools'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to EventStore timeline and map events to app types
|
||||||
|
* Provides instant cached results, then updates reactively
|
||||||
|
*
|
||||||
|
* @param eventStore - The applesauce event store
|
||||||
|
* @param filter - Nostr filter to query
|
||||||
|
* @param mapEvent - Function to transform NostrEvent to app type
|
||||||
|
* @param deps - Dependencies for memoization
|
||||||
|
* @returns Array of mapped results
|
||||||
|
*/
|
||||||
|
export function useStoreTimeline<T>(
|
||||||
|
eventStore: IEventStore | null,
|
||||||
|
filter: Filter,
|
||||||
|
mapEvent: (event: NostrEvent) => T,
|
||||||
|
deps: unknown[] = []
|
||||||
|
): T[] {
|
||||||
|
const events = useObservableMemo(
|
||||||
|
() => eventStore ? eventStore.timeline(filter).pipe(startWith([])) : undefined,
|
||||||
|
[eventStore, ...deps]
|
||||||
|
)
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => events?.map(mapEvent) ?? [],
|
||||||
|
[events, mapEvent]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
@import './styles/components/me.css';
|
@import './styles/components/me.css';
|
||||||
@import './styles/components/pull-to-refresh.css';
|
@import './styles/components/pull-to-refresh.css';
|
||||||
@import './styles/components/skeletons.css';
|
@import './styles/components/skeletons.css';
|
||||||
|
@import './styles/components/login.css';
|
||||||
@import './styles/utils/animations.css';
|
@import './styles/utils/animations.css';
|
||||||
@import './styles/utils/utilities.css';
|
@import './styles/utils/utilities.css';
|
||||||
@import './styles/utils/legacy.css';
|
@import './styles/utils/legacy.css';
|
||||||
|
|||||||
472
src/services/bookmarkController.ts
Normal file
472
src/services/bookmarkController.ts
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { Helpers, EventStore } from 'applesauce-core'
|
||||||
|
import { createEventLoader, createAddressLoader } from 'applesauce-loaders/loaders'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { EventPointer } from 'nostr-tools/nip19'
|
||||||
|
import { merge } from 'rxjs'
|
||||||
|
import { queryEvents } from './dataFetch'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { RELAYS } from '../config/relays'
|
||||||
|
import { collectBookmarksFromEvents } from './bookmarkProcessing'
|
||||||
|
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||||
|
import {
|
||||||
|
AccountWithExtension,
|
||||||
|
hydrateItems,
|
||||||
|
dedupeBookmarksById,
|
||||||
|
extractUrlsFromContent
|
||||||
|
} from './bookmarkHelpers'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unique key for event deduplication (from Debug)
|
||||||
|
*/
|
||||||
|
function getEventKey(evt: NostrEvent): string {
|
||||||
|
if (evt.kind === 30003 || evt.kind === 30001) {
|
||||||
|
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||||
|
return `${evt.kind}:${evt.pubkey}:${dTag}`
|
||||||
|
} else if (evt.kind === 10003) {
|
||||||
|
return `${evt.kind}:${evt.pubkey}`
|
||||||
|
}
|
||||||
|
return evt.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if event has encrypted content (from Debug)
|
||||||
|
*/
|
||||||
|
function hasEncryptedContent(evt: NostrEvent): boolean {
|
||||||
|
if (Helpers.hasHiddenContent(evt)) return true
|
||||||
|
if (evt.content && evt.content.includes('?iv=')) return true
|
||||||
|
if (Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt)) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawEventCallback = (event: NostrEvent) => void
|
||||||
|
type BookmarksCallback = (bookmarks: Bookmark[]) => void
|
||||||
|
type LoadingCallback = (loading: boolean) => void
|
||||||
|
type DecryptCompleteCallback = (eventId: string, publicCount: number, privateCount: number) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared bookmark streaming controller
|
||||||
|
* Encapsulates the Debug flow: stream events, dedupe, decrypt, build bookmarks
|
||||||
|
*/
|
||||||
|
class BookmarkController {
|
||||||
|
private rawEventListeners: RawEventCallback[] = []
|
||||||
|
private bookmarksListeners: BookmarksCallback[] = []
|
||||||
|
private loadingListeners: LoadingCallback[] = []
|
||||||
|
private decryptCompleteListeners: DecryptCompleteCallback[] = []
|
||||||
|
|
||||||
|
private currentEvents: Map<string, NostrEvent> = new Map()
|
||||||
|
private decryptedResults: Map<string, {
|
||||||
|
publicItems: IndividualBookmark[]
|
||||||
|
privateItems: IndividualBookmark[]
|
||||||
|
newestCreatedAt?: number
|
||||||
|
latestContent?: string
|
||||||
|
allTags?: string[][]
|
||||||
|
}> = new Map()
|
||||||
|
private isLoading = false
|
||||||
|
private hydrationGeneration = 0
|
||||||
|
|
||||||
|
// Event loaders for efficient batching
|
||||||
|
private eventStore = new EventStore()
|
||||||
|
private eventLoader: ReturnType<typeof createEventLoader> | null = null
|
||||||
|
private addressLoader: ReturnType<typeof createAddressLoader> | null = null
|
||||||
|
|
||||||
|
onRawEvent(cb: RawEventCallback): () => void {
|
||||||
|
this.rawEventListeners.push(cb)
|
||||||
|
return () => {
|
||||||
|
this.rawEventListeners = this.rawEventListeners.filter(l => l !== cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBookmarks(cb: BookmarksCallback): () => void {
|
||||||
|
this.bookmarksListeners.push(cb)
|
||||||
|
return () => {
|
||||||
|
this.bookmarksListeners = this.bookmarksListeners.filter(l => l !== cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoading(cb: LoadingCallback): () => void {
|
||||||
|
this.loadingListeners.push(cb)
|
||||||
|
return () => {
|
||||||
|
this.loadingListeners = this.loadingListeners.filter(l => l !== cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDecryptComplete(cb: DecryptCompleteCallback): () => void {
|
||||||
|
this.decryptCompleteListeners.push(cb)
|
||||||
|
return () => {
|
||||||
|
this.decryptCompleteListeners = this.decryptCompleteListeners.filter(l => l !== cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.hydrationGeneration++
|
||||||
|
this.currentEvents.clear()
|
||||||
|
this.decryptedResults.clear()
|
||||||
|
this.setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private setLoading(loading: boolean): void {
|
||||||
|
if (this.isLoading !== loading) {
|
||||||
|
this.isLoading = loading
|
||||||
|
this.loadingListeners.forEach(cb => cb(loading))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitRawEvent(evt: NostrEvent): void {
|
||||||
|
this.rawEventListeners.forEach(cb => cb(evt))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hydrate events by IDs using EventLoader (auto-batching, streaming)
|
||||||
|
*/
|
||||||
|
private hydrateByIds(
|
||||||
|
ids: string[],
|
||||||
|
idToEvent: Map<string, NostrEvent>,
|
||||||
|
onProgress: () => void,
|
||||||
|
generation: number
|
||||||
|
): void {
|
||||||
|
if (!this.eventLoader) {
|
||||||
|
console.warn('[bookmark] ⚠️ EventLoader not initialized')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to unique IDs not already hydrated
|
||||||
|
const unique = Array.from(new Set(ids)).filter(id => !idToEvent.has(id))
|
||||||
|
if (unique.length === 0) {
|
||||||
|
console.log('[bookmark] 🔧 All IDs already hydrated, skipping')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[bookmark] 🔧 Hydrating', unique.length, 'IDs using EventLoader')
|
||||||
|
|
||||||
|
// Convert IDs to EventPointers
|
||||||
|
const pointers: EventPointer[] = unique.map(id => ({ id }))
|
||||||
|
|
||||||
|
// Use EventLoader - it auto-batches and streams results
|
||||||
|
merge(...pointers.map(this.eventLoader)).subscribe({
|
||||||
|
next: (event) => {
|
||||||
|
// Check if hydration was cancelled
|
||||||
|
if (this.hydrationGeneration !== generation) return
|
||||||
|
|
||||||
|
idToEvent.set(event.id, event)
|
||||||
|
|
||||||
|
// Also index by coordinate for addressable events
|
||||||
|
if (event.kind && event.kind >= 30000 && event.kind < 40000) {
|
||||||
|
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||||
|
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
|
||||||
|
idToEvent.set(coordinate, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress()
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
console.error('[bookmark] ❌ EventLoader error:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hydrate addressable events by coordinates using AddressLoader (auto-batching, streaming)
|
||||||
|
*/
|
||||||
|
private hydrateByCoordinates(
|
||||||
|
coords: Array<{ kind: number; pubkey: string; identifier: string }>,
|
||||||
|
idToEvent: Map<string, NostrEvent>,
|
||||||
|
onProgress: () => void,
|
||||||
|
generation: number
|
||||||
|
): void {
|
||||||
|
if (!this.addressLoader) {
|
||||||
|
console.warn('[bookmark] ⚠️ AddressLoader not initialized')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coords.length === 0) return
|
||||||
|
|
||||||
|
console.log('[bookmark] 🔧 Hydrating', coords.length, 'coordinates using AddressLoader')
|
||||||
|
|
||||||
|
// Convert coordinates to AddressPointers
|
||||||
|
const pointers = coords.map(c => ({
|
||||||
|
kind: c.kind,
|
||||||
|
pubkey: c.pubkey,
|
||||||
|
identifier: c.identifier
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Use AddressLoader - it auto-batches and streams results
|
||||||
|
merge(...pointers.map(this.addressLoader)).subscribe({
|
||||||
|
next: (event) => {
|
||||||
|
// Check if hydration was cancelled
|
||||||
|
if (this.hydrationGeneration !== generation) return
|
||||||
|
|
||||||
|
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||||
|
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
|
||||||
|
idToEvent.set(coordinate, event)
|
||||||
|
idToEvent.set(event.id, event)
|
||||||
|
|
||||||
|
onProgress()
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
console.error('[bookmark] ❌ AddressLoader error:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildAndEmitBookmarks(
|
||||||
|
activeAccount: AccountWithExtension,
|
||||||
|
signerCandidate: unknown
|
||||||
|
): Promise<void> {
|
||||||
|
const allEvents = Array.from(this.currentEvents.values())
|
||||||
|
|
||||||
|
// Include unencrypted events OR encrypted events that have been decrypted
|
||||||
|
const readyEvents = allEvents.filter(evt => {
|
||||||
|
const isEncrypted = hasEncryptedContent(evt)
|
||||||
|
if (!isEncrypted) return true // Include unencrypted
|
||||||
|
// Include encrypted if already decrypted
|
||||||
|
return this.decryptedResults.has(getEventKey(evt))
|
||||||
|
})
|
||||||
|
|
||||||
|
const unencryptedCount = allEvents.filter(evt => !hasEncryptedContent(evt)).length
|
||||||
|
const decryptedCount = readyEvents.length - unencryptedCount
|
||||||
|
console.log('[bookmark] 📋 Building bookmarks:', unencryptedCount, 'unencrypted,', decryptedCount, 'decrypted, of', allEvents.length, 'total')
|
||||||
|
|
||||||
|
if (readyEvents.length === 0) {
|
||||||
|
this.bookmarksListeners.forEach(cb => cb([]))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Separate unencrypted and decrypted events
|
||||||
|
const unencryptedEvents = readyEvents.filter(evt => !hasEncryptedContent(evt))
|
||||||
|
const decryptedEvents = readyEvents.filter(evt => hasEncryptedContent(evt))
|
||||||
|
|
||||||
|
console.log('[bookmark] 🔧 Processing', unencryptedEvents.length, 'unencrypted events')
|
||||||
|
// Process unencrypted events
|
||||||
|
const { publicItemsAll: publicUnencrypted, privateItemsAll: privateUnencrypted, newestCreatedAt, latestContent, allTags } =
|
||||||
|
await collectBookmarksFromEvents(unencryptedEvents, activeAccount, signerCandidate)
|
||||||
|
console.log('[bookmark] 🔧 Unencrypted returned:', publicUnencrypted.length, 'public,', privateUnencrypted.length, 'private')
|
||||||
|
|
||||||
|
// Merge in decrypted results
|
||||||
|
let publicItemsAll = [...publicUnencrypted]
|
||||||
|
let privateItemsAll = [...privateUnencrypted]
|
||||||
|
|
||||||
|
console.log('[bookmark] 🔧 Merging', decryptedEvents.length, 'decrypted events')
|
||||||
|
decryptedEvents.forEach(evt => {
|
||||||
|
const eventKey = getEventKey(evt)
|
||||||
|
const decrypted = this.decryptedResults.get(eventKey)
|
||||||
|
if (decrypted) {
|
||||||
|
publicItemsAll = [...publicItemsAll, ...decrypted.publicItems]
|
||||||
|
privateItemsAll = [...privateItemsAll, ...decrypted.privateItems]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[bookmark] 🔧 Total after merge:', publicItemsAll.length, 'public,', privateItemsAll.length, 'private')
|
||||||
|
|
||||||
|
const allItems = [...publicItemsAll, ...privateItemsAll]
|
||||||
|
console.log('[bookmark] 🔧 Total items to process:', allItems.length)
|
||||||
|
|
||||||
|
// Separate hex IDs from coordinates
|
||||||
|
const noteIds: string[] = []
|
||||||
|
const coordinates: string[] = []
|
||||||
|
|
||||||
|
allItems.forEach(i => {
|
||||||
|
if (/^[0-9a-f]{64}$/i.test(i.id)) {
|
||||||
|
noteIds.push(i.id)
|
||||||
|
} else if (i.id.includes(':')) {
|
||||||
|
coordinates.push(i.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Helper to build and emit bookmarks
|
||||||
|
const emitBookmarks = (idToEvent: Map<string, NostrEvent>) => {
|
||||||
|
console.log('[bookmark] 🔧 Building final bookmarks list...')
|
||||||
|
const allBookmarks = dedupeBookmarksById([
|
||||||
|
...hydrateItems(publicItemsAll, idToEvent),
|
||||||
|
...hydrateItems(privateItemsAll, idToEvent)
|
||||||
|
])
|
||||||
|
console.log('[bookmark] 🔧 After hydration and dedup:', allBookmarks.length, 'bookmarks')
|
||||||
|
|
||||||
|
console.log('[bookmark] 🔧 Enriching and sorting...')
|
||||||
|
const enriched = allBookmarks.map(b => ({
|
||||||
|
...b,
|
||||||
|
tags: b.tags || [],
|
||||||
|
content: b.content || ''
|
||||||
|
}))
|
||||||
|
|
||||||
|
const sortedBookmarks = enriched
|
||||||
|
.map(b => ({ ...b, urlReferences: extractUrlsFromContent(b.content) }))
|
||||||
|
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
|
||||||
|
console.log('[bookmark] 🔧 Sorted:', sortedBookmarks.length, 'bookmarks')
|
||||||
|
|
||||||
|
console.log('[bookmark] 🔧 Creating final Bookmark object...')
|
||||||
|
const bookmark: Bookmark = {
|
||||||
|
id: `${activeAccount.pubkey}-bookmarks`,
|
||||||
|
title: `Bookmarks (${sortedBookmarks.length})`,
|
||||||
|
url: '',
|
||||||
|
content: latestContent,
|
||||||
|
created_at: newestCreatedAt || Math.floor(Date.now() / 1000),
|
||||||
|
tags: allTags,
|
||||||
|
bookmarkCount: sortedBookmarks.length,
|
||||||
|
eventReferences: allTags.filter((tag: string[]) => tag[0] === 'e').map((tag: string[]) => tag[1]),
|
||||||
|
individualBookmarks: sortedBookmarks,
|
||||||
|
isPrivate: privateItemsAll.length > 0,
|
||||||
|
encryptedContent: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[bookmark] 📋 Built bookmark with', sortedBookmarks.length, 'items')
|
||||||
|
console.log('[bookmark] 📤 Emitting to', this.bookmarksListeners.length, 'listeners')
|
||||||
|
this.bookmarksListeners.forEach(cb => cb([bookmark]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit immediately with empty metadata (show placeholders)
|
||||||
|
const idToEvent: Map<string, NostrEvent> = new Map()
|
||||||
|
console.log('[bookmark] 🚀 Emitting initial bookmarks with placeholders (IDs only)...')
|
||||||
|
emitBookmarks(idToEvent)
|
||||||
|
|
||||||
|
// Now fetch events progressively in background using batched hydrators
|
||||||
|
console.log('[bookmark] 🔧 Background hydration:', noteIds.length, 'note IDs and', coordinates.length, 'coordinates')
|
||||||
|
|
||||||
|
const generation = this.hydrationGeneration
|
||||||
|
const onProgress = () => emitBookmarks(idToEvent)
|
||||||
|
|
||||||
|
// Parse coordinates from strings to objects
|
||||||
|
const coordObjs = coordinates.map(c => {
|
||||||
|
const parts = c.split(':')
|
||||||
|
return {
|
||||||
|
kind: parseInt(parts[0]),
|
||||||
|
pubkey: parts[1],
|
||||||
|
identifier: parts[2] || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Kick off batched hydration (streaming, non-blocking)
|
||||||
|
// EventLoader and AddressLoader handle batching and streaming automatically
|
||||||
|
this.hydrateByIds(noteIds, idToEvent, onProgress, generation)
|
||||||
|
this.hydrateByCoordinates(coordObjs, idToEvent, onProgress, generation)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[bookmark] ❌ Failed to build bookmarks:', error)
|
||||||
|
console.error('[bookmark] ❌ Error details:', error instanceof Error ? error.message : String(error))
|
||||||
|
console.error('[bookmark] ❌ Stack:', error instanceof Error ? error.stack : 'no stack')
|
||||||
|
this.bookmarksListeners.forEach(cb => cb([]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(options: {
|
||||||
|
relayPool: RelayPool
|
||||||
|
activeAccount: unknown
|
||||||
|
accountManager: { getActive: () => unknown }
|
||||||
|
}): Promise<void> {
|
||||||
|
const { relayPool, activeAccount, accountManager } = options
|
||||||
|
|
||||||
|
if (!activeAccount || typeof (activeAccount as { pubkey?: string }).pubkey !== 'string') {
|
||||||
|
console.error('[bookmark] Invalid activeAccount')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = activeAccount as { pubkey: string; [key: string]: unknown }
|
||||||
|
|
||||||
|
// Increment generation to cancel any in-flight hydration
|
||||||
|
this.hydrationGeneration++
|
||||||
|
|
||||||
|
// Initialize loaders for this session
|
||||||
|
console.log('[bookmark] 🔧 Initializing EventLoader and AddressLoader with', RELAYS.length, 'relays')
|
||||||
|
this.eventLoader = createEventLoader(relayPool, {
|
||||||
|
eventStore: this.eventStore,
|
||||||
|
extraRelays: RELAYS
|
||||||
|
})
|
||||||
|
this.addressLoader = createAddressLoader(relayPool, {
|
||||||
|
eventStore: this.eventStore,
|
||||||
|
extraRelays: RELAYS
|
||||||
|
})
|
||||||
|
|
||||||
|
this.setLoading(true)
|
||||||
|
console.log('[bookmark] 🔍 Starting bookmark load for', account.pubkey.slice(0, 8))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get signer for auto-decryption
|
||||||
|
const fullAccount = accountManager.getActive() as AccountWithExtension | null
|
||||||
|
const maybeAccount = (fullAccount || account) as AccountWithExtension
|
||||||
|
let signerCandidate: unknown = maybeAccount
|
||||||
|
const hasNip04Prop = (signerCandidate as { nip04?: unknown })?.nip04 !== undefined
|
||||||
|
const hasNip44Prop = (signerCandidate as { nip44?: unknown })?.nip44 !== undefined
|
||||||
|
if (signerCandidate && !hasNip04Prop && !hasNip44Prop && maybeAccount?.signer) {
|
||||||
|
signerCandidate = maybeAccount.signer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream events with live deduplication (same as Debug)
|
||||||
|
await queryEvents(
|
||||||
|
relayPool,
|
||||||
|
{ kinds: [KINDS.ListSimple, KINDS.ListReplaceable, KINDS.List, KINDS.WebBookmark], authors: [account.pubkey] },
|
||||||
|
{
|
||||||
|
onEvent: (evt) => {
|
||||||
|
const key = getEventKey(evt)
|
||||||
|
const existing = this.currentEvents.get(key)
|
||||||
|
|
||||||
|
if (existing && (existing.created_at || 0) >= (evt.created_at || 0)) {
|
||||||
|
return // Keep existing (it's newer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add/update event
|
||||||
|
this.currentEvents.set(key, evt)
|
||||||
|
console.log('[bookmark] 📨 Event:', evt.kind, evt.id.slice(0, 8), 'encrypted:', hasEncryptedContent(evt))
|
||||||
|
|
||||||
|
// Emit raw event for Debug UI
|
||||||
|
this.emitRawEvent(evt)
|
||||||
|
|
||||||
|
// Build bookmarks immediately for unencrypted events
|
||||||
|
const isEncrypted = hasEncryptedContent(evt)
|
||||||
|
if (!isEncrypted) {
|
||||||
|
// For unencrypted events, build bookmarks immediately (progressive update)
|
||||||
|
this.buildAndEmitBookmarks(maybeAccount, signerCandidate)
|
||||||
|
.catch(err => console.error('[bookmark] ❌ Failed to update after event:', err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-decrypt if event has encrypted content (fire-and-forget, non-blocking)
|
||||||
|
if (isEncrypted) {
|
||||||
|
console.log('[bookmark] 🔓 Auto-decrypting event', evt.id.slice(0, 8))
|
||||||
|
// Don't await - let it run in background
|
||||||
|
collectBookmarksFromEvents([evt], account, signerCandidate)
|
||||||
|
.then(({ publicItemsAll, privateItemsAll, newestCreatedAt, latestContent, allTags }) => {
|
||||||
|
const eventKey = getEventKey(evt)
|
||||||
|
// Store the actual decrypted items, not just counts
|
||||||
|
this.decryptedResults.set(eventKey, {
|
||||||
|
publicItems: publicItemsAll,
|
||||||
|
privateItems: privateItemsAll,
|
||||||
|
newestCreatedAt,
|
||||||
|
latestContent,
|
||||||
|
allTags
|
||||||
|
})
|
||||||
|
console.log('[bookmark] ✅ Auto-decrypted:', evt.id.slice(0, 8), {
|
||||||
|
public: publicItemsAll.length,
|
||||||
|
private: privateItemsAll.length
|
||||||
|
})
|
||||||
|
|
||||||
|
// Emit decrypt complete for Debug UI
|
||||||
|
this.decryptCompleteListeners.forEach(cb =>
|
||||||
|
cb(evt.id, publicItemsAll.length, privateItemsAll.length)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rebuild bookmarks with newly decrypted content (progressive update)
|
||||||
|
this.buildAndEmitBookmarks(maybeAccount, signerCandidate)
|
||||||
|
.catch(err => console.error('[bookmark] ❌ Failed to update after decrypt:', err))
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('[bookmark] ❌ Auto-decrypt failed:', evt.id.slice(0, 8), error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Final update after EOSE
|
||||||
|
await this.buildAndEmitBookmarks(maybeAccount, signerCandidate)
|
||||||
|
console.log('[bookmark] ✅ Bookmark load complete')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[bookmark] ❌ Failed to load bookmarks:', error)
|
||||||
|
this.bookmarksListeners.forEach(cb => cb([]))
|
||||||
|
} finally {
|
||||||
|
this.setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
export const bookmarkController = new BookmarkController()
|
||||||
|
|
||||||
@@ -12,15 +12,93 @@ type HiddenContentSigner = Parameters<UnlockHiddenTagsFn>[1]
|
|||||||
type UnlockMode = Parameters<UnlockHiddenTagsFn>[2]
|
type UnlockMode = Parameters<UnlockHiddenTagsFn>[2]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrap a decrypt promise with a timeout to prevent hanging (using 30s timeout for bunker)
|
* Decrypt/unlock a single event and return private bookmarks
|
||||||
*/
|
*/
|
||||||
function withDecryptTimeout<T>(promise: Promise<T>, timeoutMs = 30000): Promise<T> {
|
async function decryptEvent(
|
||||||
return Promise.race([
|
evt: NostrEvent,
|
||||||
promise,
|
activeAccount: ActiveAccount,
|
||||||
new Promise<T>((_, reject) =>
|
signerCandidate: unknown,
|
||||||
setTimeout(() => reject(new Error(`Decrypt timeout after ${timeoutMs}ms`)), timeoutMs)
|
metadata: { dTag?: string; setTitle?: string; setDescription?: string; setImage?: string }
|
||||||
)
|
): Promise<IndividualBookmark[]> {
|
||||||
])
|
const { dTag, setTitle, setDescription, setImage } = metadata
|
||||||
|
const privateItems: IndividualBookmark[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt)) {
|
||||||
|
try {
|
||||||
|
await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner)
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner, 'nip44' as UnlockMode)
|
||||||
|
} catch (err) {
|
||||||
|
console.log("[bunker] ❌ nip44.decrypt failed:", err instanceof Error ? err.message : String(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (evt.content && evt.content.length > 0) {
|
||||||
|
let decryptedContent: string | undefined
|
||||||
|
|
||||||
|
// Try to detect encryption method from content format
|
||||||
|
// NIP-44 starts with version byte (currently 0x02), NIP-04 is base64
|
||||||
|
const looksLikeNip44 = evt.content.length > 0 && !evt.content.includes('?iv=')
|
||||||
|
|
||||||
|
// Try the likely method first (no timeout - let it fail naturally like debug page)
|
||||||
|
if (looksLikeNip44 && hasNip44Decrypt(signerCandidate)) {
|
||||||
|
try {
|
||||||
|
decryptedContent = await (signerCandidate as { nip44: { decrypt: DecryptFn } }).nip44.decrypt(evt.pubkey, evt.content)
|
||||||
|
} catch (err) {
|
||||||
|
console.log("[bunker] ❌ nip44.decrypt failed:", err instanceof Error ? err.message : String(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to nip04 if nip44 failed or content looks like nip04
|
||||||
|
if (!decryptedContent && hasNip04Decrypt(signerCandidate)) {
|
||||||
|
try {
|
||||||
|
decryptedContent = await (signerCandidate as { nip04: { decrypt: DecryptFn } }).nip04.decrypt(evt.pubkey, evt.content)
|
||||||
|
} catch (err) {
|
||||||
|
console.log("[bunker] ❌ nip04.decrypt failed:", err instanceof Error ? err.message : String(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decryptedContent) {
|
||||||
|
try {
|
||||||
|
const hiddenTags = JSON.parse(decryptedContent) as string[][]
|
||||||
|
const manualPrivate = Helpers.parseBookmarkTags(hiddenTags)
|
||||||
|
privateItems.push(
|
||||||
|
...processApplesauceBookmarks(manualPrivate, activeAccount, true).map(i => ({
|
||||||
|
...i,
|
||||||
|
sourceKind: evt.kind,
|
||||||
|
setName: dTag,
|
||||||
|
setTitle,
|
||||||
|
setDescription,
|
||||||
|
setImage
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
Reflect.set(evt, BookmarkHiddenSymbol, manualPrivate)
|
||||||
|
Reflect.set(evt, 'EncryptedContentSymbol', decryptedContent)
|
||||||
|
} catch (err) {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const priv = Helpers.getHiddenBookmarks(evt)
|
||||||
|
if (priv) {
|
||||||
|
privateItems.push(
|
||||||
|
...processApplesauceBookmarks(priv, activeAccount, true).map(i => ({
|
||||||
|
...i,
|
||||||
|
sourceKind: evt.kind,
|
||||||
|
setName: dTag,
|
||||||
|
setTitle,
|
||||||
|
setDescription,
|
||||||
|
setImage
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore individual event failures
|
||||||
|
}
|
||||||
|
|
||||||
|
return privateItems
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function collectBookmarksFromEvents(
|
export async function collectBookmarksFromEvents(
|
||||||
@@ -35,21 +113,23 @@ export async function collectBookmarksFromEvents(
|
|||||||
allTags: string[][]
|
allTags: string[][]
|
||||||
}> {
|
}> {
|
||||||
const publicItemsAll: IndividualBookmark[] = []
|
const publicItemsAll: IndividualBookmark[] = []
|
||||||
const privateItemsAll: IndividualBookmark[] = []
|
|
||||||
let newestCreatedAt = 0
|
let newestCreatedAt = 0
|
||||||
let latestContent = ''
|
let latestContent = ''
|
||||||
let allTags: string[][] = []
|
let allTags: string[][] = []
|
||||||
|
|
||||||
|
// Build list of events needing decrypt and collect public items immediately
|
||||||
|
const decryptJobs: Array<{ evt: NostrEvent; metadata: { dTag?: string; setTitle?: string; setDescription?: string; setImage?: string } }> = []
|
||||||
|
|
||||||
for (const evt of bookmarkListEvents) {
|
for (const evt of bookmarkListEvents) {
|
||||||
newestCreatedAt = Math.max(newestCreatedAt, evt.created_at || 0)
|
newestCreatedAt = Math.max(newestCreatedAt, evt.created_at || 0)
|
||||||
if (!latestContent && evt.content && !Helpers.hasHiddenContent(evt)) latestContent = evt.content
|
if (!latestContent && evt.content && !Helpers.hasHiddenContent(evt)) latestContent = evt.content
|
||||||
if (Array.isArray(evt.tags)) allTags = allTags.concat(evt.tags)
|
if (Array.isArray(evt.tags)) allTags = allTags.concat(evt.tags)
|
||||||
|
|
||||||
// Extract the 'd' tag and metadata for bookmark sets (kind 30003)
|
|
||||||
const dTag = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] : undefined
|
const dTag = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] : undefined
|
||||||
const setTitle = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'title')?.[1] : undefined
|
const setTitle = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'title')?.[1] : undefined
|
||||||
const setDescription = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'description')?.[1] : undefined
|
const setDescription = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'description')?.[1] : undefined
|
||||||
const setImage = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'image')?.[1] : undefined
|
const setImage = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'image')?.[1] : undefined
|
||||||
|
const metadata = { dTag, setTitle, setDescription, setImage }
|
||||||
|
|
||||||
// Handle web bookmarks (kind:39701) as individual bookmarks
|
// Handle web bookmarks (kind:39701) as individual bookmarks
|
||||||
if (evt.kind === 39701) {
|
if (evt.kind === 39701) {
|
||||||
@@ -85,72 +165,22 @@ export async function collectBookmarksFromEvents(
|
|||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
// Schedule decrypt if needed
|
||||||
if (Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt) && signerCandidate) {
|
// Check for NIP-44 (Helpers.hasHiddenContent), NIP-04 (?iv= in content), or encrypted tags
|
||||||
try {
|
const hasNip04Content = evt.content && evt.content.includes('?iv=')
|
||||||
await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner)
|
const needsDecrypt = signerCandidate && (
|
||||||
} catch {
|
(Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt)) ||
|
||||||
try {
|
Helpers.hasHiddenContent(evt) ||
|
||||||
await Helpers.unlockHiddenTags(evt, signerCandidate as HiddenContentSigner, 'nip44' as UnlockMode)
|
hasNip04Content
|
||||||
} catch (err) {
|
)
|
||||||
console.log("[bunker] ❌ nip44.decrypt failed:", err instanceof Error ? err.message : String(err))
|
|
||||||
// ignore
|
if (needsDecrypt) {
|
||||||
}
|
decryptJobs.push({ evt, metadata })
|
||||||
}
|
} else {
|
||||||
} else if (evt.content && evt.content.length > 0 && signerCandidate) {
|
// Check for already-unlocked hidden bookmarks
|
||||||
let decryptedContent: string | undefined
|
|
||||||
try {
|
|
||||||
if (hasNip44Decrypt(signerCandidate)) {
|
|
||||||
decryptedContent = await withDecryptTimeout((signerCandidate as { nip44: { decrypt: DecryptFn } }).nip44.decrypt(
|
|
||||||
evt.pubkey,
|
|
||||||
evt.content
|
|
||||||
))
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.log("[bunker] ❌ nip44.decrypt failed:", err instanceof Error ? err.message : String(err))
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!decryptedContent) {
|
|
||||||
try {
|
|
||||||
if (hasNip04Decrypt(signerCandidate)) {
|
|
||||||
decryptedContent = await withDecryptTimeout((signerCandidate as { nip04: { decrypt: DecryptFn } }).nip04.decrypt(
|
|
||||||
evt.pubkey,
|
|
||||||
evt.content
|
|
||||||
))
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.log("[bunker] ❌ nip04.decrypt failed:", err instanceof Error ? err.message : String(err))
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (decryptedContent) {
|
|
||||||
try {
|
|
||||||
const hiddenTags = JSON.parse(decryptedContent) as string[][]
|
|
||||||
const manualPrivate = Helpers.parseBookmarkTags(hiddenTags)
|
|
||||||
privateItemsAll.push(
|
|
||||||
...processApplesauceBookmarks(manualPrivate, activeAccount, true).map(i => ({
|
|
||||||
...i,
|
|
||||||
sourceKind: evt.kind,
|
|
||||||
setName: dTag,
|
|
||||||
setTitle,
|
|
||||||
setDescription,
|
|
||||||
setImage
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
Reflect.set(evt, BookmarkHiddenSymbol, manualPrivate)
|
|
||||||
Reflect.set(evt, 'EncryptedContentSymbol', decryptedContent)
|
|
||||||
// Don't set latestContent to decrypted JSON - it's not user-facing content
|
|
||||||
} catch (err) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const priv = Helpers.getHiddenBookmarks(evt)
|
const priv = Helpers.getHiddenBookmarks(evt)
|
||||||
if (priv) {
|
if (priv) {
|
||||||
privateItemsAll.push(
|
publicItemsAll.push(
|
||||||
...processApplesauceBookmarks(priv, activeAccount, true).map(i => ({
|
...processApplesauceBookmarks(priv, activeAccount, true).map(i => ({
|
||||||
...i,
|
...i,
|
||||||
sourceKind: evt.kind,
|
sourceKind: evt.kind,
|
||||||
@@ -161,8 +191,17 @@ export async function collectBookmarksFromEvents(
|
|||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch {
|
}
|
||||||
// ignore individual event failures
|
}
|
||||||
|
|
||||||
|
// Decrypt events sequentially
|
||||||
|
const privateItemsAll: IndividualBookmark[] = []
|
||||||
|
if (decryptJobs.length > 0 && signerCandidate) {
|
||||||
|
for (const job of decryptJobs) {
|
||||||
|
const privateItems = await decryptEvent(job.evt, activeAccount, signerCandidate, job.metadata)
|
||||||
|
if (privateItems && privateItems.length > 0) {
|
||||||
|
privateItemsAll.push(...privateItems)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,241 +0,0 @@
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
|
||||||
import {
|
|
||||||
AccountWithExtension,
|
|
||||||
NostrEvent,
|
|
||||||
dedupeNip51Events,
|
|
||||||
hydrateItems,
|
|
||||||
isAccountWithExtension,
|
|
||||||
hasNip04Decrypt,
|
|
||||||
hasNip44Decrypt,
|
|
||||||
dedupeBookmarksById,
|
|
||||||
extractUrlsFromContent
|
|
||||||
} from './bookmarkHelpers'
|
|
||||||
import { Bookmark } from '../types/bookmarks'
|
|
||||||
import { collectBookmarksFromEvents } from './bookmarkProcessing.ts'
|
|
||||||
import { UserSettings } from './settingsService'
|
|
||||||
import { rebroadcastEvents } from './rebroadcastService'
|
|
||||||
import { queryEvents } from './dataFetch'
|
|
||||||
import { KINDS } from '../config/kinds'
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const fetchBookmarks = async (
|
|
||||||
relayPool: RelayPool,
|
|
||||||
activeAccount: unknown, // Full account object with extension capabilities
|
|
||||||
setBookmarks: (bookmarks: Bookmark[]) => void,
|
|
||||||
settings?: UserSettings
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
|
|
||||||
if (!isAccountWithExtension(activeAccount)) {
|
|
||||||
throw new Error('Invalid account object provided')
|
|
||||||
}
|
|
||||||
// Fetch bookmark events - NIP-51 standards, legacy formats, and web bookmarks (NIP-B0)
|
|
||||||
console.log('🔍 Fetching bookmark events')
|
|
||||||
|
|
||||||
const rawEvents = await queryEvents(
|
|
||||||
relayPool,
|
|
||||||
{ kinds: [KINDS.ListSimple, KINDS.ListReplaceable, KINDS.List, KINDS.WebBookmark], authors: [activeAccount.pubkey] },
|
|
||||||
{}
|
|
||||||
)
|
|
||||||
console.log('📊 Raw events fetched:', rawEvents.length, 'events')
|
|
||||||
|
|
||||||
// Rebroadcast bookmark events to local/all relays based on settings
|
|
||||||
await rebroadcastEvents(rawEvents, relayPool, settings)
|
|
||||||
|
|
||||||
// Check for events with potentially encrypted content
|
|
||||||
const eventsWithContent = rawEvents.filter(evt => evt.content && evt.content.length > 0)
|
|
||||||
if (eventsWithContent.length > 0) {
|
|
||||||
console.log('🔐 Events with content (potentially encrypted):', eventsWithContent.length)
|
|
||||||
eventsWithContent.forEach((evt, i) => {
|
|
||||||
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || 'none'
|
|
||||||
const contentPreview = evt.content.slice(0, 60) + (evt.content.length > 60 ? '...' : '')
|
|
||||||
console.log(` Encrypted Event ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag=${dTag}, contentLength=${evt.content.length}, preview=${contentPreview}`)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
rawEvents.forEach((evt, i) => {
|
|
||||||
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || 'none'
|
|
||||||
const contentPreview = evt.content ? evt.content.slice(0, 50) + (evt.content.length > 50 ? '...' : '') : 'empty'
|
|
||||||
const eTags = evt.tags?.filter((t: string[]) => t[0] === 'e').length || 0
|
|
||||||
const aTags = evt.tags?.filter((t: string[]) => t[0] === 'a').length || 0
|
|
||||||
console.log(` Event ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag=${dTag}, contentLength=${evt.content?.length || 0}, eTags=${eTags}, aTags=${aTags}, contentPreview=${contentPreview}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
const bookmarkListEvents = dedupeNip51Events(rawEvents)
|
|
||||||
console.log('📋 After deduplication:', bookmarkListEvents.length, 'bookmark events')
|
|
||||||
|
|
||||||
// Log which events made it through deduplication
|
|
||||||
bookmarkListEvents.forEach((evt, i) => {
|
|
||||||
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || 'none'
|
|
||||||
console.log(` Dedupe ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag="${dTag}"`)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Check specifically for Primal's "reads" list
|
|
||||||
const primalReads = rawEvents.find(e => e.kind === KINDS.ListSimple && e.tags?.find((t: string[]) => t[0] === 'd' && t[1] === 'reads'))
|
|
||||||
if (primalReads) {
|
|
||||||
console.log('✅ Found Primal reads list:', primalReads.id.slice(0, 8))
|
|
||||||
} else {
|
|
||||||
console.log('❌ No Primal reads list found (kind:10003 with d="reads")')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bookmarkListEvents.length === 0) {
|
|
||||||
// Keep existing bookmarks visible; do not clear list if nothing new found
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Aggregate across events
|
|
||||||
const maybeAccount = activeAccount as AccountWithExtension
|
|
||||||
console.log('[bunker] 🔐 Account object:', {
|
|
||||||
hasSignEvent: typeof maybeAccount?.signEvent === 'function',
|
|
||||||
hasSigner: !!maybeAccount?.signer,
|
|
||||||
accountType: typeof maybeAccount,
|
|
||||||
accountKeys: maybeAccount ? Object.keys(maybeAccount) : []
|
|
||||||
})
|
|
||||||
|
|
||||||
// For ExtensionAccount, we need a signer with nip04/nip44 for decrypting hidden content
|
|
||||||
// The ExtensionAccount itself has nip04/nip44 getters that proxy to the signer
|
|
||||||
let signerCandidate: unknown = maybeAccount
|
|
||||||
const hasNip04Prop = (signerCandidate as { nip04?: unknown })?.nip04 !== undefined
|
|
||||||
const hasNip44Prop = (signerCandidate as { nip44?: unknown })?.nip44 !== undefined
|
|
||||||
if (signerCandidate && !hasNip04Prop && !hasNip44Prop && maybeAccount?.signer) {
|
|
||||||
// Fallback to the raw signer if account doesn't have nip04/nip44
|
|
||||||
signerCandidate = maybeAccount.signer
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[bunker] 🔑 Signer candidate:', !!signerCandidate, typeof signerCandidate)
|
|
||||||
if (signerCandidate) {
|
|
||||||
console.log('[bunker] 🔑 Signer has nip04:', hasNip04Decrypt(signerCandidate))
|
|
||||||
console.log('[bunker] 🔑 Signer has nip44:', hasNip44Decrypt(signerCandidate))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debug relay connectivity for bunker relays
|
|
||||||
try {
|
|
||||||
const urls = Array.from(relayPool.relays.values()).map(r => ({ url: r.url, connected: (r as unknown as { connected?: boolean }).connected }))
|
|
||||||
console.log('[bunker] Relay connections:', urls)
|
|
||||||
} catch (err) { console.warn('[bunker] Failed to read relay connections', err) }
|
|
||||||
|
|
||||||
const { publicItemsAll, privateItemsAll, newestCreatedAt, latestContent, allTags } = await collectBookmarksFromEvents(
|
|
||||||
bookmarkListEvents,
|
|
||||||
activeAccount,
|
|
||||||
signerCandidate
|
|
||||||
)
|
|
||||||
|
|
||||||
const allItems = [...publicItemsAll, ...privateItemsAll]
|
|
||||||
|
|
||||||
// Separate hex IDs (regular events) from coordinates (addressable events)
|
|
||||||
const noteIds: string[] = []
|
|
||||||
const coordinates: string[] = []
|
|
||||||
|
|
||||||
allItems.forEach(i => {
|
|
||||||
// Check if it's a hex ID (64 character hex string)
|
|
||||||
if (/^[0-9a-f]{64}$/i.test(i.id)) {
|
|
||||||
noteIds.push(i.id)
|
|
||||||
} else if (i.id.includes(':')) {
|
|
||||||
// Coordinate format: kind:pubkey:identifier
|
|
||||||
coordinates.push(i.id)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const idToEvent: Map<string, NostrEvent> = new Map()
|
|
||||||
|
|
||||||
// Fetch regular events by ID
|
|
||||||
if (noteIds.length > 0) {
|
|
||||||
try {
|
|
||||||
const events = await queryEvents(
|
|
||||||
relayPool,
|
|
||||||
{ ids: Array.from(new Set(noteIds)) },
|
|
||||||
{ localTimeoutMs: 800, remoteTimeoutMs: 2500 }
|
|
||||||
)
|
|
||||||
events.forEach((e: NostrEvent) => {
|
|
||||||
idToEvent.set(e.id, e)
|
|
||||||
// Also store by coordinate if it's an addressable event
|
|
||||||
if (e.kind && e.kind >= 30000 && e.kind < 40000) {
|
|
||||||
const dTag = e.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
|
||||||
const coordinate = `${e.kind}:${e.pubkey}:${dTag}`
|
|
||||||
idToEvent.set(coordinate, e)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to fetch events by ID:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch addressable events by coordinates
|
|
||||||
if (coordinates.length > 0) {
|
|
||||||
try {
|
|
||||||
// Group by kind for more efficient querying
|
|
||||||
const byKind = new Map<number, Array<{ pubkey: string; identifier: string }>>()
|
|
||||||
|
|
||||||
coordinates.forEach(coord => {
|
|
||||||
const parts = coord.split(':')
|
|
||||||
const kind = parseInt(parts[0])
|
|
||||||
const pubkey = parts[1]
|
|
||||||
const identifier = parts[2] || ''
|
|
||||||
|
|
||||||
if (!byKind.has(kind)) {
|
|
||||||
byKind.set(kind, [])
|
|
||||||
}
|
|
||||||
byKind.get(kind)!.push({ pubkey, identifier })
|
|
||||||
})
|
|
||||||
|
|
||||||
// Query each kind group
|
|
||||||
for (const [kind, items] of byKind.entries()) {
|
|
||||||
const authors = Array.from(new Set(items.map(i => i.pubkey)))
|
|
||||||
const identifiers = Array.from(new Set(items.map(i => i.identifier)))
|
|
||||||
|
|
||||||
const events = await queryEvents(
|
|
||||||
relayPool,
|
|
||||||
{ kinds: [kind], authors, '#d': identifiers },
|
|
||||||
{ localTimeoutMs: 800, remoteTimeoutMs: 2500 }
|
|
||||||
)
|
|
||||||
|
|
||||||
events.forEach((e: NostrEvent) => {
|
|
||||||
const dTag = e.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
|
||||||
const coordinate = `${e.kind}:${e.pubkey}:${dTag}`
|
|
||||||
idToEvent.set(coordinate, e)
|
|
||||||
// Also store by event ID
|
|
||||||
idToEvent.set(e.id, e)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to fetch addressable events:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`📦 Hydration: fetched ${idToEvent.size} events for ${allItems.length} bookmarks (${noteIds.length} notes, ${coordinates.length} articles)`)
|
|
||||||
const allBookmarks = dedupeBookmarksById([
|
|
||||||
...hydrateItems(publicItemsAll, idToEvent),
|
|
||||||
...hydrateItems(privateItemsAll, idToEvent)
|
|
||||||
])
|
|
||||||
|
|
||||||
// Sort individual bookmarks by "added" timestamp first (most recently added first),
|
|
||||||
// falling back to event created_at when unknown.
|
|
||||||
const enriched = allBookmarks.map(b => ({
|
|
||||||
...b,
|
|
||||||
tags: b.tags || [],
|
|
||||||
content: b.content || ''
|
|
||||||
}))
|
|
||||||
const sortedBookmarks = enriched
|
|
||||||
.map(b => ({ ...b, urlReferences: extractUrlsFromContent(b.content) }))
|
|
||||||
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
|
|
||||||
|
|
||||||
const bookmark: Bookmark = {
|
|
||||||
id: `${activeAccount.pubkey}-bookmarks`,
|
|
||||||
title: `Bookmarks (${sortedBookmarks.length})`,
|
|
||||||
url: '',
|
|
||||||
content: latestContent,
|
|
||||||
created_at: newestCreatedAt || Math.floor(Date.now() / 1000),
|
|
||||||
tags: allTags,
|
|
||||||
bookmarkCount: sortedBookmarks.length,
|
|
||||||
eventReferences: allTags.filter((tag: string[]) => tag[0] === 'e').map((tag: string[]) => tag[1]),
|
|
||||||
individualBookmarks: sortedBookmarks,
|
|
||||||
isPrivate: privateItemsAll.length > 0,
|
|
||||||
encryptedContent: undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
setBookmarks([bookmark])
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch bookmarks:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { prioritizeLocalRelays } from '../utils/helpers'
|
import { prioritizeLocalRelays } from '../utils/helpers'
|
||||||
import { queryEvents } from './dataFetch'
|
import { queryEvents } from './dataFetch'
|
||||||
import { CONTACTS_REMOTE_TIMEOUT_MS } from '../config/network'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches the contact list (follows) for a specific user
|
* Fetches the contact list (follows) for a specific user
|
||||||
@@ -24,7 +23,6 @@ export const fetchContacts = async (
|
|||||||
{ kinds: [3], authors: [pubkey] },
|
{ kinds: [3], authors: [pubkey] },
|
||||||
{
|
{
|
||||||
relayUrls,
|
relayUrls,
|
||||||
remoteTimeoutMs: CONTACTS_REMOTE_TIMEOUT_MS,
|
|
||||||
onEvent: (event: { created_at: number; tags: string[][] }) => {
|
onEvent: (event: { created_at: number; tags: string[][] }) => {
|
||||||
// Stream partials as we see any contact list
|
// Stream partials as we see any contact list
|
||||||
for (const tag of event.tags) {
|
for (const tag of event.tags) {
|
||||||
|
|||||||
114
src/services/contactsController.ts
Normal file
114
src/services/contactsController.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { fetchContacts } from './contactService'
|
||||||
|
|
||||||
|
type ContactsCallback = (contacts: Set<string>) => void
|
||||||
|
type LoadingCallback = (loading: boolean) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared contacts/friends controller
|
||||||
|
* Manages the user's follow list centrally, similar to bookmarkController
|
||||||
|
*/
|
||||||
|
class ContactsController {
|
||||||
|
private contactsListeners: ContactsCallback[] = []
|
||||||
|
private loadingListeners: LoadingCallback[] = []
|
||||||
|
|
||||||
|
private currentContacts: Set<string> = new Set()
|
||||||
|
private lastLoadedPubkey: string | null = null
|
||||||
|
|
||||||
|
onContacts(cb: ContactsCallback): () => void {
|
||||||
|
this.contactsListeners.push(cb)
|
||||||
|
return () => {
|
||||||
|
this.contactsListeners = this.contactsListeners.filter(l => l !== cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoading(cb: LoadingCallback): () => void {
|
||||||
|
this.loadingListeners.push(cb)
|
||||||
|
return () => {
|
||||||
|
this.loadingListeners = this.loadingListeners.filter(l => l !== cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setLoading(loading: boolean): void {
|
||||||
|
this.loadingListeners.forEach(cb => cb(loading))
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitContacts(contacts: Set<string>): void {
|
||||||
|
this.contactsListeners.forEach(cb => cb(contacts))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current contacts without triggering a reload
|
||||||
|
*/
|
||||||
|
getContacts(): Set<string> {
|
||||||
|
return new Set(this.currentContacts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if contacts are loaded for a specific pubkey
|
||||||
|
*/
|
||||||
|
isLoadedFor(pubkey: string): boolean {
|
||||||
|
return this.lastLoadedPubkey === pubkey && this.currentContacts.size > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset state (for logout or manual refresh)
|
||||||
|
*/
|
||||||
|
reset(): void {
|
||||||
|
this.currentContacts.clear()
|
||||||
|
this.lastLoadedPubkey = null
|
||||||
|
this.emitContacts(this.currentContacts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load contacts for a user
|
||||||
|
* Streams partial results and caches the final list
|
||||||
|
*/
|
||||||
|
async start(options: {
|
||||||
|
relayPool: RelayPool
|
||||||
|
pubkey: string
|
||||||
|
force?: boolean
|
||||||
|
}): Promise<void> {
|
||||||
|
const { relayPool, pubkey, force = false } = options
|
||||||
|
|
||||||
|
// Skip if already loaded for this pubkey (unless forced)
|
||||||
|
if (!force && this.isLoadedFor(pubkey)) {
|
||||||
|
console.log('[contacts] ✅ Already loaded for', pubkey.slice(0, 8))
|
||||||
|
this.emitContacts(this.currentContacts)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setLoading(true)
|
||||||
|
console.log('[contacts] 🔍 Loading contacts for', pubkey.slice(0, 8))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const contacts = await fetchContacts(
|
||||||
|
relayPool,
|
||||||
|
pubkey,
|
||||||
|
(partial) => {
|
||||||
|
// Stream partial updates
|
||||||
|
this.currentContacts = new Set(partial)
|
||||||
|
this.emitContacts(this.currentContacts)
|
||||||
|
console.log('[contacts] 📥 Partial contacts:', partial.size)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store final result
|
||||||
|
this.currentContacts = new Set(contacts)
|
||||||
|
this.lastLoadedPubkey = pubkey
|
||||||
|
this.emitContacts(this.currentContacts)
|
||||||
|
|
||||||
|
console.log('[contacts] ✅ Loaded', contacts.size, 'contacts')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[contacts] ❌ Failed to load contacts:', error)
|
||||||
|
this.currentContacts.clear()
|
||||||
|
this.emitContacts(this.currentContacts)
|
||||||
|
} finally {
|
||||||
|
this.setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
export const contactsController = new ContactsController()
|
||||||
|
|
||||||
@@ -1,20 +1,18 @@
|
|||||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||||
import { Observable, merge, takeUntil, timer, toArray, tap, lastValueFrom } from 'rxjs'
|
import { Observable, merge, toArray, tap, lastValueFrom } from 'rxjs'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { Filter } from 'nostr-tools/filter'
|
import { Filter } from 'nostr-tools/filter'
|
||||||
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
|
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
|
||||||
import { LOCAL_TIMEOUT_MS, REMOTE_TIMEOUT_MS } from '../config/network'
|
|
||||||
|
|
||||||
export interface QueryOptions {
|
export interface QueryOptions {
|
||||||
relayUrls?: string[]
|
relayUrls?: string[]
|
||||||
localTimeoutMs?: number
|
|
||||||
remoteTimeoutMs?: number
|
|
||||||
onEvent?: (event: NostrEvent) => void
|
onEvent?: (event: NostrEvent) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unified local-first query helper with optional streaming callback.
|
* Unified local-first query helper with optional streaming callback.
|
||||||
* Returns all collected events (deduped by id) after both streams complete or time out.
|
* Returns all collected events (deduped by id) after both streams complete (EOSE).
|
||||||
|
* Trusts relay EOSE signals - no artificial timeouts.
|
||||||
*/
|
*/
|
||||||
export async function queryEvents(
|
export async function queryEvents(
|
||||||
relayPool: RelayPool,
|
relayPool: RelayPool,
|
||||||
@@ -23,8 +21,6 @@ export async function queryEvents(
|
|||||||
): Promise<NostrEvent[]> {
|
): Promise<NostrEvent[]> {
|
||||||
const {
|
const {
|
||||||
relayUrls,
|
relayUrls,
|
||||||
localTimeoutMs = LOCAL_TIMEOUT_MS,
|
|
||||||
remoteTimeoutMs = REMOTE_TIMEOUT_MS,
|
|
||||||
onEvent
|
onEvent
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
@@ -41,8 +37,7 @@ export async function queryEvents(
|
|||||||
.pipe(
|
.pipe(
|
||||||
onlyEvents(),
|
onlyEvents(),
|
||||||
onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}),
|
onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}),
|
||||||
completeOnEose(),
|
completeOnEose()
|
||||||
takeUntil(timer(localTimeoutMs))
|
|
||||||
) as unknown as Observable<NostrEvent>
|
) as unknown as Observable<NostrEvent>
|
||||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||||
|
|
||||||
@@ -52,8 +47,7 @@ export async function queryEvents(
|
|||||||
.pipe(
|
.pipe(
|
||||||
onlyEvents(),
|
onlyEvents(),
|
||||||
onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}),
|
onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}),
|
||||||
completeOnEose(),
|
completeOnEose()
|
||||||
takeUntil(timer(remoteTimeoutMs))
|
|
||||||
) as unknown as Observable<NostrEvent>
|
) as unknown as Observable<NostrEvent>
|
||||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||||
|
|
||||||
|
|||||||
96
src/services/highlights/cache.ts
Normal file
96
src/services/highlights/cache.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { Highlight } from '../../types/highlights'
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
highlights: Highlight[]
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple in-memory session cache for highlight queries with TTL
|
||||||
|
*/
|
||||||
|
class HighlightCache {
|
||||||
|
private cache = new Map<string, CacheEntry>()
|
||||||
|
private ttlMs = 60000 // 60 seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate cache key for article coordinate
|
||||||
|
*/
|
||||||
|
articleKey(coordinate: string): string {
|
||||||
|
return `article:${coordinate}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate cache key for URL
|
||||||
|
*/
|
||||||
|
urlKey(url: string): string {
|
||||||
|
// Normalize URL for consistent caching
|
||||||
|
try {
|
||||||
|
const normalized = new URL(url)
|
||||||
|
normalized.hash = '' // Remove hash
|
||||||
|
return `url:${normalized.toString()}`
|
||||||
|
} catch {
|
||||||
|
return `url:${url}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate cache key for author pubkey
|
||||||
|
*/
|
||||||
|
authorKey(pubkey: string): string {
|
||||||
|
return `author:${pubkey}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached highlights if not expired
|
||||||
|
*/
|
||||||
|
get(key: string): Highlight[] | null {
|
||||||
|
const entry = this.cache.get(key)
|
||||||
|
if (!entry) return null
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - entry.timestamp > this.ttlMs) {
|
||||||
|
this.cache.delete(key)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.highlights
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store highlights in cache
|
||||||
|
*/
|
||||||
|
set(key: string, highlights: Highlight[]): void {
|
||||||
|
this.cache.set(key, {
|
||||||
|
highlights,
|
||||||
|
timestamp: Date.now()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear specific cache entry
|
||||||
|
*/
|
||||||
|
clear(key: string): void {
|
||||||
|
this.cache.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all cache entries
|
||||||
|
*/
|
||||||
|
clearAll(): void {
|
||||||
|
this.cache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache stats
|
||||||
|
*/
|
||||||
|
stats(): { size: number; keys: string[] } {
|
||||||
|
return {
|
||||||
|
size: this.cache.size,
|
||||||
|
keys: Array.from(this.cache.keys())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
export const highlightCache = new HighlightCache()
|
||||||
|
|
||||||
@@ -1,61 +1,77 @@
|
|||||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
|
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { Highlight } from '../../types/highlights'
|
import { Highlight } from '../../types/highlights'
|
||||||
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
|
|
||||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
||||||
import { UserSettings } from '../settingsService'
|
import { UserSettings } from '../settingsService'
|
||||||
import { rebroadcastEvents } from '../rebroadcastService'
|
import { rebroadcastEvents } from '../rebroadcastService'
|
||||||
import { KINDS } from '../../config/kinds'
|
import { KINDS } from '../../config/kinds'
|
||||||
|
import { queryEvents } from '../dataFetch'
|
||||||
|
import { highlightCache } from './cache'
|
||||||
|
|
||||||
export const fetchHighlights = async (
|
export const fetchHighlights = async (
|
||||||
relayPool: RelayPool,
|
relayPool: RelayPool,
|
||||||
pubkey: string,
|
pubkey: string,
|
||||||
onHighlight?: (highlight: Highlight) => void,
|
onHighlight?: (highlight: Highlight) => void,
|
||||||
settings?: UserSettings
|
settings?: UserSettings,
|
||||||
|
force = false,
|
||||||
|
eventStore?: IEventStore
|
||||||
): Promise<Highlight[]> => {
|
): Promise<Highlight[]> => {
|
||||||
|
// Check cache first unless force refresh
|
||||||
|
if (!force) {
|
||||||
|
const cacheKey = highlightCache.authorKey(pubkey)
|
||||||
|
const cached = highlightCache.get(cacheKey)
|
||||||
|
if (cached) {
|
||||||
|
console.log(`📌 Using cached highlights for author (${cached.length} items)`)
|
||||||
|
// Stream cached highlights if callback provided
|
||||||
|
if (onHighlight) {
|
||||||
|
cached.forEach(h => onHighlight(h))
|
||||||
|
}
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
|
||||||
const ordered = prioritizeLocalRelays(relayUrls)
|
|
||||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(ordered)
|
|
||||||
|
|
||||||
const seenIds = new Set<string>()
|
const seenIds = new Set<string>()
|
||||||
const local$ = localRelays.length > 0
|
const rawEvents: NostrEvent[] = await queryEvents(
|
||||||
? relayPool
|
relayPool,
|
||||||
.req(localRelays, { kinds: [KINDS.Highlights], authors: [pubkey] })
|
{ kinds: [KINDS.Highlights], authors: [pubkey] },
|
||||||
.pipe(
|
{
|
||||||
onlyEvents(),
|
onEvent: (event: NostrEvent) => {
|
||||||
tap((event: NostrEvent) => {
|
if (seenIds.has(event.id)) return
|
||||||
if (!seenIds.has(event.id)) {
|
seenIds.add(event.id)
|
||||||
seenIds.add(event.id)
|
|
||||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
// Store in event store if provided
|
||||||
}
|
if (eventStore) {
|
||||||
}),
|
eventStore.add(event)
|
||||||
completeOnEose(),
|
}
|
||||||
takeUntil(timer(1200))
|
|
||||||
)
|
if (onHighlight) onHighlight(eventToHighlight(event))
|
||||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
}
|
||||||
const remote$ = remoteRelays.length > 0
|
}
|
||||||
? relayPool
|
)
|
||||||
.req(remoteRelays, { kinds: [KINDS.Highlights], authors: [pubkey] })
|
|
||||||
.pipe(
|
console.log(`📌 Fetched ${rawEvents.length} highlight events for author:`, pubkey.slice(0, 8))
|
||||||
onlyEvents(),
|
|
||||||
tap((event: NostrEvent) => {
|
// Store all events in event store if provided
|
||||||
if (!seenIds.has(event.id)) {
|
if (eventStore) {
|
||||||
seenIds.add(event.id)
|
rawEvents.forEach(evt => eventStore.add(evt))
|
||||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
}
|
||||||
}
|
|
||||||
}),
|
try {
|
||||||
completeOnEose(),
|
await rebroadcastEvents(rawEvents, relayPool, settings)
|
||||||
takeUntil(timer(6000))
|
} catch (err) {
|
||||||
)
|
console.warn('Failed to rebroadcast highlight events:', err)
|
||||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
}
|
||||||
const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
|
||||||
|
|
||||||
await rebroadcastEvents(rawEvents, relayPool, settings)
|
|
||||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||||
const highlights = uniqueEvents.map(eventToHighlight)
|
const highlights = uniqueEvents.map(eventToHighlight)
|
||||||
return sortHighlights(highlights)
|
const sorted = sortHighlights(highlights)
|
||||||
|
|
||||||
|
// Cache the results
|
||||||
|
const cacheKey = highlightCache.authorKey(pubkey)
|
||||||
|
highlightCache.set(cacheKey, sorted)
|
||||||
|
|
||||||
|
return sorted
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,95 +1,81 @@
|
|||||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
|
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { Highlight } from '../../types/highlights'
|
import { Highlight } from '../../types/highlights'
|
||||||
import { RELAYS } from '../../config/relays'
|
import { KINDS } from '../../config/kinds'
|
||||||
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
|
|
||||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
||||||
import { UserSettings } from '../settingsService'
|
import { UserSettings } from '../settingsService'
|
||||||
import { rebroadcastEvents } from '../rebroadcastService'
|
import { rebroadcastEvents } from '../rebroadcastService'
|
||||||
|
import { queryEvents } from '../dataFetch'
|
||||||
|
import { highlightCache } from './cache'
|
||||||
|
|
||||||
export const fetchHighlightsForArticle = async (
|
export const fetchHighlightsForArticle = async (
|
||||||
relayPool: RelayPool,
|
relayPool: RelayPool,
|
||||||
articleCoordinate: string,
|
articleCoordinate: string,
|
||||||
eventId?: string,
|
eventId?: string,
|
||||||
onHighlight?: (highlight: Highlight) => void,
|
onHighlight?: (highlight: Highlight) => void,
|
||||||
settings?: UserSettings
|
settings?: UserSettings,
|
||||||
|
force = false,
|
||||||
|
eventStore?: IEventStore
|
||||||
): Promise<Highlight[]> => {
|
): Promise<Highlight[]> => {
|
||||||
|
// Check cache first unless force refresh
|
||||||
|
if (!force) {
|
||||||
|
const cacheKey = highlightCache.articleKey(articleCoordinate)
|
||||||
|
const cached = highlightCache.get(cacheKey)
|
||||||
|
if (cached) {
|
||||||
|
console.log(`📌 Using cached highlights for article (${cached.length} items)`)
|
||||||
|
// Stream cached highlights if callback provided
|
||||||
|
if (onHighlight) {
|
||||||
|
cached.forEach(h => onHighlight(h))
|
||||||
|
}
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const seenIds = new Set<string>()
|
const seenIds = new Set<string>()
|
||||||
const processEvent = (event: NostrEvent): Highlight | null => {
|
const onEvent = (event: NostrEvent) => {
|
||||||
if (seenIds.has(event.id)) return null
|
if (seenIds.has(event.id)) return
|
||||||
seenIds.add(event.id)
|
seenIds.add(event.id)
|
||||||
return eventToHighlight(event)
|
|
||||||
|
// Store in event store if provided
|
||||||
|
if (eventStore) {
|
||||||
|
eventStore.add(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onHighlight) onHighlight(eventToHighlight(event))
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderedRelays = prioritizeLocalRelays(RELAYS)
|
// Query for both #a and #e tags in parallel
|
||||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
|
const [aTagEvents, eTagEvents] = await Promise.all([
|
||||||
|
queryEvents(relayPool, { kinds: [KINDS.Highlights], '#a': [articleCoordinate] }, { onEvent }),
|
||||||
const aLocal$ = localRelays.length > 0
|
eventId
|
||||||
? relayPool
|
? queryEvents(relayPool, { kinds: [KINDS.Highlights], '#e': [eventId] }, { onEvent })
|
||||||
.req(localRelays, { kinds: [9802], '#a': [articleCoordinate] })
|
: Promise.resolve([] as NostrEvent[])
|
||||||
.pipe(
|
])
|
||||||
onlyEvents(),
|
|
||||||
tap((event: NostrEvent) => {
|
|
||||||
const highlight = processEvent(event)
|
|
||||||
if (highlight && onHighlight) onHighlight(highlight)
|
|
||||||
}),
|
|
||||||
completeOnEose(),
|
|
||||||
takeUntil(timer(1200))
|
|
||||||
)
|
|
||||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
|
||||||
const aRemote$ = remoteRelays.length > 0
|
|
||||||
? relayPool
|
|
||||||
.req(remoteRelays, { kinds: [9802], '#a': [articleCoordinate] })
|
|
||||||
.pipe(
|
|
||||||
onlyEvents(),
|
|
||||||
tap((event: NostrEvent) => {
|
|
||||||
const highlight = processEvent(event)
|
|
||||||
if (highlight && onHighlight) onHighlight(highlight)
|
|
||||||
}),
|
|
||||||
completeOnEose(),
|
|
||||||
takeUntil(timer(6000))
|
|
||||||
)
|
|
||||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
|
||||||
const aTagEvents: NostrEvent[] = await lastValueFrom(merge(aLocal$, aRemote$).pipe(toArray()))
|
|
||||||
|
|
||||||
let eTagEvents: NostrEvent[] = []
|
|
||||||
if (eventId) {
|
|
||||||
const eLocal$ = localRelays.length > 0
|
|
||||||
? relayPool
|
|
||||||
.req(localRelays, { kinds: [9802], '#e': [eventId] })
|
|
||||||
.pipe(
|
|
||||||
onlyEvents(),
|
|
||||||
tap((event: NostrEvent) => {
|
|
||||||
const highlight = processEvent(event)
|
|
||||||
if (highlight && onHighlight) onHighlight(highlight)
|
|
||||||
}),
|
|
||||||
completeOnEose(),
|
|
||||||
takeUntil(timer(1200))
|
|
||||||
)
|
|
||||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
|
||||||
const eRemote$ = remoteRelays.length > 0
|
|
||||||
? relayPool
|
|
||||||
.req(remoteRelays, { kinds: [9802], '#e': [eventId] })
|
|
||||||
.pipe(
|
|
||||||
onlyEvents(),
|
|
||||||
tap((event: NostrEvent) => {
|
|
||||||
const highlight = processEvent(event)
|
|
||||||
if (highlight && onHighlight) onHighlight(highlight)
|
|
||||||
}),
|
|
||||||
completeOnEose(),
|
|
||||||
takeUntil(timer(6000))
|
|
||||||
)
|
|
||||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
|
||||||
eTagEvents = await lastValueFrom(merge(eLocal$, eRemote$).pipe(toArray()))
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawEvents = [...aTagEvents, ...eTagEvents]
|
const rawEvents = [...aTagEvents, ...eTagEvents]
|
||||||
await rebroadcastEvents(rawEvents, relayPool, settings)
|
console.log(`📌 Fetched ${rawEvents.length} highlight events for article:`, articleCoordinate)
|
||||||
|
|
||||||
|
// Store all events in event store if provided
|
||||||
|
if (eventStore) {
|
||||||
|
rawEvents.forEach(evt => eventStore.add(evt))
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await rebroadcastEvents(rawEvents, relayPool, settings)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to rebroadcast highlight events:', err)
|
||||||
|
}
|
||||||
|
|
||||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||||
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
|
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
|
||||||
return sortHighlights(highlights)
|
const sorted = sortHighlights(highlights)
|
||||||
|
|
||||||
|
// Cache the results
|
||||||
|
const cacheKey = highlightCache.articleKey(articleCoordinate)
|
||||||
|
highlightCache.set(cacheKey, sorted)
|
||||||
|
|
||||||
|
return sorted
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,68 +1,80 @@
|
|||||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
|
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { Highlight } from '../../types/highlights'
|
import { Highlight } from '../../types/highlights'
|
||||||
import { RELAYS } from '../../config/relays'
|
import { KINDS } from '../../config/kinds'
|
||||||
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
|
|
||||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
||||||
import { UserSettings } from '../settingsService'
|
import { UserSettings } from '../settingsService'
|
||||||
import { rebroadcastEvents } from '../rebroadcastService'
|
import { rebroadcastEvents } from '../rebroadcastService'
|
||||||
|
import { queryEvents } from '../dataFetch'
|
||||||
|
import { highlightCache } from './cache'
|
||||||
|
|
||||||
export const fetchHighlightsForUrl = async (
|
export const fetchHighlightsForUrl = async (
|
||||||
relayPool: RelayPool,
|
relayPool: RelayPool,
|
||||||
url: string,
|
url: string,
|
||||||
onHighlight?: (highlight: Highlight) => void,
|
onHighlight?: (highlight: Highlight) => void,
|
||||||
settings?: UserSettings
|
settings?: UserSettings,
|
||||||
|
force = false,
|
||||||
|
eventStore?: IEventStore
|
||||||
): Promise<Highlight[]> => {
|
): Promise<Highlight[]> => {
|
||||||
const seenIds = new Set<string>()
|
// Check cache first unless force refresh
|
||||||
const orderedRelaysUrl = prioritizeLocalRelays(RELAYS)
|
if (!force) {
|
||||||
const { local: localRelaysUrl, remote: remoteRelaysUrl } = partitionRelays(orderedRelaysUrl)
|
const cacheKey = highlightCache.urlKey(url)
|
||||||
|
const cached = highlightCache.get(cacheKey)
|
||||||
|
if (cached) {
|
||||||
|
console.log(`📌 Using cached highlights for URL (${cached.length} items)`)
|
||||||
|
// Stream cached highlights if callback provided
|
||||||
|
if (onHighlight) {
|
||||||
|
cached.forEach(h => onHighlight(h))
|
||||||
|
}
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const local$ = localRelaysUrl.length > 0
|
const seenIds = new Set<string>()
|
||||||
? relayPool
|
const rawEvents: NostrEvent[] = await queryEvents(
|
||||||
.req(localRelaysUrl, { kinds: [9802], '#r': [url] })
|
relayPool,
|
||||||
.pipe(
|
{ kinds: [KINDS.Highlights], '#r': [url] },
|
||||||
onlyEvents(),
|
{
|
||||||
tap((event: NostrEvent) => {
|
onEvent: (event: NostrEvent) => {
|
||||||
seenIds.add(event.id)
|
if (seenIds.has(event.id)) return
|
||||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
seenIds.add(event.id)
|
||||||
}),
|
|
||||||
completeOnEose(),
|
// Store in event store if provided
|
||||||
takeUntil(timer(1200))
|
if (eventStore) {
|
||||||
)
|
eventStore.add(event)
|
||||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
}
|
||||||
const remote$ = remoteRelaysUrl.length > 0
|
|
||||||
? relayPool
|
if (onHighlight) onHighlight(eventToHighlight(event))
|
||||||
.req(remoteRelaysUrl, { kinds: [9802], '#r': [url] })
|
}
|
||||||
.pipe(
|
}
|
||||||
onlyEvents(),
|
)
|
||||||
tap((event: NostrEvent) => {
|
|
||||||
seenIds.add(event.id)
|
|
||||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
|
||||||
}),
|
|
||||||
completeOnEose(),
|
|
||||||
takeUntil(timer(6000))
|
|
||||||
)
|
|
||||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
|
||||||
const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
|
||||||
|
|
||||||
console.log(`📌 Fetched ${rawEvents.length} highlight events for URL:`, url)
|
console.log(`📌 Fetched ${rawEvents.length} highlight events for URL:`, url)
|
||||||
|
|
||||||
|
// Store all events in event store if provided
|
||||||
|
if (eventStore) {
|
||||||
|
rawEvents.forEach(evt => eventStore.add(evt))
|
||||||
|
}
|
||||||
|
|
||||||
// Rebroadcast events - but don't let errors here break the highlight display
|
// Rebroadcast events - but don't let errors here break the highlight display
|
||||||
try {
|
try {
|
||||||
await rebroadcastEvents(rawEvents, relayPool, settings)
|
await rebroadcastEvents(rawEvents, relayPool, settings)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to rebroadcast highlight events:', err)
|
console.warn('Failed to rebroadcast highlight events:', err)
|
||||||
}
|
}
|
||||||
|
|
||||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||||
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
|
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
|
||||||
return sortHighlights(highlights)
|
const sorted = sortHighlights(highlights)
|
||||||
|
|
||||||
|
// Cache the results
|
||||||
|
const cacheKey = highlightCache.urlKey(url)
|
||||||
|
highlightCache.set(cacheKey, sorted)
|
||||||
|
|
||||||
|
return sorted
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching highlights for URL:', err)
|
console.error('Error fetching highlights for URL:', err)
|
||||||
// Return highlights that were already streamed via callback
|
|
||||||
// Don't return empty array as that would clear already-displayed highlights
|
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { Highlight } from '../../types/highlights'
|
import { Highlight } from '../../types/highlights'
|
||||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
||||||
import { queryEvents } from '../dataFetch'
|
import { queryEvents } from '../dataFetch'
|
||||||
@@ -9,12 +10,14 @@ import { queryEvents } from '../dataFetch'
|
|||||||
* @param relayPool - The relay pool to query
|
* @param relayPool - The relay pool to query
|
||||||
* @param pubkeys - Array of pubkeys to fetch highlights from
|
* @param pubkeys - Array of pubkeys to fetch highlights from
|
||||||
* @param onHighlight - Optional callback for streaming highlights as they arrive
|
* @param onHighlight - Optional callback for streaming highlights as they arrive
|
||||||
|
* @param eventStore - Optional event store to persist events
|
||||||
* @returns Array of highlights
|
* @returns Array of highlights
|
||||||
*/
|
*/
|
||||||
export const fetchHighlightsFromAuthors = async (
|
export const fetchHighlightsFromAuthors = async (
|
||||||
relayPool: RelayPool,
|
relayPool: RelayPool,
|
||||||
pubkeys: string[],
|
pubkeys: string[],
|
||||||
onHighlight?: (highlight: Highlight) => void
|
onHighlight?: (highlight: Highlight) => void,
|
||||||
|
eventStore?: IEventStore
|
||||||
): Promise<Highlight[]> => {
|
): Promise<Highlight[]> => {
|
||||||
try {
|
try {
|
||||||
if (pubkeys.length === 0) {
|
if (pubkeys.length === 0) {
|
||||||
@@ -32,12 +35,23 @@ export const fetchHighlightsFromAuthors = async (
|
|||||||
onEvent: (event: NostrEvent) => {
|
onEvent: (event: NostrEvent) => {
|
||||||
if (!seenIds.has(event.id)) {
|
if (!seenIds.has(event.id)) {
|
||||||
seenIds.add(event.id)
|
seenIds.add(event.id)
|
||||||
|
|
||||||
|
// Store in event store if provided
|
||||||
|
if (eventStore) {
|
||||||
|
eventStore.add(event)
|
||||||
|
}
|
||||||
|
|
||||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
if (onHighlight) onHighlight(eventToHighlight(event))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Store all events in event store if provided
|
||||||
|
if (eventStore) {
|
||||||
|
rawEvents.forEach(evt => eventStore.add(evt))
|
||||||
|
}
|
||||||
|
|
||||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||||
const highlights = uniqueEvents.map(eventToHighlight)
|
const highlights = uniqueEvents.map(eventToHighlight)
|
||||||
|
|
||||||
|
|||||||
208
src/services/highlightsController.ts
Normal file
208
src/services/highlightsController.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { IEventStore } from 'applesauce-core'
|
||||||
|
import { Highlight } from '../types/highlights'
|
||||||
|
import { queryEvents } from './dataFetch'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { eventToHighlight, sortHighlights } from './highlightEventProcessor'
|
||||||
|
|
||||||
|
type HighlightsCallback = (highlights: Highlight[]) => void
|
||||||
|
type LoadingCallback = (loading: boolean) => void
|
||||||
|
|
||||||
|
const LAST_SYNCED_KEY = 'highlights_last_synced'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared highlights controller
|
||||||
|
* Manages the user's highlights centrally, similar to bookmarkController
|
||||||
|
*/
|
||||||
|
class HighlightsController {
|
||||||
|
private highlightsListeners: HighlightsCallback[] = []
|
||||||
|
private loadingListeners: LoadingCallback[] = []
|
||||||
|
|
||||||
|
private currentHighlights: Highlight[] = []
|
||||||
|
private lastLoadedPubkey: string | null = null
|
||||||
|
private generation = 0
|
||||||
|
|
||||||
|
onHighlights(cb: HighlightsCallback): () => void {
|
||||||
|
this.highlightsListeners.push(cb)
|
||||||
|
return () => {
|
||||||
|
this.highlightsListeners = this.highlightsListeners.filter(l => l !== cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoading(cb: LoadingCallback): () => void {
|
||||||
|
this.loadingListeners.push(cb)
|
||||||
|
return () => {
|
||||||
|
this.loadingListeners = this.loadingListeners.filter(l => l !== cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setLoading(loading: boolean): void {
|
||||||
|
this.loadingListeners.forEach(cb => cb(loading))
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitHighlights(highlights: Highlight[]): void {
|
||||||
|
this.highlightsListeners.forEach(cb => cb(highlights))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current highlights without triggering a reload
|
||||||
|
*/
|
||||||
|
getHighlights(): Highlight[] {
|
||||||
|
return [...this.currentHighlights]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if highlights are loaded for a specific pubkey
|
||||||
|
*/
|
||||||
|
isLoadedFor(pubkey: string): boolean {
|
||||||
|
return this.lastLoadedPubkey === pubkey && this.currentHighlights.length >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset state (for logout or manual refresh)
|
||||||
|
*/
|
||||||
|
reset(): void {
|
||||||
|
this.generation++
|
||||||
|
this.currentHighlights = []
|
||||||
|
this.lastLoadedPubkey = null
|
||||||
|
this.emitHighlights(this.currentHighlights)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get last synced timestamp for incremental loading
|
||||||
|
*/
|
||||||
|
private getLastSyncedAt(pubkey: string): number | null {
|
||||||
|
try {
|
||||||
|
const data = localStorage.getItem(LAST_SYNCED_KEY)
|
||||||
|
if (!data) return null
|
||||||
|
const parsed = JSON.parse(data)
|
||||||
|
return parsed[pubkey] || null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update last synced timestamp
|
||||||
|
*/
|
||||||
|
private setLastSyncedAt(pubkey: string, timestamp: number): void {
|
||||||
|
try {
|
||||||
|
const data = localStorage.getItem(LAST_SYNCED_KEY)
|
||||||
|
const parsed = data ? JSON.parse(data) : {}
|
||||||
|
parsed[pubkey] = timestamp
|
||||||
|
localStorage.setItem(LAST_SYNCED_KEY, JSON.stringify(parsed))
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[highlights] Failed to save last synced timestamp:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load highlights for a user
|
||||||
|
* Streams results and stores in event store
|
||||||
|
*/
|
||||||
|
async start(options: {
|
||||||
|
relayPool: RelayPool
|
||||||
|
eventStore: IEventStore
|
||||||
|
pubkey: string
|
||||||
|
force?: boolean
|
||||||
|
}): Promise<void> {
|
||||||
|
const { relayPool, eventStore, pubkey, force = false } = options
|
||||||
|
|
||||||
|
// Skip if already loaded for this pubkey (unless forced)
|
||||||
|
if (!force && this.isLoadedFor(pubkey)) {
|
||||||
|
console.log('[highlights] ✅ Already loaded for', pubkey.slice(0, 8))
|
||||||
|
this.emitHighlights(this.currentHighlights)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment generation to cancel any in-flight work
|
||||||
|
this.generation++
|
||||||
|
const currentGeneration = this.generation
|
||||||
|
|
||||||
|
this.setLoading(true)
|
||||||
|
console.log('[highlights] 🔍 Loading highlights for', pubkey.slice(0, 8))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const seenIds = new Set<string>()
|
||||||
|
const highlightsMap = new Map<string, Highlight>()
|
||||||
|
|
||||||
|
// Get last synced timestamp for incremental loading
|
||||||
|
const lastSyncedAt = force ? null : this.getLastSyncedAt(pubkey)
|
||||||
|
const filter: { kinds: number[]; authors: string[]; since?: number } = {
|
||||||
|
kinds: [KINDS.Highlights],
|
||||||
|
authors: [pubkey]
|
||||||
|
}
|
||||||
|
if (lastSyncedAt) {
|
||||||
|
filter.since = lastSyncedAt
|
||||||
|
console.log('[highlights] 📅 Incremental sync since', new Date(lastSyncedAt * 1000).toISOString())
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = await queryEvents(
|
||||||
|
relayPool,
|
||||||
|
filter,
|
||||||
|
{
|
||||||
|
onEvent: (evt) => {
|
||||||
|
// Check if this generation is still active
|
||||||
|
if (currentGeneration !== this.generation) return
|
||||||
|
|
||||||
|
if (seenIds.has(evt.id)) return
|
||||||
|
seenIds.add(evt.id)
|
||||||
|
|
||||||
|
// Store in event store immediately
|
||||||
|
eventStore.add(evt)
|
||||||
|
|
||||||
|
// Convert to highlight and add to map
|
||||||
|
const highlight = eventToHighlight(evt)
|
||||||
|
highlightsMap.set(highlight.id, highlight)
|
||||||
|
|
||||||
|
// Stream to listeners
|
||||||
|
const sortedHighlights = sortHighlights(Array.from(highlightsMap.values()))
|
||||||
|
this.currentHighlights = sortedHighlights
|
||||||
|
this.emitHighlights(sortedHighlights)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check if still active after async operation
|
||||||
|
if (currentGeneration !== this.generation) {
|
||||||
|
console.log('[highlights] ⚠️ Load cancelled (generation mismatch)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store all events in event store
|
||||||
|
events.forEach(evt => eventStore.add(evt))
|
||||||
|
|
||||||
|
// Final processing
|
||||||
|
const highlights = events.map(eventToHighlight)
|
||||||
|
const uniqueHighlights = Array.from(
|
||||||
|
new Map(highlights.map(h => [h.id, h])).values()
|
||||||
|
)
|
||||||
|
const sorted = sortHighlights(uniqueHighlights)
|
||||||
|
|
||||||
|
this.currentHighlights = sorted
|
||||||
|
this.lastLoadedPubkey = pubkey
|
||||||
|
this.emitHighlights(sorted)
|
||||||
|
|
||||||
|
// Update last synced timestamp
|
||||||
|
if (sorted.length > 0) {
|
||||||
|
const newestTimestamp = Math.max(...sorted.map(h => h.created_at))
|
||||||
|
this.setLastSyncedAt(pubkey, newestTimestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[highlights] ✅ Loaded', sorted.length, 'highlights')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[highlights] ❌ Failed to load highlights:', error)
|
||||||
|
this.currentHighlights = []
|
||||||
|
this.emitHighlights(this.currentHighlights)
|
||||||
|
} finally {
|
||||||
|
// Only clear loading if this generation is still active
|
||||||
|
if (currentGeneration === this.generation) {
|
||||||
|
this.setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
export const highlightsController = new HighlightsController()
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { Helpers } from 'applesauce-core'
|
import { Helpers, IEventStore } from 'applesauce-core'
|
||||||
import { BlogPostPreview } from './exploreService'
|
import { BlogPostPreview } from './exploreService'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor'
|
import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor'
|
||||||
@@ -13,15 +13,17 @@ const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary
|
|||||||
* @param relayPool - The relay pool to query
|
* @param relayPool - The relay pool to query
|
||||||
* @param relayUrls - Array of relay URLs to query
|
* @param relayUrls - Array of relay URLs to query
|
||||||
* @param limit - Maximum number of posts to fetch (default: 50)
|
* @param limit - Maximum number of posts to fetch (default: 50)
|
||||||
|
* @param eventStore - Optional event store to persist fetched events
|
||||||
* @returns Array of blog post previews
|
* @returns Array of blog post previews
|
||||||
*/
|
*/
|
||||||
export const fetchNostrverseBlogPosts = async (
|
export const fetchNostrverseBlogPosts = async (
|
||||||
relayPool: RelayPool,
|
relayPool: RelayPool,
|
||||||
relayUrls: string[],
|
relayUrls: string[],
|
||||||
limit = 50
|
limit = 50,
|
||||||
|
eventStore?: IEventStore
|
||||||
): Promise<BlogPostPreview[]> => {
|
): Promise<BlogPostPreview[]> => {
|
||||||
try {
|
try {
|
||||||
console.log('📚 Fetching nostrverse blog posts (kind 30023), limit:', limit)
|
console.log('[NOSTRVERSE] 📚 Fetching blog posts (kind 30023), limit:', limit)
|
||||||
|
|
||||||
// Deduplicate replaceable events by keeping the most recent version
|
// Deduplicate replaceable events by keeping the most recent version
|
||||||
const uniqueEvents = new Map<string, NostrEvent>()
|
const uniqueEvents = new Map<string, NostrEvent>()
|
||||||
@@ -32,6 +34,11 @@ export const fetchNostrverseBlogPosts = async (
|
|||||||
{
|
{
|
||||||
relayUrls,
|
relayUrls,
|
||||||
onEvent: (event: NostrEvent) => {
|
onEvent: (event: NostrEvent) => {
|
||||||
|
// Store in event store if provided
|
||||||
|
if (eventStore) {
|
||||||
|
eventStore.add(event)
|
||||||
|
}
|
||||||
|
|
||||||
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
|
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
const key = `${event.pubkey}:${dTag}`
|
const key = `${event.pubkey}:${dTag}`
|
||||||
const existing = uniqueEvents.get(key)
|
const existing = uniqueEvents.get(key)
|
||||||
@@ -42,7 +49,7 @@ export const fetchNostrverseBlogPosts = async (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
console.log('📊 Nostrverse blog post events fetched (unique):', uniqueEvents.size)
|
console.log('[NOSTRVERSE] 📊 Blog post events fetched (unique):', uniqueEvents.size)
|
||||||
|
|
||||||
// Convert to blog post previews and sort by published date (most recent first)
|
// Convert to blog post previews and sort by published date (most recent first)
|
||||||
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
|
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
|
||||||
@@ -60,7 +67,7 @@ export const fetchNostrverseBlogPosts = async (
|
|||||||
return timeB - timeA // Most recent first
|
return timeB - timeA // Most recent first
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('📰 Processed', blogPosts.length, 'unique nostrverse blog posts')
|
console.log('[NOSTRVERSE] 📰 Processed', blogPosts.length, 'unique blog posts')
|
||||||
|
|
||||||
return blogPosts
|
return blogPosts
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -73,25 +80,43 @@ export const fetchNostrverseBlogPosts = async (
|
|||||||
* Fetches public highlights (kind:9802) from the nostrverse (not filtered by author)
|
* Fetches public highlights (kind:9802) from the nostrverse (not filtered by author)
|
||||||
* @param relayPool - The relay pool to query
|
* @param relayPool - The relay pool to query
|
||||||
* @param limit - Maximum number of highlights to fetch (default: 100)
|
* @param limit - Maximum number of highlights to fetch (default: 100)
|
||||||
|
* @param eventStore - Optional event store to persist fetched events
|
||||||
* @returns Array of highlights
|
* @returns Array of highlights
|
||||||
*/
|
*/
|
||||||
export const fetchNostrverseHighlights = async (
|
export const fetchNostrverseHighlights = async (
|
||||||
relayPool: RelayPool,
|
relayPool: RelayPool,
|
||||||
limit = 100
|
limit = 100,
|
||||||
|
eventStore?: IEventStore
|
||||||
): Promise<Highlight[]> => {
|
): Promise<Highlight[]> => {
|
||||||
try {
|
try {
|
||||||
console.log('💡 Fetching nostrverse highlights (kind 9802), limit:', limit)
|
console.log('[NOSTRVERSE] 💡 Fetching highlights (kind 9802), limit:', limit)
|
||||||
|
|
||||||
|
const seenIds = new Set<string>()
|
||||||
const rawEvents = await queryEvents(
|
const rawEvents = await queryEvents(
|
||||||
relayPool,
|
relayPool,
|
||||||
{ kinds: [9802], limit },
|
{ kinds: [9802], limit },
|
||||||
{}
|
{
|
||||||
|
onEvent: (event: NostrEvent) => {
|
||||||
|
if (seenIds.has(event.id)) return
|
||||||
|
seenIds.add(event.id)
|
||||||
|
|
||||||
|
// Store in event store if provided
|
||||||
|
if (eventStore) {
|
||||||
|
eventStore.add(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Store all events in event store if provided (in case some were missed in streaming)
|
||||||
|
if (eventStore) {
|
||||||
|
rawEvents.forEach(evt => eventStore.add(evt))
|
||||||
|
}
|
||||||
|
|
||||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||||
const highlights = uniqueEvents.map(eventToHighlight)
|
const highlights = uniqueEvents.map(eventToHighlight)
|
||||||
|
|
||||||
console.log('💡 Processed', highlights.length, 'unique nostrverse highlights')
|
console.log('[NOSTRVERSE] 💡 Processed', highlights.length, 'unique highlights')
|
||||||
|
|
||||||
return sortHighlights(highlights)
|
return sortHighlights(highlights)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export interface UserSettings {
|
|||||||
defaultHighlightVisibilityNostrverse?: boolean
|
defaultHighlightVisibilityNostrverse?: boolean
|
||||||
defaultHighlightVisibilityFriends?: boolean
|
defaultHighlightVisibilityFriends?: boolean
|
||||||
defaultHighlightVisibilityMine?: boolean
|
defaultHighlightVisibilityMine?: boolean
|
||||||
|
// Default explore scope
|
||||||
|
defaultExploreScopeNostrverse?: boolean
|
||||||
|
defaultExploreScopeFriends?: boolean
|
||||||
|
defaultExploreScopeMine?: boolean
|
||||||
// Zap split weights (treated as relative weights, not strict percentages)
|
// Zap split weights (treated as relative weights, not strict percentages)
|
||||||
zapSplitHighlighterWeight?: number // default 50
|
zapSplitHighlighterWeight?: number // default 50
|
||||||
zapSplitBorisWeight?: number // default 2.1
|
zapSplitBorisWeight?: number // default 2.1
|
||||||
|
|||||||
261
src/styles/components/login.css
Normal file
261
src/styles/components/login.css
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
/* Login component styles */
|
||||||
|
.login-container {
|
||||||
|
max-width: 420px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-description {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-highlight {
|
||||||
|
background-color: var(--highlight-color-mine, #fde047);
|
||||||
|
color: var(--color-text);
|
||||||
|
padding: 0.125rem 0.25rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: none;
|
||||||
|
min-height: var(--min-touch-target);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button svg {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button-primary {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button-primary:hover:not(:disabled) {
|
||||||
|
background: var(--color-primary-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button-secondary {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button-secondary:hover:not(:disabled) {
|
||||||
|
background: var(--color-border);
|
||||||
|
border-color: var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bunker-input-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bunker-input {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bunker-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bunker-input:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bunker-input::placeholder {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bunker-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bunker-button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: none;
|
||||||
|
min-height: var(--min-touch-target);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bunker-connect {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bunker-connect:hover:not(:disabled) {
|
||||||
|
background: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bunker-connect:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bunker-cancel {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bunker-cancel:hover:not(:disabled) {
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bunker-cancel:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
background: rgba(251, 191, 36, 0.1);
|
||||||
|
border: 1px solid rgba(251, 191, 36, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error svg {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
color: rgb(251, 191, 36);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error a:hover {
|
||||||
|
color: var(--color-primary-hover);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-footer {
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-footer a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-footer a:hover {
|
||||||
|
color: var(--color-primary-hover);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsive styles */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.login-container {
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-description {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
padding: 0.875rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bunker-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bunker-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
.highlight-mode-toggle .mode-btn.active { background: var(--color-primary); color: rgb(255 255 255); /* white */ }
|
.highlight-mode-toggle .mode-btn.active { background: var(--color-primary); color: rgb(255 255 255); /* white */ }
|
||||||
|
|
||||||
/* Three-level highlight toggles */
|
/* 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 { display: flex; gap: 0.25rem; padding: 0.25rem; border-radius: 4px; }
|
||||||
|
|
||||||
.highlights-loading,
|
.highlights-loading,
|
||||||
.highlights-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem 1rem; color: var(--color-text-secondary); text-align: center; gap: 0.5rem; }
|
.highlights-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem 1rem; color: var(--color-text-secondary); text-align: center; gap: 0.5rem; }
|
||||||
|
|||||||
39
src/utils/async.ts
Normal file
39
src/utils/async.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Wrap a promise with a timeout
|
||||||
|
*/
|
||||||
|
export async function withTimeout<T>(promise: Promise<T>, timeoutMs = 30000): Promise<T> {
|
||||||
|
return Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise<T>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error(`Timeout after ${timeoutMs}ms`)), timeoutMs)
|
||||||
|
)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map items through an async function with limited concurrency
|
||||||
|
* @param items - Array of items to process
|
||||||
|
* @param limit - Maximum number of concurrent operations
|
||||||
|
* @param mapper - Async function to apply to each item
|
||||||
|
* @returns Array of results in the same order as input
|
||||||
|
*/
|
||||||
|
export async function mapWithConcurrency<T, R>(
|
||||||
|
items: T[],
|
||||||
|
limit: number,
|
||||||
|
mapper: (item: T, index: number) => Promise<R>
|
||||||
|
): Promise<R[]> {
|
||||||
|
const results: R[] = new Array(items.length)
|
||||||
|
let currentIndex = 0
|
||||||
|
|
||||||
|
const worker = async () => {
|
||||||
|
while (currentIndex < items.length) {
|
||||||
|
const index = currentIndex++
|
||||||
|
results[index] = await mapper(items[index], index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workers = new Array(Math.min(limit, items.length)).fill(0).map(() => worker())
|
||||||
|
await Promise.all(workers)
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
@@ -92,19 +92,30 @@ export const sortIndividualBookmarks = (items: IndividualBookmark[]) => {
|
|||||||
|
|
||||||
export function groupIndividualBookmarks(items: IndividualBookmark[]) {
|
export function groupIndividualBookmarks(items: IndividualBookmark[]) {
|
||||||
const sorted = sortIndividualBookmarks(items)
|
const sorted = sortIndividualBookmarks(items)
|
||||||
const web = sorted.filter(i => i.kind === 39701 || i.type === 'web')
|
|
||||||
// Only non-encrypted legacy bookmarks go to the amethyst section
|
// Group by source list, not by content type
|
||||||
const amethyst = sorted.filter(i => i.sourceKind === 30001 && !i.isPrivate)
|
const nip51Public = sorted.filter(i => i.sourceKind === 10003 && !i.isPrivate)
|
||||||
const isIn = (list: IndividualBookmark[], x: IndividualBookmark) => list.some(i => i.id === x.id)
|
const nip51Private = sorted.filter(i => i.sourceKind === 10003 && i.isPrivate)
|
||||||
// Private items include encrypted legacy bookmarks
|
// Amethyst bookmarks: kind:30001 (any d-tag or undefined)
|
||||||
const privateItems = sorted.filter(i => i.isPrivate && !isIn(web, i))
|
const amethystPublic = sorted.filter(i => i.sourceKind === 30001 && !i.isPrivate)
|
||||||
const publicItems = sorted.filter(i => !i.isPrivate && !isIn(amethyst, i) && !isIn(web, i))
|
const amethystPrivate = sorted.filter(i => i.sourceKind === 30001 && i.isPrivate)
|
||||||
return { privateItems, publicItems, web, amethyst }
|
const standaloneWeb = sorted.filter(i => i.sourceKind === 39701)
|
||||||
|
|
||||||
|
return {
|
||||||
|
nip51Public,
|
||||||
|
nip51Private,
|
||||||
|
amethystPublic,
|
||||||
|
amethystPrivate,
|
||||||
|
standaloneWeb
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple filter: only exclude bookmarks with empty/whitespace-only content
|
// Simple filter: show bookmarks that have content OR just an ID (placeholder)
|
||||||
export function hasContent(bookmark: IndividualBookmark): boolean {
|
export function hasContent(bookmark: IndividualBookmark): boolean {
|
||||||
return !!(bookmark.content && bookmark.content.trim().length > 0)
|
// Show if has content OR has an ID (placeholder until events are fetched)
|
||||||
|
const hasValidContent = !!(bookmark.content && bookmark.content.trim().length > 0)
|
||||||
|
const hasId = !!(bookmark.id && bookmark.id.trim().length > 0)
|
||||||
|
return hasValidContent || hasId
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bookmark sets helpers (kind 30003)
|
// Bookmark sets helpers (kind 30003)
|
||||||
|
|||||||
35
src/utils/dedupe.ts
Normal file
35
src/utils/dedupe.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Highlight } from '../types/highlights'
|
||||||
|
import { BlogPostPreview } from '../services/exploreService'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduplicate highlights by ID
|
||||||
|
*/
|
||||||
|
export function dedupeHighlightsById(highlights: Highlight[]): Highlight[] {
|
||||||
|
const byId = new Map<string, Highlight>()
|
||||||
|
for (const highlight of highlights) {
|
||||||
|
byId.set(highlight.id, highlight)
|
||||||
|
}
|
||||||
|
return Array.from(byId.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduplicate blog posts by replaceable event key (author:d-tag)
|
||||||
|
* Keeps the newest version when duplicates exist
|
||||||
|
*/
|
||||||
|
export function dedupeWritingsByReplaceable(posts: BlogPostPreview[]): BlogPostPreview[] {
|
||||||
|
const byKey = new Map<string, BlogPostPreview>()
|
||||||
|
|
||||||
|
for (const post of posts) {
|
||||||
|
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const key = `${post.author}:${dTag}`
|
||||||
|
const existing = byKey.get(key)
|
||||||
|
|
||||||
|
// Keep the newer version
|
||||||
|
if (!existing || post.event.created_at > existing.event.created_at) {
|
||||||
|
byKey.set(key, post)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(byKey.values())
|
||||||
|
}
|
||||||
|
|
||||||
@@ -123,7 +123,8 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
injectManifest: {
|
injectManifest: {
|
||||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,webp}'],
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,webp}'],
|
||||||
globIgnores: ['**/_headers', '**/_redirects', '**/robots.txt']
|
globIgnores: ['**/_headers', '**/_redirects', '**/robots.txt'],
|
||||||
|
maximumFileSizeToCacheInBytes: 3 * 1024 * 1024 // 3 MiB
|
||||||
},
|
},
|
||||||
devOptions: {
|
devOptions: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user