diff --git a/.gitignore b/.gitignore index 44af6a48..b99fd766 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ dist # Reference Projects applesauce primal-web-app +Amber diff --git a/Amber.md b/Amber.md new file mode 100644 index 00000000..a46b4799 --- /dev/null +++ b/Amber.md @@ -0,0 +1,155 @@ +## Boris ↔ Amber bunker: current findings + +- **Environment** + - Client: Boris (web) using `applesauce` stack (`NostrConnectSigner`, `RelayPool`). + - Bunker: Amber (mobile). + - We restored a `nostr-connect` account from localStorage and re-wired the signer to the app `RelayPool` before use. + +## What we changed client-side + +- **Signer wiring** + - Bound `NostrConnectSigner.subscriptionMethod/publishMethod` to the app `RelayPool` at startup. + - 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. + - Increased probe timeout from 3s → 10s; increased bookmark decrypt timeout from 15s → 30s. + +- **Logging** + - Added logs for publish/subscribe and parsed the NIP-46 request content length. + - Confirmed NIP‑46 request events are kind `24133` with a single `p` tag (expected). The method is inside the encrypted content, so it prints as `method: undefined` (expected). + +## Evidence from logs (client) + +``` +[bunker] ✅ Wired NostrConnectSigner to RelayPool publish/subscription +[bunker] 🔗 Signer relays merged with app RELAYS: (19) [...] +[bunker] subscribe via signer: { relays: [...], filters: [...] } +[bunker] ✅ Signer subscription opened +[bunker] publish via signer: { relays: [...], kind: 24133, tags: [['p', ]], contentLength: 260|304|54704 } +[bunker] 🔎 Probe nip44 roundtrip (encrypt→decrypt)… → probe timeout after 10000ms +[bunker] 🔎 Probe nip04 roundtrip (encrypt→decrypt)… → probe timeout after 10000ms +bookmarkProcessing.ts: ❌ nip44.decrypt failed: Decrypt timeout after 30000ms +bookmarkProcessing.ts: ❌ nip04.decrypt failed: Decrypt timeout after 30000ms +``` + +Notes: +- Final signer status shows `listening: true`, `isConnected: true`, and requests are published to 19 relays (includes Amber’s). + +## Evidence from Amber (device) + +- Activity screen shows multiple entries for: “Encrypt data using nip 4” and “Encrypt data using nip 44” with green checkmarks. +- No entries for “Decrypt data using nip 4” or “Decrypt data using nip 44”. + +## Interpretation + +- Transport and publish paths are working: Boris is publishing NIP‑46 requests (kind 24133) and Amber receives them (ENCRYPT activity visible). +- The persistent failure is specific to DECRYPT handling: Amber does not show any DECRYPT activity and Boris receives no decrypt responses within 10–30s windows. +- Client-side wiring is likely correct (subscription open, permissions requested, relays merged). The remaining issue appears provider-side in Amber’s NIP‑46 decrypt handling or permission gating. + +## Repro steps (quick) + +1) Revoke Boris in Amber. +2) Reconnect with a fresh bunker URI; approve signing and both encrypt/decrypt scopes for nip‑04 and nip‑44. +3) Keep Amber unlocked and foregrounded. +4) Reload Boris; observe: + - Logs showing `publish via signer` for kind 24133. + - In Amber, activity should include “Decrypt data using nip 4/44”. + +If DECRYPT entries still don’t appear: + +- This points to Amber’s NIP‑46 provider not executing/authorizing `nip04_decrypt`/`nip44_decrypt` methods, or not publishing responses. + +## Suggestions for Amber-side debugging + +- Verify permission gating allows `nip04_decrypt` and `nip44_decrypt` (not just encrypt). +- Confirm the provider recognizes NIP‑46 methods `nip04_decrypt` and `nip44_decrypt` in the decrypted payload and routes them to decrypt routines. +- 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. +- 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. + + diff --git a/src/App.tsx b/src/App.tsx index 6b08577f..dd1e2c6c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,13 +1,16 @@ -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' import { EventStoreProvider, AccountsProvider, Hooks } from 'applesauce-react' import { EventStore } from 'applesauce-core' -import { AccountManager } from 'applesauce-accounts' +import { AccountManager, Accounts } from 'applesauce-accounts' import { registerCommonAccountTypes } from 'applesauce-accounts/accounts' import { RelayPool } from 'applesauce-relay' +import { NostrConnectSigner } from 'applesauce-signers' +import { getDefaultBunkerPermissions } from './services/nostrConnect' import { createAddressLoader } from 'applesauce-loaders/loaders' +import Debug from './components/Debug' import Bookmarks from './components/Bookmarks' import RouteDebug from './components/RouteDebug' import Toast from './components/Toast' @@ -15,6 +18,9 @@ import { useToast } from './hooks/useToast' 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' @@ -28,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([]) + 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') } @@ -42,6 +92,9 @@ function AppRoutes({ } /> @@ -51,6 +104,9 @@ function AppRoutes({ } /> @@ -60,6 +116,9 @@ function AppRoutes({ } /> @@ -69,6 +128,9 @@ function AppRoutes({ } /> @@ -78,6 +140,9 @@ function AppRoutes({ } /> @@ -87,6 +152,9 @@ function AppRoutes({ } /> @@ -100,6 +168,9 @@ function AppRoutes({ } /> @@ -109,6 +180,9 @@ function AppRoutes({ } /> @@ -118,6 +192,9 @@ function AppRoutes({ } /> @@ -127,6 +204,9 @@ function AppRoutes({ } /> @@ -136,6 +216,9 @@ function AppRoutes({ } /> @@ -145,6 +228,9 @@ function AppRoutes({ } /> @@ -154,6 +240,9 @@ function AppRoutes({ } /> @@ -163,6 +252,21 @@ function AppRoutes({ + } + /> + } /> @@ -184,23 +288,68 @@ 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) + // Create relay pool and set it up BEFORE loading accounts + // NostrConnectAccount.fromJSON needs this to restore the signer + const pool = new RelayPool() + // Wire the signer to use this pool; make publish non-blocking so callers don't + // 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') { + // 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() + } + console.log('[bunker] ✅ Wired NostrConnectSigner to RelayPool publish/subscription (before account load)') + + // Create a relay group for better event deduplication and management + pool.group(RELAYS) + console.log('[bunker] Created relay group with', RELAYS.length, 'relays (including local)') + // Load persisted accounts from localStorage try { - const json = JSON.parse(localStorage.getItem('accounts') || '[]') + const accountsJson = localStorage.getItem('accounts') + console.log('[bunker] Raw accounts from localStorage:', accountsJson) + + const json = JSON.parse(accountsJson || '[]') + console.log('[bunker] Parsed accounts:', json.length, 'accounts') + await accounts.fromJSON(json) - console.log('Loaded', accounts.accounts.length, 'accounts from storage') + console.log('[bunker] Loaded', accounts.accounts.length, 'accounts from storage') + console.log('[bunker] Account types:', accounts.accounts.map(a => ({ id: a.id, type: a.type }))) // Load active account from storage const activeId = localStorage.getItem('active') - if (activeId && accounts.getAccount(activeId)) { - accounts.setActive(activeId) - console.log('Restored active account:', activeId) + console.log('[bunker] Active ID from localStorage:', activeId) + + if (activeId) { + const account = accounts.getAccount(activeId) + console.log('[bunker] Found account for ID?', !!account, account?.type) + + if (account) { + accounts.setActive(activeId) + console.log('[bunker] ✅ Restored active account:', activeId, 'type:', account.type) + } else { + console.warn('[bunker] ⚠️ Active ID found but account not in list') + } + } else { + console.log('[bunker] No active account ID in localStorage') } } catch (err) { - console.error('Failed to load accounts from storage:', err) + console.error('[bunker] ❌ Failed to load accounts from storage:', err) } // Subscribe to accounts changes and persist to localStorage @@ -217,12 +366,198 @@ function App() { } }) - const pool = new RelayPool() + // Reconnect bunker signers when active account changes + // Keep track of which accounts we've already reconnected to avoid double-connecting + const reconnectedAccounts = new Set() - // Create a relay group for better event deduplication and management - pool.group(RELAYS) - console.log('Created relay group with', RELAYS.length, 'relays (including local)') - console.log('Relay URLs:', RELAYS) + const bunkerReconnectSub = accounts.active$.subscribe(async (account) => { + console.log('[bunker] Active account changed:', { + hasAccount: !!account, + type: account?.type, + id: account?.id + }) + + if (account && account.type === 'nostr-connect') { + const nostrConnectAccount = account as Accounts.NostrConnectAccount + // Disable applesauce account queueing so decrypt requests aren't serialized behind earlier ops + try { + if (!(nostrConnectAccount as unknown as { disableQueue?: boolean }).disableQueue) { + (nostrConnectAccount as unknown as { disableQueue?: boolean }).disableQueue = true + console.log('[bunker] ⚙️ Disabled account request queueing for nostr-connect') + } + } catch (err) { console.warn('[bunker] failed to disable queue', err) } + // Note: for Amber bunker, the remote signer pubkey is the user's pubkey. This is expected. + + // Skip if we've already reconnected this account + if (reconnectedAccounts.has(account.id)) { + console.log('[bunker] ⏭️ Already reconnected this account, skipping') + return + } + + console.log('[bunker] Account detected. Status:', { + listening: nostrConnectAccount.signer.listening, + isConnected: nostrConnectAccount.signer.isConnected, + hasRemote: !!nostrConnectAccount.signer.remote, + bunkerRelays: nostrConnectAccount.signer.relays + }) + + try { + // For restored signers, ensure they have the pool's subscription methods + // The signer was created in fromJSON without pool context, so we need to recreate it + const signerData = nostrConnectAccount.toJSON().signer + + // Add bunker's relays to the pool BEFORE recreating the signer + // This ensures the pool has all relays when the signer sets up its methods + const bunkerRelays = signerData.relays || [] + const existingRelayUrls = new Set(Array.from(pool.relays.keys())) + const newBunkerRelays = bunkerRelays.filter(url => !existingRelayUrls.has(url)) + + if (newBunkerRelays.length > 0) { + console.log('[bunker] Adding bunker relays to pool BEFORE signer recreation:', newBunkerRelays) + pool.group(newBunkerRelays) + } else { + console.log('[bunker] Bunker relays already in pool') + } + + const recreatedSigner = new NostrConnectSigner({ + relays: signerData.relays, + pubkey: nostrConnectAccount.pubkey, + remote: signerData.remote, + signer: nostrConnectAccount.signer.signer, // Use the existing SimpleSigner + pool: pool + }) + // Ensure local relays are included for NIP-46 request/response traffic (e.g., Amber bunker) + try { + const mergedRelays = Array.from(new Set([...(signerData.relays || []), ...RELAYS])) + recreatedSigner.relays = mergedRelays + console.log('[bunker] 🔗 Signer relays merged with app RELAYS:', mergedRelays) + } catch (err) { console.warn('[bunker] failed to merge signer relays', err) } + + // Replace the signer on the account + nostrConnectAccount.signer = recreatedSigner + console.log('[bunker] ✅ Signer recreated with pool context') + + // Debug: log publish/subscription calls made by signer (decrypt/sign requests) + // IMPORTANT: bind originals to preserve `this` context used internally by the signer + const originalPublish = (recreatedSigner as unknown as { publishMethod: (relays: string[], event: unknown) => unknown }).publishMethod.bind(recreatedSigner) + ;(recreatedSigner as unknown as { publishMethod: (relays: string[], event: unknown) => unknown }).publishMethod = (relays: string[], event: unknown) => { + try { + let method: string | undefined + const content = (event as { content?: unknown })?.content + if (typeof content === 'string') { + try { + const parsed = JSON.parse(content) as { method?: string; id?: unknown } + method = parsed?.method + } catch (err) { console.warn('[bunker] failed to parse event content', err) } + } + const summary = { + relays, + kind: (event as { kind?: number })?.kind, + method, + // include tags array for debugging (NIP-46 expects method tag) + tags: (event as { tags?: unknown })?.tags, + contentLength: typeof content === 'string' ? content.length : undefined + } + console.log('[bunker] publish via signer:', summary) + try { DebugBus.info('bunker', 'publish', summary) } catch (err) { console.warn('[bunker] failed to log to DebugBus', err) } + } catch (err) { console.warn('[bunker] failed to log publish summary', err) } + // Fire-and-forget publish: trigger the publish but do not return the + // Observable/Promise to upstream to avoid their awaiting of completion. + const result = originalPublish(relays, event) + if (result && typeof (result as { subscribe?: unknown }).subscribe === 'function') { + // 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 + // (e.g., applesauce makeRequest) won't throw on `"subscribe" in result`. + return {} as unknown as never + } + const originalSubscribe = (recreatedSigner as unknown as { subscriptionMethod: (relays: string[], filters: unknown[]) => unknown }).subscriptionMethod.bind(recreatedSigner) + ;(recreatedSigner as unknown as { subscriptionMethod: (relays: string[], filters: unknown[]) => unknown }).subscriptionMethod = (relays: string[], filters: unknown[]) => { + try { + console.log('[bunker] subscribe via signer:', { relays, filters }) + try { DebugBus.info('bunker', 'subscribe', { relays, filters }) } catch (err) { console.warn('[bunker] failed to log subscribe to DebugBus', err) } + } catch (err) { console.warn('[bunker] failed to log subscribe summary', err) } + return originalSubscribe(relays, filters) + } + + + // Just ensure the signer is listening for responses - don't call connect() again + // The fromBunkerURI already connected with permissions during login + if (!nostrConnectAccount.signer.listening) { + console.log('[bunker] Opening signer subscription...') + await nostrConnectAccount.signer.open() + console.log('[bunker] ✅ Signer subscription opened') + } else { + console.log('[bunker] ✅ Signer already listening') + } + + // Attempt a guarded reconnect to ensure Amber authorizes decrypt operations + try { + if (nostrConnectAccount.signer.remote && !reconnectedAccounts.has(account.id)) { + const permissions = getDefaultBunkerPermissions() + console.log('[bunker] Attempting guarded connect() with permissions to ensure decrypt perms', { count: permissions.length }) + await nostrConnectAccount.signer.connect(undefined, permissions) + console.log('[bunker] ✅ Guarded connect() succeeded with permissions') + } + } catch (e) { + console.warn('[bunker] ⚠️ Guarded connect() failed:', e) + } + + // Give the subscription a moment to fully establish before allowing decrypt operations + // This ensures the signer is ready to handle and receive responses + await new Promise(resolve => setTimeout(resolve, 100)) + console.log("[bunker] Subscription ready after startup delay") + // Fire-and-forget: probe decrypt path to verify Amber responds to NIP-46 decrypt + try { + const withTimeout = async (p: Promise, ms = 10000): Promise => { + return await Promise.race([ + p, + new Promise((_, rej) => setTimeout(() => rej(new Error(`probe timeout after ${ms}ms`)), ms)), + ]) + } + setTimeout(async () => { + const self = nostrConnectAccount.pubkey + // Try a roundtrip so the bunker can respond successfully + try { + console.log('[bunker] 🔎 Probe nip44 roundtrip (encrypt→decrypt)…') + const cipher44 = await withTimeout(nostrConnectAccount.signer.nip44!.encrypt(self, 'probe-nip44')) + const plain44 = await withTimeout(nostrConnectAccount.signer.nip44!.decrypt(self, cipher44)) + console.log('[bunker] 🔎 Probe nip44 responded:', typeof plain44 === 'string' ? plain44 : typeof plain44) + } catch (err) { + console.log('[bunker] 🔎 Probe nip44 result:', err instanceof Error ? err.message : err) + } + try { + console.log('[bunker] 🔎 Probe nip04 roundtrip (encrypt→decrypt)…') + const cipher04 = await withTimeout(nostrConnectAccount.signer.nip04!.encrypt(self, 'probe-nip04')) + const plain04 = await withTimeout(nostrConnectAccount.signer.nip04!.decrypt(self, cipher04)) + console.log('[bunker] 🔎 Probe nip04 responded:', typeof plain04 === 'string' ? plain04 : typeof plain04) + } catch (err) { + console.log('[bunker] 🔎 Probe nip04 result:', err instanceof Error ? err.message : err) + } + }, 0) + } catch (err) { + console.log('[bunker] 🔎 Probe setup failed:', err) + } + // The bunker remembers the permissions from the initial connection + nostrConnectAccount.signer.isConnected = true + + console.log('[bunker] Final signer status:', { + listening: nostrConnectAccount.signer.listening, + isConnected: nostrConnectAccount.signer.isConnected, + remote: nostrConnectAccount.signer.remote, + relays: nostrConnectAccount.signer.relays + }) + + // Mark this account as reconnected + reconnectedAccounts.add(account.id) + console.log('[bunker] 🎉 Signer ready for signing') + } catch (error) { + console.error('[bunker] ❌ Failed to open signer:', error) + } + } + }) // Keep all relay connections alive indefinitely by creating a persistent subscription // This prevents disconnection when no other subscriptions are active @@ -252,6 +587,7 @@ function App() { return () => { accountsSub.unsubscribe() activeSub.unsubscribe() + bunkerReconnectSub.unsubscribe() // Clean up keep-alive subscription if it exists const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } } if (poolWithSub._keepAliveSubscription) { @@ -268,7 +604,7 @@ function App() { return () => { if (cleanup) cleanup() } - }, []) + }, [isOnline, showToast]) // Monitor online/offline status useEffect(() => { diff --git a/src/components/BookmarkList.tsx b/src/components/BookmarkList.tsx index 74e6adc5..cd5c453e 100644 --- a/src/components/BookmarkList.tsx +++ b/src/components/BookmarkList.tsx @@ -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' @@ -21,6 +21,7 @@ import { RELAYS } from '../config/relays' import { Hooks } from 'applesauce-react' import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters' import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier' +import LoginOptions from './LoginOptions' interface BookmarkListProps { bookmarks: Bookmark[] @@ -64,8 +65,18 @@ export const BookmarkList: React.FC = ({ const friendsColor = settings?.highlightColorFriends || '#f97316' const [showAddModal, setShowAddModal] = useState(false) const [selectedFilter, setSelectedFilter] = useState('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') @@ -97,14 +108,18 @@ export const BookmarkList: React.FC = ({ 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 => { @@ -153,7 +168,9 @@ export const BookmarkList: React.FC = ({ /> )} - {filteredBookmarks.length === 0 && allIndividualBookmarks.length > 0 ? ( + {!activeAccount ? ( + + ) : filteredBookmarks.length === 0 && allIndividualBookmarks.length > 0 ? (

No bookmarks match this filter.

@@ -170,7 +187,6 @@ export const BookmarkList: React.FC = ({

No bookmarks found.

Add bookmarks using your nostr client to see them here.

-

If you aren't on nostr yet, start here: nstart.me

) ) : ( @@ -222,40 +238,49 @@ export const BookmarkList: React.FC = ({ style={{ color: friendsColor }} /> -
- {onRefresh && ( + {activeAccount && ( +
+ {onRefresh && ( + + )} - )} - onViewModeChange('compact')} - title="Compact list view" - ariaLabel="Compact list view" - variant={viewMode === 'compact' ? 'primary' : 'ghost'} - /> - onViewModeChange('cards')} - title="Cards view" - ariaLabel="Cards view" - variant={viewMode === 'cards' ? 'primary' : 'ghost'} - /> - onViewModeChange('large')} - title="Large preview view" - ariaLabel="Large preview view" - variant={viewMode === 'large' ? 'primary' : 'ghost'} - /> -
+ onViewModeChange('compact')} + title="Compact list view" + ariaLabel="Compact list view" + variant={viewMode === 'compact' ? 'primary' : 'ghost'} + /> + onViewModeChange('cards')} + title="Cards view" + ariaLabel="Cards view" + variant={viewMode === 'cards' ? 'primary' : 'ghost'} + /> + onViewModeChange('large')} + title="Large preview view" + ariaLabel="Large preview view" + variant={viewMode === 'large' ? 'primary' : 'ghost'} + /> +
+ )} {showAddModal && ( void + bookmarks: Bookmark[] + bookmarksLoading: boolean + onRefreshBookmarks: () => Promise } -const Bookmarks: React.FC = ({ relayPool, onLogout }) => { +const Bookmarks: React.FC = ({ + 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 = ({ relayPool, onLogout }) => { }, [navigationState, setIsHighlightsCollapsed, setSelectedHighlightId, navigate, location.pathname]) const { - bookmarks, - bookmarksLoading, highlights, setHighlights, highlightsLoading, @@ -166,12 +174,12 @@ const Bookmarks: React.FC = ({ relayPool, onLogout }) => { } = useBookmarksData({ relayPool, activeAccount, - accountManager, naddr, externalUrl, currentArticleCoordinate, currentArticleEventId, - settings + settings, + onRefreshBookmarks }) const { @@ -317,10 +325,10 @@ const Bookmarks: React.FC = ({ relayPool, onLogout }) => { relayPool ? : null ) : undefined} me={showMe ? ( - relayPool ? : null + relayPool ? : null ) : undefined} profile={showProfile && profilePubkey ? ( - relayPool ? : null + relayPool ? : null ) : undefined} support={showSupport ? ( relayPool ? : null diff --git a/src/components/Debug.tsx b/src/components/Debug.tsx new file mode 100644 index 00000000..e85bdbe3 --- /dev/null +++ b/src/components/Debug.tsx @@ -0,0 +1,743 @@ +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 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.' + +interface DebugProps { + relayPool: RelayPool | null + bookmarks: Bookmark[] + bookmarksLoading: boolean + onRefreshBookmarks: () => Promise + onLogout: () => void +} + +const Debug: React.FC = ({ + 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(defaultPayload) + const [cipher44, setCipher44] = useState('') + const [cipher04, setCipher04] = useState('') + const [plain44, setPlain44] = useState('') + const [plain04, setPlain04] = useState('') + const [tEncrypt44, setTEncrypt44] = useState(null) + const [tEncrypt04, setTEncrypt04] = useState(null) + const [tDecrypt44, setTDecrypt44] = useState(null) + const [tDecrypt04, setTDecrypt04] = useState(null) + const [logs, setLogs] = useState(DebugBus.snapshot()) + const [debugEnabled, setDebugEnabled] = useState(() => localStorage.getItem('debug') === '*') + + // Bunker login state + const [bunkerUri, setBunkerUri] = useState('') + const [isBunkerLoading, setIsBunkerLoading] = useState(false) + const [bunkerError, setBunkerError] = useState(null) + + // Bookmark loading state + const [bookmarkEvents, setBookmarkEvents] = useState([]) + const [isLoadingBookmarks, setIsLoadingBookmarks] = useState(false) + const [bookmarkStats, setBookmarkStats] = useState<{ public: number; private: number } | null>(null) + const [tLoadBookmarks, setTLoadBookmarks] = useState(null) + const [tDecryptBookmarks, setTDecryptBookmarks] = useState(null) + + // Individual event decryption results + const [decryptedEvents, setDecryptedEvents] = useState>(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(() => { + return DebugBus.subscribe((e) => setLogs(prev => [...prev, e].slice(-300))) + }, []) + + // Live timer effect - triggers re-renders for live timing updates + useEffect(() => { + const interval = setInterval(() => { + // Force re-render to update live timing display + setLiveTiming(prev => prev) + }, 16) // ~60fps for smooth updates + return () => clearInterval(interval) + }, []) + + const signer = useMemo(() => (activeAccount as unknown as { signer?: unknown })?.signer, [activeAccount]) + const pubkey = (activeAccount as unknown as { pubkey?: string })?.pubkey + + 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 { + const api = (signer as { [key: string]: { encrypt: (pubkey: string, message: string) => Promise } })[mode] + DebugBus.info('debug', `encrypt start ${mode}`, { pubkey, len: payload.length }) + + // Start live timing + const start = performance.now() + setLiveTiming(prev => ({ ...prev, [mode]: { type: 'encrypt', startTime: start } })) + + const cipher = await api.encrypt(pubkey, payload) + const ms = Math.round(performance.now() - start) + + // Stop live timing + setLiveTiming(prev => ({ ...prev, [mode]: undefined })) + + DebugBus.info('debug', `encrypt done ${mode}`, { len: typeof cipher === 'string' ? cipher.length : -1, ms }) + if (mode === 'nip44') setCipher44(cipher) + else setCipher04(cipher) + if (mode === 'nip44') setTEncrypt44(ms) + else setTEncrypt04(ms) + } catch (e) { + // Stop live timing on error + setLiveTiming(prev => ({ ...prev, [mode]: undefined })) + DebugBus.error('debug', `encrypt error ${mode}`, e instanceof Error ? e.message : String(e)) + } + } + + const doDecrypt = async (mode: 'nip44' | 'nip04') => { + if (!signer || !pubkey) return + try { + const api = (signer as { [key: string]: { decrypt: (pubkey: string, ciphertext: string) => Promise } })[mode] + const cipher = mode === 'nip44' ? cipher44 : cipher04 + if (!cipher) { + DebugBus.warn('debug', `no cipher to decrypt for ${mode}`) + return + } + DebugBus.info('debug', `decrypt start ${mode}`, { len: cipher.length }) + + // Start live timing + const start = performance.now() + setLiveTiming(prev => ({ ...prev, [mode]: { type: 'decrypt', startTime: start } })) + + const plain = await api.decrypt(pubkey, cipher) + const ms = Math.round(performance.now() - start) + + // Stop live timing + setLiveTiming(prev => ({ ...prev, [mode]: undefined })) + + DebugBus.info('debug', `decrypt done ${mode}`, { len: typeof plain === 'string' ? plain.length : -1, ms }) + if (mode === 'nip44') setPlain44(String(plain)) + else setPlain04(String(plain)) + if (mode === 'nip44') setTDecrypt44(ms) + else setTDecrypt04(ms) + } catch (e) { + // Stop live timing on error + setLiveTiming(prev => ({ ...prev, [mode]: undefined })) + DebugBus.error('debug', `decrypt error ${mode}`, e instanceof Error ? e.message : String(e)) + } + } + + const toggleDebug = () => { + const next = !debugEnabled + setDebugEnabled(next) + if (next) localStorage.setItem('debug', '*') + else localStorage.removeItem('debug') + } + + const handleLoadBookmarks = async () => { + if (!relayPool || !activeAccount) { + DebugBus.warn('debug', 'Cannot load bookmarks: missing relayPool or activeAccount') + return + } + + try { + setIsLoadingBookmarks(true) + setBookmarkStats(null) + setBookmarkEvents([]) // Clear existing events + setDecryptedEvents(new Map()) + 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') + return + } + + if (!bunkerUri.startsWith('bunker://')) { + setBunkerError('Invalid bunker URI. Must start with bunker://') + return + } + + try { + setIsBunkerLoading(true) + setBunkerError(null) + + // Create signer from bunker URI with default permissions + const permissions = getDefaultBunkerPermissions() + const signer = await NostrConnectSigner.fromBunkerURI(bunkerUri, { permissions }) + + // Get pubkey from signer + const pubkey = await signer.getPublicKey() + + // Create account from signer + const account = new Accounts.NostrConnectAccount(pubkey, signer) + + // Add to account manager and set active + accountManager.addAccount(account) + accountManager.setActive(account) + + // Clear input on success + setBunkerUri('') + } catch (err) { + console.error('[bunker] Login failed:', err) + const errorMessage = err instanceof Error ? err.message : 'Failed to connect to bunker' + + // Check for permission-related errors + if (errorMessage.toLowerCase().includes('permission') || errorMessage.toLowerCase().includes('unauthorized')) { + setBunkerError('Your bunker connection is missing signing permissions. Reconnect and approve signing.') + } else { + setBunkerError(errorMessage) + } + } finally { + setIsBunkerLoading(false) + } + } + + const CodeBox = ({ value }: { value: string }) => ( +
+ {value || '—'} +
+ ) + + const getLiveTiming = (mode: 'nip44' | 'nip04', type: 'encrypt' | 'decrypt') => { + const timing = liveTiming[mode] + if (timing && timing.type === type) { + const elapsed = Math.round(performance.now() - timing.startTime) + return elapsed + } + return null + } + + 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 = bookmarkOp ? getBookmarkLiveTiming(bookmarkOp) : (mode && type ? getLiveTiming(mode, type) : null) + const isLive = !!liveValue + + let displayValue: string + if (isLive) { + displayValue = '' + } else if (value !== null && value !== undefined) { + displayValue = `${value}ms` + } else { + displayValue = '—' + } + + return ( + + + {label}: {isLive ? ( + + ) : ( + displayValue + )} + + ) + } + + const debugContent = ( +
+
+

Debug

+
+ Active pubkey: {pubkey || 'none'} +
+
+ +
+ + {/* Account Connection Section */} +
+

+ {activeAccount + ? activeAccount.type === 'extension' + ? 'Browser Extension' + : activeAccount.type === 'nostr-connect' + ? 'Bunker Connection' + : 'Account Connection' + : 'Account Connection'} +

+ {!activeAccount ? ( +
+
Connect to your bunker (Nostr Connect signer) to enable encryption/decryption testing
+
+ setBunkerUri(e.target.value)} + disabled={isBunkerLoading} + /> + +
+ {bunkerError && ( +
{bunkerError}
+ )} +
+ ) : ( +
+
+
+ {activeAccount.type === 'extension' + ? 'Connected via browser extension' + : activeAccount.type === 'nostr-connect' + ? 'Connected to bunker' + : 'Connected'} +
+
{pubkey}
+
+ +
+ )} +
+ + {/* Encryption Tools Section */} +
+

Encryption Tools

+
+ +