mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
92 Commits
bunker-enc
...
v0.7.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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).
|
||||
- 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**
|
||||
- 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.
|
||||
@@ -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).
|
||||
- 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
|
||||
|
||||
- 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.
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.6.24",
|
||||
"version": "0.7.0",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
|
||||
116
src/App.tsx
116
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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||
@@ -19,6 +19,8 @@ import { useOnlineStatus } from './hooks/useOnlineStatus'
|
||||
import { RELAYS } from './config/relays'
|
||||
import { SkeletonThemeProvider } from './components/Skeletons'
|
||||
import { DebugBus } from './utils/debugBus'
|
||||
import { Bookmark } from './types/bookmarks'
|
||||
import { bookmarkController } from './services/bookmarkController'
|
||||
|
||||
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
||||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
||||
@@ -32,9 +34,53 @@ function AppRoutes({
|
||||
showToast: (message: string) => void
|
||||
}) {
|
||||
const accountManager = Hooks.useAccountManager()
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
|
||||
// Centralized bookmark state (fed by controller)
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||
const [bookmarksLoading, setBookmarksLoading] = 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()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Auto-load bookmarks when account is ready (on login or page mount)
|
||||
useEffect(() => {
|
||||
if (activeAccount && relayPool && bookmarks.length === 0 && !bookmarksLoading) {
|
||||
console.log('[bookmark] 🚀 Auto-loading bookmarks on mount/login')
|
||||
bookmarkController.start({ relayPool, activeAccount, accountManager })
|
||||
}
|
||||
}, [activeAccount, relayPool, bookmarks.length, bookmarksLoading, 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 = () => {
|
||||
accountManager.clearActive()
|
||||
bookmarkController.reset() // Clear bookmarks via controller
|
||||
showToast('Logged out successfully')
|
||||
}
|
||||
|
||||
@@ -46,6 +92,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -55,6 +104,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -64,6 +116,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -73,6 +128,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -82,6 +140,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -91,6 +152,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -104,6 +168,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -113,6 +180,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -122,6 +192,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -131,6 +204,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -140,6 +216,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -149,6 +228,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -158,6 +240,9 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -167,10 +252,24 @@ function AppRoutes({
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/debug"
|
||||
element={
|
||||
<Debug
|
||||
relayPool={relayPool}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/debug" element={<Debug />} />
|
||||
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
|
||||
</Routes>
|
||||
)
|
||||
@@ -189,6 +288,10 @@ function App() {
|
||||
const store = new EventStore()
|
||||
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)
|
||||
registerCommonAccountTypes(accounts)
|
||||
|
||||
@@ -199,9 +302,13 @@ function App() {
|
||||
// wait for every relay send to finish. Responses still resolve the pending request.
|
||||
NostrConnectSigner.subscriptionMethod = pool.subscription.bind(pool)
|
||||
NostrConnectSigner.publishMethod = (relays: string[], event: unknown) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-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') {
|
||||
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 Promise.resolve()
|
||||
@@ -358,7 +465,8 @@ function App() {
|
||||
// Observable/Promise to upstream to avoid their awaiting of completion.
|
||||
const result = originalPublish(relays, event)
|
||||
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.
|
||||
// Return a benign object so callers that probe for a "subscribe" property
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
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 { RelayPool } from 'applesauce-relay'
|
||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||
@@ -65,8 +65,18 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
const friendsColor = settings?.highlightColorFriends || '#f97316'
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
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 toggleGroupingMode = () => {
|
||||
const newMode = groupingMode === 'grouped' ? 'flat' : 'grouped'
|
||||
setGroupingMode(newMode)
|
||||
localStorage.setItem('bookmarkGroupingMode', newMode)
|
||||
}
|
||||
|
||||
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
|
||||
if (!activeAccount || !relayPool) {
|
||||
throw new Error('Please login to create bookmarks')
|
||||
@@ -98,14 +108,18 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
const bookmarksWithoutSet = getBookmarksWithoutSet(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 sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
|
||||
{ key: 'private', title: 'Private Bookmarks', items: groups.privateItems },
|
||||
{ key: 'public', title: 'Public Bookmarks', items: groups.publicItems },
|
||||
{ key: 'web', title: 'Web Bookmarks', items: groups.web },
|
||||
{ key: 'amethyst', title: 'Legacy Bookmarks', items: groups.amethyst }
|
||||
]
|
||||
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
|
||||
groupingMode === 'flat'
|
||||
? [{ key: 'all', title: `All Bookmarks (${bookmarksWithoutSet.length})`, items: bookmarksWithoutSet }]
|
||||
: [
|
||||
{ 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
|
||||
bookmarkSets.forEach(set => {
|
||||
@@ -224,40 +238,49 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
style={{ color: friendsColor }}
|
||||
/>
|
||||
</div>
|
||||
<div className="view-mode-right">
|
||||
{onRefresh && (
|
||||
{activeAccount && (
|
||||
<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
|
||||
icon={faRotate}
|
||||
onClick={onRefresh}
|
||||
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'}
|
||||
ariaLabel="Refresh bookmarks"
|
||||
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"
|
||||
disabled={isRefreshing}
|
||||
spin={isRefreshing}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
icon={faList}
|
||||
onClick={() => onViewModeChange('compact')}
|
||||
title="Compact list view"
|
||||
ariaLabel="Compact list view"
|
||||
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faThLarge}
|
||||
onClick={() => onViewModeChange('cards')}
|
||||
title="Cards view"
|
||||
ariaLabel="Cards view"
|
||||
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faImage}
|
||||
onClick={() => onViewModeChange('large')}
|
||||
title="Large preview view"
|
||||
ariaLabel="Large preview view"
|
||||
variant={viewMode === 'large' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
icon={faList}
|
||||
onClick={() => onViewModeChange('compact')}
|
||||
title="Compact list view"
|
||||
ariaLabel="Compact list view"
|
||||
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faThLarge}
|
||||
onClick={() => onViewModeChange('cards')}
|
||||
title="Cards view"
|
||||
ariaLabel="Cards view"
|
||||
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faImage}
|
||||
onClick={() => onViewModeChange('large')}
|
||||
title="Large preview view"
|
||||
ariaLabel="Large preview view"
|
||||
variant={viewMode === 'large' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showAddModal && (
|
||||
<AddBookmarkModal
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useHighlightCreation } from '../hooks/useHighlightCreation'
|
||||
import { useBookmarksUI } from '../hooks/useBookmarksUI'
|
||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||
import { useOfflineSync } from '../hooks/useOfflineSync'
|
||||
import { Bookmark } from '../types/bookmarks'
|
||||
import ThreePaneLayout from './ThreePaneLayout'
|
||||
import Explore from './Explore'
|
||||
import Me from './Me'
|
||||
@@ -24,9 +25,18 @@ export type ViewMode = 'compact' | 'cards' | 'large'
|
||||
interface BookmarksProps {
|
||||
relayPool: RelayPool | null
|
||||
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 location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
@@ -152,8 +162,6 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
}, [navigationState, setIsHighlightsCollapsed, setSelectedHighlightId, navigate, location.pathname])
|
||||
|
||||
const {
|
||||
bookmarks,
|
||||
bookmarksLoading,
|
||||
highlights,
|
||||
setHighlights,
|
||||
highlightsLoading,
|
||||
@@ -166,12 +174,12 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
} = useBookmarksData({
|
||||
relayPool,
|
||||
activeAccount,
|
||||
accountManager,
|
||||
naddr,
|
||||
externalUrl,
|
||||
currentArticleCoordinate,
|
||||
currentArticleEventId,
|
||||
settings
|
||||
settings,
|
||||
onRefreshBookmarks
|
||||
})
|
||||
|
||||
const {
|
||||
@@ -317,10 +325,10 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
|
||||
) : undefined}
|
||||
me={showMe ? (
|
||||
relayPool ? <Me relayPool={relayPool} activeTab={meTab} /> : null
|
||||
relayPool ? <Me relayPool={relayPool} activeTab={meTab} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} /> : null
|
||||
) : undefined}
|
||||
profile={showProfile && profilePubkey ? (
|
||||
relayPool ? <Me relayPool={relayPool} activeTab={profileTab} pubkey={profilePubkey} /> : null
|
||||
relayPool ? <Me relayPool={relayPool} activeTab={profileTab} pubkey={profilePubkey} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} /> : null
|
||||
) : undefined}
|
||||
support={showSupport ? (
|
||||
relayPool ? <Support relayPool={relayPool} eventStore={eventStore} settings={settings} /> : null
|
||||
|
||||
@@ -1,18 +1,58 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faClock, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { useEventStore } from 'applesauce-react/hooks'
|
||||
import { Accounts } from 'applesauce-accounts'
|
||||
import { NostrConnectSigner } from 'applesauce-signers'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { getDefaultBunkerPermissions } from '../services/nostrConnect'
|
||||
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'
|
||||
|
||||
const defaultPayload = 'The quick brown fox jumps over the lazy dog.'
|
||||
|
||||
const Debug: React.FC = () => {
|
||||
interface DebugProps {
|
||||
relayPool: RelayPool | null
|
||||
bookmarks: Bookmark[]
|
||||
bookmarksLoading: boolean
|
||||
onRefreshBookmarks: () => Promise<void>
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
const Debug: React.FC<DebugProps> = ({
|
||||
relayPool,
|
||||
bookmarks,
|
||||
bookmarksLoading,
|
||||
onRefreshBookmarks,
|
||||
onLogout
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const accountManager = Hooks.useAccountManager()
|
||||
const eventStore = useEventStore()
|
||||
|
||||
const { settings, saveSettings } = useSettings({
|
||||
relayPool,
|
||||
eventStore,
|
||||
pubkey: activeAccount?.pubkey,
|
||||
accountManager
|
||||
})
|
||||
|
||||
const {
|
||||
isMobile,
|
||||
isCollapsed,
|
||||
setIsCollapsed,
|
||||
viewMode,
|
||||
setViewMode
|
||||
} = useBookmarksUI({ settings })
|
||||
const [payload, setPayload] = useState<string>(defaultPayload)
|
||||
const [cipher44, setCipher44] = useState<string>('')
|
||||
const [cipher04, setCipher04] = useState<string>('')
|
||||
@@ -30,10 +70,22 @@ const Debug: React.FC = () => {
|
||||
const [isBunkerLoading, setIsBunkerLoading] = useState<boolean>(false)
|
||||
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)
|
||||
|
||||
// Individual event decryption results
|
||||
const [decryptedEvents, setDecryptedEvents] = useState<Map<string, { public: number; private: number }>>(new Map())
|
||||
|
||||
// Live timing state
|
||||
const [liveTiming, setLiveTiming] = useState<{
|
||||
nip44?: { type: 'encrypt' | 'decrypt'; startTime: number }
|
||||
nip04?: { type: 'encrypt' | 'decrypt'; startTime: number }
|
||||
loadBookmarks?: { startTime: number }
|
||||
decryptBookmarks?: { startTime: number }
|
||||
}>({})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -55,6 +107,63 @@ const Debug: React.FC = () => {
|
||||
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 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') => {
|
||||
if (!signer || !pubkey) return
|
||||
try {
|
||||
@@ -123,6 +232,89 @@ const Debug: React.FC = () => {
|
||||
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())
|
||||
DebugBus.info('debug', 'Loading bookmark events...')
|
||||
|
||||
// Start timing
|
||||
const start = performance.now()
|
||||
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) => {
|
||||
// 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)
|
||||
setDecryptedEvents(new Map())
|
||||
DebugBus.info('debug', 'Cleared bookmark data')
|
||||
}
|
||||
|
||||
const handleBunkerLogin = async () => {
|
||||
if (!bunkerUri.trim()) {
|
||||
setBunkerError('Please enter a bunker URI')
|
||||
@@ -184,13 +376,23 @@ const Debug: React.FC = () => {
|
||||
return null
|
||||
}
|
||||
|
||||
const Stat = ({ label, value, mode, type }: {
|
||||
const getBookmarkLiveTiming = (operation: 'loadBookmarks' | 'decryptBookmarks') => {
|
||||
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;
|
||||
value?: string | number | null;
|
||||
mode?: 'nip44' | 'nip04';
|
||||
type?: 'encrypt' | 'decrypt';
|
||||
bookmarkOp?: 'loadBookmarks' | 'decryptBookmarks';
|
||||
}) => {
|
||||
const liveValue = mode && type ? getLiveTiming(mode, type) : null
|
||||
const liveValue = bookmarkOp ? getBookmarkLiveTiming(bookmarkOp) : (mode && type ? getLiveTiming(mode, type) : null)
|
||||
const isLive = !!liveValue
|
||||
|
||||
let displayValue: string
|
||||
@@ -214,7 +416,7 @@ const Debug: React.FC = () => {
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
const debugContent = (
|
||||
<div className="settings-view">
|
||||
<div className="settings-header">
|
||||
<h2>Debug</h2>
|
||||
@@ -225,9 +427,17 @@ const Debug: React.FC = () => {
|
||||
|
||||
<div className="settings-content">
|
||||
|
||||
{/* Bunker Login Section */}
|
||||
{/* Account Connection 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 ? (
|
||||
<div>
|
||||
<div className="text-sm opacity-70 mb-3">Connect to your bunker (Nostr Connect signer) to enable encryption/decryption testing</div>
|
||||
@@ -255,7 +465,13 @@ const Debug: React.FC = () => {
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
<button
|
||||
@@ -350,6 +566,87 @@ const Debug: React.FC = () => {
|
||||
</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="load" value={tLoadBookmarks} bookmarkOp="loadBookmarks" />
|
||||
<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>
|
||||
|
||||
{/* Debug Logs Section */}
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Debug Logs</h3>
|
||||
@@ -386,10 +683,61 @@ const Debug: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VersionFooter />
|
||||
</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
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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 { Accounts } from 'applesauce-accounts'
|
||||
import { NostrConnectSigner } from 'applesauce-signers'
|
||||
@@ -9,7 +11,7 @@ const LoginOptions: React.FC = () => {
|
||||
const [showBunkerInput, setShowBunkerInput] = useState(false)
|
||||
const [bunkerUri, setBunkerUri] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [error, setError] = useState<React.ReactNode | null>(null)
|
||||
|
||||
const handleExtensionLogin = async () => {
|
||||
try {
|
||||
@@ -20,7 +22,24 @@ const LoginOptions: React.FC = () => {
|
||||
accountManager.setActive(account)
|
||||
} catch (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 {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -33,7 +52,19 @@ const LoginOptions: React.FC = () => {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -66,7 +97,22 @@ const LoginOptions: React.FC = () => {
|
||||
if (errorMessage.toLowerCase().includes('permission') || errorMessage.toLowerCase().includes('unauthorized')) {
|
||||
setError('Your bunker connection is missing signing permissions. Reconnect and approve signing.')
|
||||
} 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 {
|
||||
setIsLoading(false)
|
||||
@@ -74,103 +120,87 @@ const LoginOptions: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<p style={{ marginBottom: '1rem' }}>Login with:</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', maxWidth: '300px', margin: '0 auto' }}>
|
||||
<button
|
||||
onClick={handleExtensionLogin}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
padding: '0.75rem 1.5rem',
|
||||
fontSize: '1rem',
|
||||
cursor: isLoading ? 'wait' : 'pointer',
|
||||
opacity: isLoading ? 0.6 : 1
|
||||
}}
|
||||
>
|
||||
{isLoading && !showBunkerInput ? 'Connecting...' : 'Extension'}
|
||||
</button>
|
||||
<div className="empty-state login-container">
|
||||
<div className="login-content">
|
||||
<h2 className="login-title">Hi! I'm Boris.</h2>
|
||||
<p className="login-description">
|
||||
Connect your npub to see your bookmarks, explore long-form articles, and create <mark className="login-highlight">your own highlights</mark>.
|
||||
</p>
|
||||
|
||||
{!showBunkerInput ? (
|
||||
<button
|
||||
onClick={() => setShowBunkerInput(true)}
|
||||
disabled={isLoading}
|
||||
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)}
|
||||
<div className="login-buttons">
|
||||
{!showBunkerInput && (
|
||||
<button
|
||||
onClick={handleExtensionLogin}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
padding: '0.75rem',
|
||||
fontSize: '0.9rem',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleBunkerLogin()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button
|
||||
onClick={handleBunkerLogin}
|
||||
disabled={isLoading || !bunkerUri.trim()}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
fontSize: '0.9rem',
|
||||
flex: 1,
|
||||
cursor: isLoading || !bunkerUri.trim() ? 'not-allowed' : 'pointer',
|
||||
opacity: isLoading || !bunkerUri.trim() ? 0.6 : 1
|
||||
}}
|
||||
>
|
||||
{isLoading && showBunkerInput ? 'Connecting...' : 'Connect'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowBunkerInput(false)
|
||||
setBunkerUri('')
|
||||
setError(null)
|
||||
}}
|
||||
className="login-button login-button-primary"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPuzzlePiece} />
|
||||
<span>{isLoading ? 'Connecting...' : 'Extension'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!showBunkerInput ? (
|
||||
<button
|
||||
onClick={() => setShowBunkerInput(true)}
|
||||
disabled={isLoading}
|
||||
className="login-button login-button-secondary"
|
||||
>
|
||||
<FontAwesomeIcon icon={faShieldHalved} />
|
||||
<span>Signer</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="bunker-input-container">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="bunker://..."
|
||||
value={bunkerUri}
|
||||
onChange={(e) => setBunkerUri(e.target.value)}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
fontSize: '0.9rem',
|
||||
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||
opacity: isLoading ? 0.6 : 1
|
||||
className="bunker-input"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleBunkerLogin()
|
||||
}
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
/>
|
||||
<div className="bunker-actions">
|
||||
<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>
|
||||
|
||||
{error && (
|
||||
<div className="login-error">
|
||||
<FontAwesomeIcon icon={faCircleInfo} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p style={{ color: 'var(--color-error, #ef4444)', marginTop: '1rem', fontSize: '0.9rem' }}>
|
||||
{error}
|
||||
|
||||
<p className="login-footer">
|
||||
New to nostr? Start here:{' '}
|
||||
<a href="https://nstart.me/" target="_blank" rel="noopener noreferrer">
|
||||
nstart.me
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<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,6 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
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 { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
@@ -9,7 +9,6 @@ import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { HighlightItem } from './HighlightItem'
|
||||
import { fetchHighlights } from '../services/highlightService'
|
||||
import { fetchBookmarks } from '../services/bookmarkService'
|
||||
import { fetchAllReads, ReadItem } from '../services/readsService'
|
||||
import { fetchLinks } from '../services/linksService'
|
||||
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
|
||||
@@ -37,6 +36,8 @@ interface MeProps {
|
||||
relayPool: RelayPool
|
||||
activeTab?: TabType
|
||||
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'
|
||||
@@ -44,7 +45,12 @@ type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
|
||||
// Valid reading progress filters
|
||||
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,
|
||||
activeTab: propActiveTab,
|
||||
pubkey: propPubkey,
|
||||
bookmarks
|
||||
}) => {
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const navigate = useNavigate()
|
||||
const { filter: urlFilter } = useParams<{ filter?: string }>()
|
||||
@@ -54,7 +60,6 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
const viewingPubkey = propPubkey || activeAccount?.pubkey
|
||||
const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey)
|
||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||
const [reads, setReads] = useState<ReadItem[]>([])
|
||||
const [, setReadsMap] = useState<Map<string, ReadItem>>(new Map())
|
||||
const [links, setLinks] = useState<ReadItem[]>([])
|
||||
@@ -65,6 +70,16 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||
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
|
||||
const initialFilter = urlFilter && VALID_FILTERS.includes(urlFilter as ReadingProgressFilterType)
|
||||
@@ -142,14 +157,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
|
||||
try {
|
||||
if (!hasBeenLoaded) setLoading(true)
|
||||
try {
|
||||
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
|
||||
setBookmarks(newBookmarks)
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('Failed to load bookmarks:', err)
|
||||
setBookmarks([])
|
||||
}
|
||||
// Bookmarks come from centralized loading in App.tsx
|
||||
setLoadedTabs(prev => new Set(prev).add('reading-list'))
|
||||
} catch (err) {
|
||||
console.error('Failed to load reading list:', err)
|
||||
@@ -166,22 +174,8 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
try {
|
||||
if (!hasBeenLoaded) setLoading(true)
|
||||
|
||||
// Ensure bookmarks are loaded
|
||||
let fetchedBookmarks: Bookmark[] = 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)
|
||||
// Derive reads from bookmarks immediately (bookmarks come from centralized loading in App.tsx)
|
||||
const initialReads = deriveReadsFromBookmarks(bookmarks)
|
||||
const initialMap = new Map(initialReads.map(item => [item.id, item]))
|
||||
setReadsMap(initialMap)
|
||||
setReads(initialReads)
|
||||
@@ -190,7 +184,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
|
||||
// Background enrichment: merge reading progress and mark-as-read
|
||||
// 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:', {
|
||||
id: item.id.slice(0, 20) + '...',
|
||||
progress: item.readingProgress,
|
||||
@@ -230,22 +224,8 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
try {
|
||||
if (!hasBeenLoaded) setLoading(true)
|
||||
|
||||
// Ensure bookmarks are loaded
|
||||
let fetchedBookmarks: Bookmark[] = 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)
|
||||
// Derive links from bookmarks immediately (bookmarks come from centralized loading in App.tsx)
|
||||
const initialLinks = deriveLinksFromBookmarks(bookmarks)
|
||||
const initialMap = new Map(initialLinks.map(item => [item.id, item]))
|
||||
setLinksMap(initialMap)
|
||||
setLinks(initialLinks)
|
||||
@@ -287,7 +267,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
const cached = getCachedMeData(viewingPubkey)
|
||||
if (cached) {
|
||||
setHighlights(cached.highlights)
|
||||
setBookmarks(cached.bookmarks)
|
||||
// Bookmarks come from App.tsx centralized state, no local caching needed
|
||||
setReads(cached.reads || [])
|
||||
setLinks(cached.links || [])
|
||||
}
|
||||
@@ -421,12 +401,16 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
// Apply reading progress filter
|
||||
const filteredReads = filterByReadingProgress(reads, readingProgressFilter)
|
||||
const filteredLinks = filterByReadingProgress(links, readingProgressFilter)
|
||||
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
|
||||
{ key: 'private', title: 'Private Bookmarks', items: groups.privateItems },
|
||||
{ key: 'public', title: 'Public Bookmarks', items: groups.publicItems },
|
||||
{ key: 'web', title: 'Web Bookmarks', items: groups.web },
|
||||
{ key: 'amethyst', title: 'Legacy Bookmarks', items: groups.amethyst }
|
||||
]
|
||||
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
|
||||
groupingMode === 'flat'
|
||||
? [{ key: 'all', title: `All Bookmarks (${filteredBookmarks.length})`, items: filteredBookmarks }]
|
||||
: [
|
||||
{ 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
|
||||
const hasData = highlights.length > 0 || bookmarks.length > 0 || reads.length > 0 || links.length > 0 || writings.length > 0
|
||||
@@ -514,6 +498,13 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
marginTop: '1rem',
|
||||
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
|
||||
icon={faList}
|
||||
onClick={() => setViewMode('compact')}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import React, { useState } from 'react'
|
||||
import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
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 { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
import { Accounts } from 'applesauce-accounts'
|
||||
import IconButton from './IconButton'
|
||||
|
||||
interface SidebarHeaderProps {
|
||||
@@ -16,26 +15,10 @@ interface SidebarHeaderProps {
|
||||
}
|
||||
|
||||
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, isMobile = false }) => {
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const accountManager = Hooks.useAccountManager()
|
||||
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 = () => {
|
||||
return profile?.picture || null
|
||||
}
|
||||
@@ -73,22 +56,20 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
</button>
|
||||
)}
|
||||
<div className="sidebar-header-right">
|
||||
<div
|
||||
className="profile-avatar"
|
||||
title={activeAccount ? getUserDisplayName() : "Login"}
|
||||
onClick={
|
||||
activeAccount
|
||||
? () => navigate('/me')
|
||||
: (isConnecting ? () => {} : handleLogin)
|
||||
}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{profileImage ? (
|
||||
<img src={profileImage} alt={getUserDisplayName()} />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faUserCircle} />
|
||||
)}
|
||||
</div>
|
||||
{activeAccount && (
|
||||
<div
|
||||
className="profile-avatar"
|
||||
title={getUserDisplayName()}
|
||||
onClick={() => navigate('/me')}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{profileImage ? (
|
||||
<img src={profileImage} alt={getUserDisplayName()} />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faUserCircle} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<IconButton
|
||||
icon={faHome}
|
||||
onClick={() => navigate('/')}
|
||||
@@ -110,7 +91,7 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
ariaLabel="Settings"
|
||||
variant="ghost"
|
||||
/>
|
||||
{activeAccount ? (
|
||||
{activeAccount && (
|
||||
<IconButton
|
||||
icon={faRightFromBracket}
|
||||
onClick={onLogout}
|
||||
@@ -118,14 +99,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
ariaLabel="Logout"
|
||||
variant="ghost"
|
||||
/>
|
||||
) : (
|
||||
<IconButton
|
||||
icon={faRightToBracket}
|
||||
onClick={isConnecting ? () => {} : handleLogin}
|
||||
title={isConnecting ? "Connecting..." : "Login"}
|
||||
ariaLabel="Login"
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IAccount, AccountManager } from 'applesauce-accounts'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { Bookmark } from '../types/bookmarks'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { fetchBookmarks } from '../services/bookmarkService'
|
||||
import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService'
|
||||
import { fetchContacts } from '../services/contactService'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
@@ -11,26 +10,26 @@ import { UserSettings } from '../services/settingsService'
|
||||
interface UseBookmarksDataParams {
|
||||
relayPool: RelayPool | null
|
||||
activeAccount: IAccount | undefined
|
||||
accountManager: AccountManager
|
||||
naddr?: string
|
||||
externalUrl?: string
|
||||
currentArticleCoordinate?: string
|
||||
currentArticleEventId?: string
|
||||
settings?: UserSettings
|
||||
bookmarks: Bookmark[] // Passed from App.tsx (centralized loading)
|
||||
bookmarksLoading: boolean // Passed from App.tsx (centralized loading)
|
||||
onRefreshBookmarks: () => Promise<void>
|
||||
}
|
||||
|
||||
export const useBookmarksData = ({
|
||||
relayPool,
|
||||
activeAccount,
|
||||
accountManager,
|
||||
naddr,
|
||||
externalUrl,
|
||||
currentArticleCoordinate,
|
||||
currentArticleEventId,
|
||||
settings
|
||||
}: UseBookmarksDataParams) => {
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||
const [bookmarksLoading, setBookmarksLoading] = useState(true)
|
||||
settings,
|
||||
onRefreshBookmarks
|
||||
}: Omit<UseBookmarksDataParams, 'bookmarks' | 'bookmarksLoading'>) => {
|
||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||
const [highlightsLoading, setHighlightsLoading] = useState(true)
|
||||
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
||||
@@ -43,21 +42,6 @@ export const useBookmarksData = ({
|
||||
setFollowedPubkeys(contacts)
|
||||
}, [relayPool, activeAccount])
|
||||
|
||||
const handleFetchBookmarks = useCallback(async () => {
|
||||
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])
|
||||
|
||||
const handleFetchHighlights = useCallback(async () => {
|
||||
if (!relayPool) return
|
||||
|
||||
@@ -96,7 +80,7 @@ export const useBookmarksData = ({
|
||||
|
||||
setIsRefreshing(true)
|
||||
try {
|
||||
await handleFetchBookmarks()
|
||||
await onRefreshBookmarks()
|
||||
await handleFetchHighlights()
|
||||
await handleFetchContacts()
|
||||
setLastFetchTime(Date.now())
|
||||
@@ -105,16 +89,9 @@ export const useBookmarksData = ({
|
||||
} finally {
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
}, [relayPool, activeAccount, isRefreshing, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
|
||||
}, [relayPool, activeAccount, isRefreshing, onRefreshBookmarks, handleFetchHighlights, handleFetchContacts])
|
||||
|
||||
// Load initial data (avoid clearing on route-only changes)
|
||||
useEffect(() => {
|
||||
if (!relayPool || !activeAccount) return
|
||||
// Only (re)fetch bookmarks when account or relayPool changes, not on naddr route changes
|
||||
handleFetchBookmarks()
|
||||
}, [relayPool, activeAccount, handleFetchBookmarks])
|
||||
|
||||
// Fetch highlights/contacts independently to avoid disturbing bookmarks
|
||||
// Fetch highlights/contacts independently
|
||||
useEffect(() => {
|
||||
if (!relayPool || !activeAccount) return
|
||||
// Only fetch general highlights when not viewing an article (naddr) or external URL
|
||||
@@ -126,8 +103,6 @@ export const useBookmarksData = ({
|
||||
}, [relayPool, activeAccount, naddr, externalUrl, handleFetchHighlights, handleFetchContacts])
|
||||
|
||||
return {
|
||||
bookmarks,
|
||||
bookmarksLoading,
|
||||
highlights,
|
||||
setHighlights,
|
||||
highlightsLoading,
|
||||
@@ -135,7 +110,6 @@ export const useBookmarksData = ({
|
||||
followedPubkeys,
|
||||
isRefreshing,
|
||||
lastFetchTime,
|
||||
handleFetchBookmarks,
|
||||
handleFetchHighlights,
|
||||
handleRefreshAll
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
@import './styles/components/me.css';
|
||||
@import './styles/components/pull-to-refresh.css';
|
||||
@import './styles/components/skeletons.css';
|
||||
@import './styles/components/login.css';
|
||||
@import './styles/utils/animations.css';
|
||||
@import './styles/utils/utilities.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]
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`Decrypt timeout after ${timeoutMs}ms`)), timeoutMs)
|
||||
)
|
||||
])
|
||||
async function decryptEvent(
|
||||
evt: NostrEvent,
|
||||
activeAccount: ActiveAccount,
|
||||
signerCandidate: unknown,
|
||||
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(
|
||||
@@ -35,21 +113,23 @@ export async function collectBookmarksFromEvents(
|
||||
allTags: string[][]
|
||||
}> {
|
||||
const publicItemsAll: IndividualBookmark[] = []
|
||||
const privateItemsAll: IndividualBookmark[] = []
|
||||
let newestCreatedAt = 0
|
||||
let latestContent = ''
|
||||
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) {
|
||||
newestCreatedAt = Math.max(newestCreatedAt, evt.created_at || 0)
|
||||
if (!latestContent && evt.content && !Helpers.hasHiddenContent(evt)) latestContent = evt.content
|
||||
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 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 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
|
||||
if (evt.kind === 39701) {
|
||||
@@ -85,72 +165,22 @@ export async function collectBookmarksFromEvents(
|
||||
}))
|
||||
)
|
||||
|
||||
try {
|
||||
if (Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt) && signerCandidate) {
|
||||
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))
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} else if (evt.content && evt.content.length > 0 && signerCandidate) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule decrypt if needed
|
||||
// Check for NIP-44 (Helpers.hasHiddenContent), NIP-04 (?iv= in content), or encrypted tags
|
||||
const hasNip04Content = evt.content && evt.content.includes('?iv=')
|
||||
const needsDecrypt = signerCandidate && (
|
||||
(Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt)) ||
|
||||
Helpers.hasHiddenContent(evt) ||
|
||||
hasNip04Content
|
||||
)
|
||||
|
||||
if (needsDecrypt) {
|
||||
decryptJobs.push({ evt, metadata })
|
||||
} else {
|
||||
// Check for already-unlocked hidden bookmarks
|
||||
const priv = Helpers.getHiddenBookmarks(evt)
|
||||
if (priv) {
|
||||
privateItemsAll.push(
|
||||
publicItemsAll.push(
|
||||
...processApplesauceBookmarks(priv, activeAccount, true).map(i => ({
|
||||
...i,
|
||||
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 { prioritizeLocalRelays } from '../utils/helpers'
|
||||
import { queryEvents } from './dataFetch'
|
||||
import { CONTACTS_REMOTE_TIMEOUT_MS } from '../config/network'
|
||||
|
||||
/**
|
||||
* Fetches the contact list (follows) for a specific user
|
||||
@@ -24,7 +23,6 @@ export const fetchContacts = async (
|
||||
{ kinds: [3], authors: [pubkey] },
|
||||
{
|
||||
relayUrls,
|
||||
remoteTimeoutMs: CONTACTS_REMOTE_TIMEOUT_MS,
|
||||
onEvent: (event: { created_at: number; tags: string[][] }) => {
|
||||
// Stream partials as we see any contact list
|
||||
for (const tag of event.tags) {
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
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 { Filter } from 'nostr-tools/filter'
|
||||
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
|
||||
import { LOCAL_TIMEOUT_MS, REMOTE_TIMEOUT_MS } from '../config/network'
|
||||
|
||||
export interface QueryOptions {
|
||||
relayUrls?: string[]
|
||||
localTimeoutMs?: number
|
||||
remoteTimeoutMs?: number
|
||||
onEvent?: (event: NostrEvent) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
relayPool: RelayPool,
|
||||
@@ -23,8 +21,6 @@ export async function queryEvents(
|
||||
): Promise<NostrEvent[]> {
|
||||
const {
|
||||
relayUrls,
|
||||
localTimeoutMs = LOCAL_TIMEOUT_MS,
|
||||
remoteTimeoutMs = REMOTE_TIMEOUT_MS,
|
||||
onEvent
|
||||
} = options
|
||||
|
||||
@@ -41,8 +37,7 @@ export async function queryEvents(
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(localTimeoutMs))
|
||||
completeOnEose()
|
||||
) as unknown as Observable<NostrEvent>
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
@@ -52,8 +47,7 @@ export async function queryEvents(
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(remoteTimeoutMs))
|
||||
completeOnEose()
|
||||
) as unknown as Observable<NostrEvent>
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
|
||||
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%;
|
||||
}
|
||||
}
|
||||
|
||||
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[]) {
|
||||
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
|
||||
const amethyst = sorted.filter(i => i.sourceKind === 30001 && !i.isPrivate)
|
||||
const isIn = (list: IndividualBookmark[], x: IndividualBookmark) => list.some(i => i.id === x.id)
|
||||
// Private items include encrypted legacy bookmarks
|
||||
const privateItems = sorted.filter(i => i.isPrivate && !isIn(web, i))
|
||||
const publicItems = sorted.filter(i => !i.isPrivate && !isIn(amethyst, i) && !isIn(web, i))
|
||||
return { privateItems, publicItems, web, amethyst }
|
||||
|
||||
// Group by source list, not by content type
|
||||
const nip51Public = sorted.filter(i => i.sourceKind === 10003 && !i.isPrivate)
|
||||
const nip51Private = sorted.filter(i => i.sourceKind === 10003 && i.isPrivate)
|
||||
// Amethyst bookmarks: kind:30001 (any d-tag or undefined)
|
||||
const amethystPublic = sorted.filter(i => i.sourceKind === 30001 && !i.isPrivate)
|
||||
const amethystPrivate = sorted.filter(i => i.sourceKind === 30001 && i.isPrivate)
|
||||
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 {
|
||||
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)
|
||||
|
||||
@@ -123,7 +123,8 @@ export default defineConfig({
|
||||
},
|
||||
injectManifest: {
|
||||
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: {
|
||||
enabled: true,
|
||||
|
||||
Reference in New Issue
Block a user