commit a8bbbbafb9215f4f71dc0dcb8c2ec14af1bb5e0d
Author: Laan Tungir
",html);assert m,"module script not found in post.html";pathlib.Path("build/post-module-check.mjs").write_text(m.group(1),encoding="utf-8");print("extracted module script to build/post-module-check.mjs")' + +node --check build/post-module-check.mjs diff --git a/.roo/commands/push.md b/.roo/commands/push.md new file mode 100644 index 0000000..032935f --- /dev/null +++ b/.roo/commands/push.md @@ -0,0 +1,9 @@ +--- +description: "First, we run increment and commit, which updates git, and pushes to the remote repo. Then we upload our changed files to our web server." +--- + +Run: ./increment_and_commit.sh "This is a meaningful git commit message" + +Run: ./upload_www.sh + +Do not use bash for these commands. Just run them as shown. Don't check git status. \ No newline at end of file diff --git a/.roo/mcp.json b/.roo/mcp.json new file mode 100644 index 0000000..e69de29 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..6f3a291 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "liveServer.settings.port": 5501 +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ff503e8 --- /dev/null +++ b/README.md @@ -0,0 +1,182 @@ +# client + +A nostr web application framework built on [NDK (Nostr Development Kit)](https://github.com/nostr-dev-kit/ndk). Uses a SharedWorker architecture where a single NDK instance manages all relay connections, caching, and subscriptions across multiple browser tabs. + +## Architecture Overview + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ index.html │ │ post.html │ │ cashu.html │ ... (any page) +│ init-ndk.mjs│ │ init-ndk.mjs│ │ init-ndk.mjs│ +└──────┬───────┘ └──────┬───────┘ └──────┬───────┘ + │ postMessage │ │ + └─────────────┬────┘─────────────────┘ + ▼ + ┌─────────────────┐ + │ ndk-worker.js │ (SharedWorker — single instance) + │ ┌─────────────┐ │ + │ │ NDK │ │ + │ │ + Dexie │ │ ← IndexedDB cache (ndk-shared) + │ │ + Signer │ │ + │ └──────┬──────┘ │ + └─────────┼────────┘ + ▼ + ┌────────────────────────┐ + │ Nostr Relay Pool │ + │ wss://relay1 relay2 │ + └────────────────────────┘ +``` + +## Event Management Strategy + +This section defines **when and how** nostr events are fetched, cached, and subscribed to. It serves as the single source of truth for event lifecycle management. + +### Cache Behavior + +All events flowing through NDK are automatically cached in **IndexedDB via the Dexie adapter** (database: `ndk-shared`). This means: + +- Any `ndk.fetchEvents()` call caches results automatically +- Any subscription event received is cached via `cacheAdapter.setEvent()` +- Pages can query the cache directly via `queryCache()` without hitting relays +- NDK's `CACHE_FIRST` mode returns cached data immediately, then updates from relays + +### Startup Events (fetched by worker on init) + +These events are fetched once during `handleInit()` when the first tab connects. They populate the Dexie cache and provide essential app-wide state. + +| Priority | Kind | NIP | Name | Filter | Subscription | Purpose | +|----------|------|-----|------|--------|-------------|---------| +| 1 | `0` | NIP-01 | User Metadata | `authors:[pubkey]` | No — one-shot | Profile display (name, picture, about) | +| 2 | `10002` | NIP-65 | Relay List | `authors:[pubkey], limit:1` | **Yes** — persistent `closeOnEose:false` | Configure relay pool; live updates | +| 3 | `30078` | NIP-78 | App Settings | `authors:[pubkey], #d:[user-settings]` | No — one-shot | Encrypted user preferences (NIP-44) | +| 4 | `17375` | NIP-60 | Cashu Wallet | `authors:[pubkey]` | **Yes** — startup fetch + persistent | Wallet metadata, mint list, and live wallet changes | +| 5 | `7375` | NIP-60 | Cashu Tokens | `authors:[pubkey]` | **Yes** — startup fetch + persistent | Unspent proofs and live token changes | +| 6 | `5` | NIP-09 | Event Deletion | `authors:[pubkey], #k:[7375]` | **Yes** — startup fetch + persistent | Track spent/deleted tokens | + +**Startup sequence (current implementation):** + +``` +handleInit(pubkey) + ├── initNDK() // Create NDK, connect relays + ├── hydrateUserSettingsForPubkey() // kind 30078 + ├── fetchUserProfile() // kind 0 + ├── fetchUserRelays() // kind 10002 (one-shot) + ├── subscribe kind 10002 // persistent relay list updates + ├── fetchStartupEventDownloads() // one-shot preload: kinds 3, 17375, 7375, 5(#k=7375) + └── ensureStartupWalletSubscriptions() // persistent wallet subscriptions: 17375, 7375, 5(#k=7375) +``` + +This means wallet information is not only prefetched at startup; it is now also continuously updated by long-lived worker subscriptions. + +### On-Demand Events (fetched by individual pages) + +These events are fetched when a specific page loads. They benefit from the Dexie cache — if the same events were previously fetched, they're served from cache first. + +| Kind | NIP | Name | Pages | Pattern | +|------|-----|------|-------|---------| +| `0` | NIP-01 | User Metadata | profile, msg, post, npub | `fetchEventsFromAllRelays` or `subscribe` | +| `1` | NIP-01 | Short Text Notes | post, index, links, strudel, template | `subscribe` with `CACHE_FIRST` | +| `3` | NIP-02 | Contact List | post, profile, cashu | `subscribe` + `queryCache` + `fetchEventsFromAllRelays` | +| `4` | NIP-04 | Encrypted DMs | msg | `subscribe` with `CACHE_FIRST` | +| `1059` | NIP-44 | Gift Wrap (DMs) | msg | `subscribe` with `CACHE_FIRST` | +| `5` | NIP-09 | Event Deletion | (via startup sub) | Persistent subscription | +| `7` | NIP-25 | Reactions | post (via interactions) | `fetchEventsFromAllRelays` | +| `6` | NIP-18 | Reposts | post (via interactions) | `fetchEventsFromAllRelays` | +| `9735` | NIP-57 | Zap Receipts | post (via interactions) | `fetchEventsFromAllRelays` | +| `10019` | NIP-60 | Mint List | cashu (mint discovery) | `fetchEventsFromAllRelays` | +| `10063` | NIP-96 | Blossom Server List | blobs | `subscribe` with `CACHE_FIRST` | +| `17375` | NIP-60 | Cashu Wallet | cashu (full wallet ops) | Via NDKCashuWallet | +| `7375` | NIP-60 | Cashu Tokens | cashu (full wallet ops) | Via NDKCashuWallet (cache-first) | +| `7376` | NIP-60 | Spending History | cashu (transaction list) | Via NDKCashuWallet | +| `30023` | NIP-23 | Long-form Content | note | `subscribe` with `CACHE_FIRST` | +| `30024` | NIP-23 | Draft Long-form | note | `subscribe` with `CACHE_FIRST` | +| `30078` | NIP-78 | App-specific Data | todo, cal, links, post (viewed) | `subscribe` or `fetchEvents` | +| `38000` | NIP-89 | Recommendations | cashu (mint discovery) | `fetchEventsFromAllRelays` | +| `38421` | — | AI Service Announcements | ai | `subscribe` with `CACHE_FIRST` | + +### Subscription Patterns + +The app uses three distinct patterns for fetching events: + +#### 1. One-shot Fetch +```javascript +// Fetch once, cache result, done +const events = await ndk.fetchEvents(filter); +``` +Used for: startup profile, relay list, wallet definition, user settings + +#### 2. Subscribe with CACHE_FIRST +```javascript +// Returns cached events immediately, then live events from relays +subscribe(filter, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' }); +``` +Used for: most page-level subscriptions (posts, contacts, notes, DMs) + +#### 3. Persistent Worker Subscription +```javascript +// Long-lived subscription in the worker, survives page navigation +ndk.subscribe(filter, { closeOnEose: false }); +``` +Used for: relay list updates (kind 10002), wallet metadata (kind 17375), token events (kind 7375), and token deletions (kind 5 with #k=7375) + +### Cache-First Advantage + +Because startup events are fetched early and cached in Dexie, subsequent page loads benefit: + +- **post.html** subscribes to kind 3 (contacts) → if profile.html already fetched it, cache hit +- **cashu.html** starts NDKCashuWallet → kind 7375 tokens already in cache from startup → instant optimistic balance +- **Any page** fetching kind 0 (profile) → already cached from startup + +### Modifying Event Timing + +To change when an event kind is fetched: + +1. **Move to startup:** Add the fetch to `fetchStartupWalletState()` or create a new startup function in `handleInit()`. This pre-populates the cache for all pages. + +2. **Move to on-demand:** Remove from startup, let individual pages fetch as needed. First load will be slower but startup will be faster. + +3. **Add a subscription:** For events that change frequently, add a persistent subscription in the worker. This keeps the cache fresh without page-level polling. + +**Trade-offs:** +- More startup events = slower initial load, faster page navigation +- Fewer startup events = faster initial load, slower first page that needs the data +- Persistent subscriptions = always fresh data, more relay bandwidth + +## Project Structure + +``` +www/ +├── ndk-worker.js # SharedWorker — NDK instance, cache, subscriptions +├── js/ +│ ├── init-ndk.mjs # Client-side NDK API (shared by all pages) +│ ├── relay-ui.mjs # Footer relay status + balance display +│ ├── cashu-wallet.mjs # Cashu wallet controller (cashu.html only) +│ ├── post-interactions.mjs # Social interactions (reactions, reposts, zaps) +│ └── utilities.mjs # Shared utilities +├── css/client.css # Shared styles +├── *.html # Individual pages +└── ndk-core.bundle.js # Bundled NDK library + +build/ +├── ndk-entry.js # Bundle entry point +└── ndk-core.bundle.js # Built bundle + +ndk/ # NDK source (submodule/clone) +├── core/ # Core NDK library +├── wallet/ # Cashu wallet implementation +├── cache-browser/ # Browser cache adapter +├── cache-dexie/ # Dexie (IndexedDB) cache +└── ... + +plans/ # Architecture and implementation plans +``` + +## Building + +```bash +# Build the NDK bundle +node build-ndk-bundle.js + +# Serve locally +python serve_local.py +``` diff --git a/__pycache__/serve_local.cpython-313.pyc b/__pycache__/serve_local.cpython-313.pyc new file mode 100644 index 0000000..95c51f8 Binary files /dev/null and b/__pycache__/serve_local.cpython-313.pyc differ diff --git a/agent_browser_login.sh b/agent_browser_login.sh new file mode 100755 index 0000000..ea864f1 --- /dev/null +++ b/agent_browser_login.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# agent_browser_login.sh +# Opens a headed, isolated agent-browser session and logs in to any client +# page using the nsec from ~/.wsb_key/private_key.txt. +# +# Usage: +# ./agent_browser_login.sh [session-name] [url] +# +# Defaults: +# session-name : auto-login-$$ (unique per run) +# url : https://laantungir.net/client/index.html + +set -euo pipefail + +SESSION="${1:-auto-login-$$}" +RAW_URL="${2:-index.html}" +BASE_URL="https://laantungir.net/client" + +# If just a page name (no http/https/slash prefix), expand to full client URL +if [[ "$RAW_URL" != http* && "$RAW_URL" != /* ]]; then + URL="${BASE_URL}/${RAW_URL}" +else + URL="$RAW_URL" +fi +KEY_FILE="${HOME}/.wsb_key/private_key.txt" + +if [[ ! -f "$KEY_FILE" ]]; then + echo "ERROR: Key file not found: $KEY_FILE" >&2 + exit 1 +fi + +NSEC=$(cat "$KEY_FILE" | tr -d '[:space:]') +if [[ -z "$NSEC" ]]; then + echo "ERROR: Key file is empty: $KEY_FILE" >&2 + exit 1 +fi + +echo "[agent_browser_login] Session: $SESSION" +echo "[agent_browser_login] URL: $URL" +echo "[agent_browser_login] Key: ${NSEC:0:12}..." + +# Open the page in an isolated headed session +agent-browser --session-name "$SESSION" open "$URL" +agent-browser --session-name "$SESSION" wait --load networkidle + +# Check if already logged in (Logout button present = already authed) +IS_LOGGED_IN=$(agent-browser --session-name "$SESSION" eval \ + "document.querySelector('button')?.textContent?.includes('Logout') || \ + [...document.querySelectorAll('button')].some(b=>b.textContent.trim()==='Logout') \ + ? 'yes' : 'no'" 2>/dev/null || echo "no") + +if [[ "$IS_LOGGED_IN" == "yes" ]]; then + echo "[agent_browser_login] Already logged in, logging out first..." + agent-browser --session-name "$SESSION" eval \ + "[...document.querySelectorAll('button')].find(b=>b.textContent.trim()==='Logout')?.click()" + agent-browser --session-name "$SESSION" wait 1000 +fi + +# Launch the login modal +echo "[agent_browser_login] Launching login modal..." +agent-browser --session-name "$SESSION" eval "window.NOSTR_LOGIN_LITE?.launch()" +agent-browser --session-name "$SESSION" wait 500 + +# Click "Local Key" +echo "[agent_browser_login] Clicking Local Key..." +agent-browser --session-name "$SESSION" eval \ + "[...document.querySelectorAll('button')].find(b=>b.textContent.includes('Local Key'))?.click()" +agent-browser --session-name "$SESSION" wait 500 + +# Fill in the nsec key +echo "[agent_browser_login] Filling in nsec..." +agent-browser --session-name "$SESSION" eval \ + "(()=>{ + const i = document.querySelector('input[type=password], input[placeholder*=nsec], input[placeholder*=secret], textarea[placeholder*=nsec]') + || [...document.querySelectorAll('input,textarea')].find(el=>el.placeholder?.toLowerCase().includes('secret')); + if(!i) return 'ERROR: no key input found'; + i.value = '$NSEC'; + i.dispatchEvent(new Event('input',{bubbles:true})); + i.dispatchEvent(new Event('change',{bubbles:true})); + return 'filled'; + })()" +agent-browser --session-name "$SESSION" wait 300 + +# Click "Import Key" +echo "[agent_browser_login] Clicking Import Key..." +agent-browser --session-name "$SESSION" eval \ + "[...document.querySelectorAll('button')].find(b=>b.textContent.includes('Import Key') && !b.disabled)?.click()" +agent-browser --session-name "$SESSION" wait 1000 + +# Click "Continue" if shown +agent-browser --session-name "$SESSION" eval \ + "[...document.querySelectorAll('button')].find(b=>b.textContent.trim()==='Continue')?.click()" 2>/dev/null || true +agent-browser --session-name "$SESSION" wait 2000 + +# Verify login +PUBKEY=$(agent-browser --session-name "$SESSION" eval \ + "window.nostr?.getPublicKey ? 'pending' : 'none'" 2>/dev/null || echo "none") + +agent-browser --session-name "$SESSION" eval \ + "window.nostr?.getPublicKey().then(pk=>window._loginPubkey=pk)" 2>/dev/null || true +agent-browser --session-name "$SESSION" wait 1000 + +PUBKEY=$(agent-browser --session-name "$SESSION" eval \ + "window._loginPubkey || 'not logged in'" 2>/dev/null || echo "not logged in") + +echo "[agent_browser_login] Logged in as: $PUBKEY" + +# If this is bunker.html, extract and print the bunker URI +if [[ "$URL" == *"bunker.html"* ]]; then + agent-browser --session-name "$SESSION" wait 2000 + BUNKER_URI=$(agent-browser --session-name "$SESSION" eval \ + "[...document.querySelectorAll('textarea')].map(t=>t.value).find(v=>v.startsWith('bunker://'))" \ + | tr -d '"') + if [[ -n "$BUNKER_URI" && "$BUNKER_URI" != "null" ]]; then + echo "[agent_browser_login] Bunker URI: $BUNKER_URI" + echo "BUNKER_URI=$BUNKER_URI" + else + echo "[agent_browser_login] WARNING: Could not read bunker URI (bunker may not be started yet)" + fi +fi + +echo "[agent_browser_login] Session '$SESSION' is ready." +echo "" +echo "To use this session in subsequent agent-browser commands:" +echo " agent-browser --session-name \"$SESSION\" snapshot" diff --git a/agent_browser_remote_login.sh b/agent_browser_remote_login.sh new file mode 100755 index 0000000..4b84cbe --- /dev/null +++ b/agent_browser_remote_login.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# agent_browser_remote_login.sh +# Opens a headed browser window in a NEW isolated session (separate +# localStorage/cookies from agent_browser_login.sh sessions) for testing +# NIP-46 remote login. +# +# Usage: +# ./agent_browser_remote_login.sh [session-name] [page] [bunker-uri] +# +# Examples: +# ./agent_browser_remote_login.sh remote1 index.html +# ./agent_browser_remote_login.sh remote1 index.html "bunker://..." + +set -euo pipefail + +SESSION="${1:-remote-$$}" +RAW_URL="${2:-index.html}" +BUNKER_URI="${3:-}" +BASE_URL="https://laantungir.net/client" + +# Expand bare page name to full URL +if [[ "$RAW_URL" != http* && "$RAW_URL" != /* ]]; then + URL="${BASE_URL}/${RAW_URL}" +else + URL="$RAW_URL" +fi + +echo "[remote_login] Session: $SESSION (isolated from other sessions)" +echo "[remote_login] URL: $URL" + +# Open the page — --session-name gives each session its own isolated storage +agent-browser --session-name "$SESSION" open "$URL" +agent-browser --session-name "$SESSION" wait --load networkidle + +# Clear any stored auth so this session starts fresh +agent-browser --session-name "$SESSION" eval \ + "(()=>{ + const keys = Object.keys(localStorage).filter(k=>k.includes('auth')||k.includes('nostr')); + keys.forEach(k=>localStorage.removeItem(k)); + const skeys = Object.keys(sessionStorage).filter(k=>k.includes('auth')||k.includes('nostr')); + skeys.forEach(k=>sessionStorage.removeItem(k)); + return 'cleared ' + keys.length + ' localStorage + ' + skeys.length + ' sessionStorage keys'; + })()" + +# Reload so the cleared state takes effect +agent-browser --session-name "$SESSION" eval "location.reload()" +agent-browser --session-name "$SESSION" wait --load networkidle +agent-browser --session-name "$SESSION" wait 1000 + +echo "[remote_login] Page loaded fresh (no stored auth)." + +# If a bunker URI was provided, auto-fill and connect +if [[ -n "$BUNKER_URI" ]]; then + echo "[remote_login] Auto-connecting with bunker URI..." + + # Click Nostr Connect + agent-browser --session-name "$SESSION" eval \ + "[...document.querySelectorAll('button')].find(b=>b.textContent.includes('Nostr Connect'))?.click()" + agent-browser --session-name "$SESSION" wait 500 + + # Fill bunker URI — write to a global var first to avoid quoting issues + agent-browser --session-name "$SESSION" eval "window._bunkerUri = atob('$(echo -n "$BUNKER_URI" | base64 -w0)')" + agent-browser --session-name "$SESSION" eval \ + "(()=>{ + const i = document.querySelector('input[placeholder*=\"bunker\"]'); + if (!i) return 'ERROR: no bunker input found'; + i.value = window._bunkerUri; + i.dispatchEvent(new Event('input', {bubbles:true})); + return 'filled: ' + i.value.substring(0,40); + })()" + agent-browser --session-name "$SESSION" wait 300 + + # Click Connect to Bunker + agent-browser --session-name "$SESSION" eval \ + "[...document.querySelectorAll('button')].find(b=>b.textContent.includes('Connect to Bunker')&&!b.disabled)?.click()" + + echo "[remote_login] Connect initiated — approve in the bunker window if needed." +fi + +echo "" +echo "[remote_login] Session '$SESSION' is ready." +echo "To control: agent-browser --session-name \"$SESSION\" snapshot" diff --git a/build-ndk-bundle.js b/build-ndk-bundle.js new file mode 100644 index 0000000..9909ad9 --- /dev/null +++ b/build-ndk-bundle.js @@ -0,0 +1,213 @@ +/** + * NDK Browser Bundle Builder + * + * Creates a browser-compatible IIFE bundle of NDK with: + * - Core NDK functionality + * - Browser cache + * - SQLite WASM cache + * - Sessions + * - Messages + * - Web of Trust (WoT) + * - Blossom + * - Uses the existing window.NostrTools from nostr.bundle.js + * - Exposes NDK as window.NDK + */ + +const esbuild = require('esbuild'); +const path = require('path'); +const fs = require('fs'); + +async function buildNDKBundle() { + console.log('🔧 Building NDK browser bundle with additional packages...'); + console.log('📦 Including: core, wallet, cache-browser, cache-dexie, cache-sqlite-wasm, sessions, messages, wot, blossom'); + + const __dirname = path.resolve(); + + // Create a virtual entry point that imports all packages using absolute paths + const entryContent = ` +// Import the default NDK class +import NDK from '${path.join(__dirname, 'ndk/core/src/index.ts')}'; + +// Import default exports from cache packages +import NDKCacheBrowser from '${path.join(__dirname, 'ndk/cache-browser/src/index.ts')}'; +import NDKCacheAdapterDexie from '${path.join(__dirname, 'ndk/cache-dexie/src/index.ts')}'; +import NDKCacheAdapterSqliteWasm from '${path.join(__dirname, 'ndk/cache-sqlite-wasm/src/index.ts')}'; + +import { CashuMint, CashuWallet, getDecodedToken, getEncodedTokenV4 } from '${path.join(__dirname, 'ndk/node_modules/@cashu/cashu-ts/lib/cashu-ts.es.js')}'; + +// Re-export everything as named exports +export * from '${path.join(__dirname, 'ndk/core/src/index.ts')}'; +export * from '${path.join(__dirname, 'ndk/cache-browser/src/index.ts')}'; +export * from '${path.join(__dirname, 'ndk/cache-dexie/src/index.ts')}'; +export * from '${path.join(__dirname, 'ndk/cache-sqlite-wasm/src/index.ts')}'; +export * from '${path.join(__dirname, 'ndk/sessions/src/index.ts')}'; +export * from '${path.join(__dirname, 'ndk/messages/src/index.ts')}'; +export * from '${path.join(__dirname, 'ndk/wot/src/index.ts')}'; +export * from '${path.join(__dirname, 'ndk/blossom/src/index.ts')}'; +export * from '${path.join(__dirname, 'ndk/wallet/src/index.ts')}'; + +// Re-export default exports as named exports +export { + NDKCacheBrowser, + NDKCacheAdapterDexie, + NDKCacheAdapterSqliteWasm, + CashuMint, + CashuWallet, + getDecodedToken, + getEncodedTokenV4 +}; + +// Export the main NDK class as default +export default NDK; +`; + + // Write the virtual entry point + const entryPath = path.join(__dirname, 'build/ndk-entry.js'); + fs.mkdirSync('build', { recursive: true }); + fs.writeFileSync(entryPath, entryContent); + + try { + const result = await esbuild.build({ + entryPoints: [entryPath], + bundle: true, + format: 'iife', + globalName: 'NDK', + outfile: 'build/ndk-core.bundle.js', + platform: 'browser', + target: 'es2020', + minify: false, // Set to true for production + sourcemap: true, + metafile: true, // Enable metafile for analysis + + // Plugin to map nostr-tools imports to window.NostrTools + plugins: [{ + name: 'nostr-tools-external', + setup(build) { + // Intercept all nostr-tools imports (including subpaths like nostr-tools/nip49) + build.onResolve({ filter: /^nostr-tools/ }, args => { + return { + path: args.path, + namespace: 'nostr-tools-external' + }; + }); + + // Replace with window.NostrTools reference + build.onLoad({ filter: /.*/, namespace: 'nostr-tools-external' }, (args) => { + // Extract subpath if present (e.g., "nostr-tools/nip49" -> "nip49") + const subpath = args.path.replace(/^nostr-tools\/?/, ''); + + return { + contents: ` + // Map nostr-tools to window.NostrTools + if (typeof window === 'undefined' || !window.NostrTools) { + throw new Error('NDK: nostr.bundle.js must be loaded before ndk-core.bundle.js'); + } + ${subpath ? ` + // Access subpath: ${subpath} + if (!window.NostrTools.${subpath}) { + console.warn('nostr-tools/${subpath} not found in window.NostrTools'); + module.exports = {}; + } else { + module.exports = window.NostrTools.${subpath}; + } + ` : ` + module.exports = window.NostrTools; + `} + `, + loader: 'js' + }; + }); + } + }], + + // Define environment variables for browser + define: { + 'process.env.NODE_ENV': '"production"', + 'global': 'window' + }, + + // Banner to add at the top of the bundle + banner: { + js: `/** + * NDK (Nostr Development Kit) - Browser Bundle + * Version: 3.0.0-beta.64 + * + * IMPORTANT: This bundle requires nostr.bundle.js to be loaded first! + * + * Usage: + * + * + * + * Then access via: window.NDK + * + * Generated: ${new Date().toISOString()} + */ +` + } + }); + + // Save metafile for analysis + fs.writeFileSync('build/meta.json', JSON.stringify(result.metafile, null, 2)); + + const stats = fs.statSync('build/ndk-core.bundle.js'); + const sizeKB = (stats.size / 1024).toFixed(2); + + console.log('✅ NDK bundle created successfully!'); + console.log(`📏 Bundle size: ${sizeKB} KB`); + console.log('📄 Output: build/ndk-core.bundle.js'); + console.log('📄 Source map: build/ndk-core.bundle.js.map'); + console.log(''); + + // Analyze bundle composition + console.log('📊 Bundle Composition Analysis:'); + console.log(''); + + const inputs = result.metafile.inputs; + const packageSizes = {}; + + for (const [file, info] of Object.entries(inputs)) { + let packageName = 'other'; + + if (file.includes('ndk/core/')) packageName = 'core'; + else if (file.includes('ndk/cache-browser/')) packageName = 'cache-browser'; + else if (file.includes('ndk/cache-dexie/')) packageName = 'cache-dexie'; + else if (file.includes('ndk/cache-sqlite-wasm/')) packageName = 'cache-sqlite-wasm'; + else if (file.includes('ndk/sessions/')) packageName = 'sessions'; + else if (file.includes('ndk/messages/')) packageName = 'messages'; + else if (file.includes('ndk/wot/')) packageName = 'wot'; + else if (file.includes('ndk/blossom/')) packageName = 'blossom'; + else if (file.includes('node_modules/@noble/')) packageName = 'noble-crypto'; + else if (file.includes('node_modules/')) packageName = 'dependencies'; + + if (!packageSizes[packageName]) packageSizes[packageName] = 0; + packageSizes[packageName] += info.bytes; + } + + // Sort by size + const sorted = Object.entries(packageSizes) + .sort((a, b) => b[1] - a[1]) + .map(([name, bytes]) => ({ + name, + kb: (bytes / 1024).toFixed(2), + percent: ((bytes / stats.size) * 100).toFixed(1) + })); + + for (const item of sorted) { + const bar = '█'.repeat(Math.floor(item.percent / 2)); + console.log(` ${item.name.padEnd(20)} ${item.kb.padStart(8)} KB ${item.percent.padStart(5)}% ${bar}`); + } + + console.log(''); + console.log('📋 Usage:'); + console.log(' 1. Load nostr.bundle.js first'); + console.log(' 2. Load ndk-core.bundle.js second'); + console.log(' 3. Access via window.NDK'); + + } catch (error) { + console.error('❌ Build failed:', error); + process.exit(1); + } +} + +// Run the build +buildNDKBundle(); diff --git a/build/.cashu-inline-check.mjs b/build/.cashu-inline-check.mjs new file mode 100644 index 0000000..57ac514 --- /dev/null +++ b/build/.cashu-inline-check.mjs @@ -0,0 +1,1980 @@ + + import { initNDKPage, getPubkey, injectHeaderAvatar, disconnect, getVersion, updateVersionDisplay, getRelayData, ndkFetchEvents, queryCache, getUserSettings, patchUserSettings, onUserSettings } from './js/init-ndk.mjs'; + import { HamburgerMorphing } from './hamburger_morphing/hamburger.mjs'; + import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs'; + import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs'; + + import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs'; + import { createCashuWalletController } from './js/cashu-wallet.mjs?v=mint-discovery-3'; + + const versionInfo = await getVersion(); + const VERSION = versionInfo.VERSION; + console.log(`[cashu.html ${VERSION}] Loading...`); + + let updateIntervalId = null; + let currentPubkey = null; + + let hamburgerInstance = null; + let isNavOpen = false; + + let logoutHamburger = null; + let themeToggleHamburger = null; + let isDarkMode = false; + + let walletController = null; + let unsubscribeTx = null; + let currentRelayUrls = []; + let latestWorkerWalletBalance = null; + + let mintDiscoveryPromise = null; + let unsubscribeUserSettings = null; + let zapSettingsLoaded = false; + let zapSettings = { + defaultAmountSats: 21, + defaultComment: '', + preferredMethod: 'auto', + receiveMintAllowlist: [] + }; + let latestMintListStatus = { + mints: [], + relays: [], + p2pk: null, + eventId: null, + publishedRelayCount: 0, + updatedAtSec: 0 + }; + let walletStartupPromise = null; + let mintDiscoveryCache = { + contactMintsByScope: { all: [], wot: [] }, + recommendationMintsByScope: { all: [], wot: [] }, + followedPubkeys: [], + rawContactEvents: [], + rawMintListEvents: [], + rawRecommendationEvents: [], + error: null, + scope: 'all', + hydratedFromCache: false, + refreshedFromRelays: false + }; + + const divBody = document.getElementById('divBody'); + const divSideNav = document.getElementById('divSideNav'); + const divSideNavBody = document.getElementById('divSideNavBody'); + const divFooterCenter = document.getElementById('divFooterCenter'); + const divFooterRight = document.getElementById('divFooterRight'); + + const divWalletStatus = document.getElementById('divWalletStatus'); + const divSetupPanel = document.getElementById('divSetupPanel'); + const divSetupStatus = document.getElementById('divSetupStatus'); + const taSetupMints = document.getElementById('taSetupMints'); + const btnCreateWallet = document.getElementById('btnCreateWallet'); + + const divWalletContent = document.getElementById('divWalletContent'); + const divBalanceAmount = document.getElementById('divBalanceAmount'); + const divMintBalances = document.getElementById('divMintBalances'); + + const btnTabReceive = document.getElementById('btnTabReceive'); + const btnTabSend = document.getElementById('btnTabSend'); + const btnTabDeposit = document.getElementById('btnTabDeposit'); + const btnTabWithdraw = document.getElementById('btnTabWithdraw'); + + const panelReceive = document.getElementById('panelReceive'); + const panelSend = document.getElementById('panelSend'); + const panelDeposit = document.getElementById('panelDeposit'); + const panelWithdraw = document.getElementById('panelWithdraw'); + + const taReceiveToken = document.getElementById('taReceiveToken'); + const btnReceiveToken = document.getElementById('btnReceiveToken'); + + const inpSendAmount = document.getElementById('inpSendAmount'); + const inpSendMemo = document.getElementById('inpSendMemo'); + const selSendMint = document.getElementById('selSendMint'); + const btnSendToken = document.getElementById('btnSendToken'); + const divSendTokenOutput = document.getElementById('divSendTokenOutput'); + const divSendTokenPreview = document.getElementById('divSendTokenPreview'); + const divSendTokenQr = document.getElementById('divSendTokenQr'); + const btnCloseSendTokenPreview = document.getElementById('btnCloseSendTokenPreview'); + const btnCopySendToken = document.getElementById('btnCopySendToken'); + const btnClearSendToken = document.getElementById('btnClearSendToken'); + + const inpDepositAmount = document.getElementById('inpDepositAmount'); + const selDepositMint = document.getElementById('selDepositMint'); + const btnCreateInvoice = document.getElementById('btnCreateInvoice'); + const divDepositInvoice = document.getElementById('divDepositInvoice'); + const divDepositInvoiceQr = document.getElementById('divDepositInvoiceQr'); + const btnCopyInvoice = document.getElementById('btnCopyInvoice'); + + const selWithdrawMint = document.getElementById('selWithdrawMint'); + const taWithdrawInvoice = document.getElementById('taWithdrawInvoice'); + const btnPayInvoice = document.getElementById('btnPayInvoice'); + + const divTxList = document.getElementById('divTxList'); + + const inpAddMint = document.getElementById('inpAddMint'); + const btnAddMint = document.getElementById('btnAddMint'); + const btnCheckProofs = document.getElementById('btnCheckProofs'); + const divCheckProofsResult = document.getElementById('divCheckProofsResult'); + const btnRefreshWallet = document.getElementById('btnRefreshWallet'); + + const divContactMintsTitle = document.getElementById('divContactMintsTitle'); + const divContactMintsList = document.getElementById('divContactMintsList'); + const btnSortByCount = document.getElementById('btnSortByCount'); + const btnSortByRating = document.getElementById('btnSortByRating'); + const btnToggleWot = document.getElementById('btnToggleWot'); + + const inpZapDefaultAmount = document.getElementById('inpZapDefaultAmount'); + const taZapDefaultComment = document.getElementById('taZapDefaultComment'); + const selZapPreferredMethod = document.getElementById('selZapPreferredMethod'); + const btnSaveZapsSettings = document.getElementById('btnSaveZapsSettings'); + const btnResetZapsSettings = document.getElementById('btnResetZapsSettings'); + const btnRepublishMintList = document.getElementById('btnRepublishMintList'); + const divNutzapMintCount = document.getElementById('divNutzapMintCount'); + const divNutzapRelayCount = document.getElementById('divNutzapRelayCount'); + const divNutzapReceiveMintCount = document.getElementById('divNutzapReceiveMintCount'); + const divNutzapP2pk = document.getElementById('divNutzapP2pk'); + const divNutzapLastPublish = document.getElementById('divNutzapLastPublish'); + const taNutzapReceiveMints = document.getElementById('taNutzapReceiveMints'); + const btnUseWalletMintsForNutzap = document.getElementById('btnUseWalletMintsForNutzap'); + const divZapsStatus = document.getElementById('divZapsStatus'); + + const MINT_DISCOVERY_WINDOW_SEC = 365 * 24 * 60 * 60; + let mintDiscoveryScope = 'all'; + let mintDiscoverySortMode = 'count'; + const mintDiscoveryLastNonEmptyByView = new Map(); + + let walletStatusReady = false; + let hasFullWalletBalanceReady = false; + let txTimeAgoIntervalId = null; + let workerWalletUiActivated = false; + const redeemedSendTxIds = new Set(); + let hasSeededInitialSendTxState = false; + + function setStatus(message, isError = false) { + divWalletStatus.textContent = message; + divWalletStatus.classList.toggle('error', Boolean(isError)); + + if (divSetupStatus) { + divSetupStatus.textContent = message; + divSetupStatus.classList.toggle('error', Boolean(isError)); + } + } + + function formatSats(amount, { approximate = false } = {}) { + const value = Number(amount || 0); + const prefix = approximate ? '~' : ''; + return `${prefix}${value.toLocaleString()} sats`; + } + + function renderImmediateWorkerBalance(payload = {}) { + const total = Number(payload?.balance || 0); + const byMint = (payload?.balancesByMint && typeof payload?.balancesByMint === 'object') + ? payload.balancesByMint + : {}; + + const isApproximate = payload?.isApproximate === true || payload?.balanceSource === 'lightweight'; + divBalanceAmount.textContent = formatSats(total, { approximate: isApproximate }); + + const mintRows = Object.entries(byMint || {}); + if (mintRows.length === 0) { + divMintBalances.innerHTML = total > 0 ? '' : 'No mint balances yet'; + } else { + divMintBalances.innerHTML = mintRows + .map(([mint, amount]) => ` +
+ `) + .join(''); + } + + const payloadMints = Object.keys(byMint || {}) + .map((mint) => normalizeMintUrl(mint)) + .filter(Boolean); + + if (payloadMints.length > 0) { + renderMintSelectors(payloadMints); + if (selWithdrawMint) { + selWithdrawMint.innerHTML = payloadMints + .map((mint) => ``) + .join(''); + } + } + } + + function formatTimeAgo(timestamp, nowSec = Math.floor(Date.now() / 1000)) { + const ts = Number(timestamp || 0); + const diff = Math.max(0, nowSec - ts); + + if (diff < 60) { + return { + text: `${diff}s ago`, + nextUpdateAt: nowSec + 1 + }; + } + + if (diff < 3600) { + const remainder = diff % 60; + return { + text: `${Math.floor(diff / 60)}m ago`, + nextUpdateAt: nowSec + (remainder === 0 ? 60 : 60 - remainder) + }; + } + + if (diff < 86400) { + const remainder = diff % 3600; + return { + text: `${Math.floor(diff / 3600)}h ago`, + nextUpdateAt: nowSec + (remainder === 0 ? 3600 : 3600 - remainder) + }; + } + + const remainder = diff % 86400; + return { + text: `${Math.floor(diff / 86400)}d ago`, + nextUpdateAt: nowSec + (remainder === 0 ? 86400 : 86400 - remainder) + }; + } + + function shortenMintName(mintUrl) { + const normalized = normalizeMintUrl(mintUrl) || String(mintUrl || ''); + if (!normalized) return ''; + + try { + const host = new URL(normalized).hostname || normalized; + return host.replace(/^www\./i, ''); + } catch (_error) { + return normalized + .replace(/^https?:\/\//i, '') + .replace(/\/$/, ''); + } + } + + function clearTransactionOutputs({ send = true, deposit = true, withdraw = true } = {}) { + if (send) { + divSendTokenOutput.textContent = ''; + divSendTokenOutput.classList.add('clsHidden'); + renderQrCode(divSendTokenQr, ''); + if (divSendTokenPreview) { + divSendTokenPreview.classList.add('clsHidden'); + } + btnCopySendToken.classList.add('clsHidden'); + if (btnClearSendToken) { + btnClearSendToken.classList.add('clsHidden'); + } + } + + if (deposit) { + divDepositInvoice.textContent = ''; + divDepositInvoice.classList.add('clsHidden'); + renderQrCode(divDepositInvoiceQr, ''); + btnCopyInvoice.classList.add('clsHidden'); + } + + if (withdraw && taWithdrawInvoice) { + taWithdrawInvoice.value = ''; + } + } + + function confirmDiscardTransactionOutputs({ send = false, deposit = false, withdraw = false } = {}) { + const parts = []; + if (send) parts.push('generated token QR/code'); + if (deposit) parts.push('deposit invoice QR/code'); + if (withdraw) parts.push('withdraw invoice input'); + + if (parts.length === 0) return true; + + return window.confirm(`Transaction complete. Discard ${parts.join(' and ')}?`); + } + + function startTimestampUpdater() { + if (txTimeAgoIntervalId) return; + + txTimeAgoIntervalId = setInterval(() => { + const nowSec = Math.floor(Date.now() / 1000); + const nodes = divTxList.querySelectorAll('.cashuTxTimeAgo[data-timestamp]'); + + for (const node of nodes) { + const timestamp = Number(node.dataset.timestamp || 0); + const nextUpdateAt = Number(node.dataset.nextUpdateAt || 0); + if (nextUpdateAt > nowSec) continue; + + const formatted = formatTimeAgo(timestamp, nowSec); + node.textContent = formatted.text; + node.dataset.nextUpdateAt = String(formatted.nextUpdateAt); + } + }, 1000); + } + + async function copyToClipboard(value, successMessage) { + try { + await navigator.clipboard.writeText(String(value || '')); + setStatus(successMessage); + } catch (error) { + console.error('[cashu.html] Clipboard error:', error); + setStatus('Failed to copy to clipboard', true); + } + } + + function renderQrCode(container, text) { + if (!container) return; + + const raw = String(text || '').trim(); + if (!raw) { + container.innerHTML = ''; + container.classList.add('clsHidden'); + return; + } + + const isLightningInvoice = /^ln[a-z0-9]+/i.test(raw); + const value = isLightningInvoice ? raw.toUpperCase() : raw; + const margin = isLightningInvoice ? 2 : 1; + + if (typeof qrcode !== 'function') { + console.warn('[cashu.html] qrcode-generator not available'); + container.innerHTML = ''; + container.classList.add('clsHidden'); + return; + } + + try { + container.innerHTML = ''; + + // Use byte mode for cashuB tokens so long payloads are encoded correctly. + const qr = qrcode(0, 'L'); + qr.addData(value, 'Byte'); + qr.make(); + + const svgMarkup = qr.createSvgTag(4, margin); + container.innerHTML = svgMarkup; + + const svg = container.querySelector('svg'); + if (svg) { + svg.removeAttribute('width'); + svg.removeAttribute('height'); + } + + container.classList.remove('clsHidden'); + } catch (error) { + console.error('[cashu.html] QR render failed:', error); + container.innerHTML = ''; + container.classList.add('clsHidden'); + } + } + + function initHamburgerMenu() { + hamburgerInstance = new HamburgerMorphing('#divSvgHam', { + foreground: 'var(--primary-color)', + background: 'var(--secondary-color)', + hover: 'var(--accent-color)' + }); + hamburgerInstance.animateTo('burger'); + } + + function openNav() { + divSideNav.style.zIndex = 3; + divSideNav.style.width = 'clamp(400px, 50vw, 600px)'; + isNavOpen = true; + + if (hamburgerInstance) { + hamburgerInstance.animateTo('arrow_left'); + } + + renderMintDiscoverySidebar(); + + if (!logoutHamburger) { + logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', { + size: 24, + foreground: 'var(--primary-color)', + background: 'var(--secondary-color)', + hover: 'var(--accent-color)' + }); + logoutHamburger.animateTo('x'); + } + + if (!themeToggleHamburger) { + themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', { + size: 24, + foreground: 'var(--primary-color)', + background: 'var(--secondary-color)', + hover: 'var(--accent-color)' + }); + + const savedTheme = localStorage.getItem('theme'); + isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode'); + themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle'); + } + } + + function closeNav() { + divSideNav.style.width = '0vw'; + divSideNav.style.zIndex = -1; + isNavOpen = false; + + if (hamburgerInstance) { + hamburgerInstance.animateTo('burger'); + } + } + + function toggleNav() { + if (isNavOpen) closeNav(); + else openNav(); + } + + const UpdateFooter = async () => { + try { + await updateFooterRelayStatus(); + await updateSidenavRelaySection(); + await updateBlossomSection(); + divFooterCenter.innerHTML = ''; + divFooterRight.innerHTML = ''; + } catch (error) { + console.error('[cashu.html] Error updating footer:', error); + } + }; + + const Logout = async () => { + console.log('[cashu.html] Starting logout process...'); + + if (updateIntervalId) { + clearInterval(updateIntervalId); + updateIntervalId = null; + } + + try { + await walletController?.shutdown?.(); + } catch (_error) { + // no-op + } + + disconnect(); + + if (window.NOSTR_LOGIN_LITE && window.NOSTR_LOGIN_LITE.logout) { + await window.NOSTR_LOGIN_LITE.logout(); + } + + localStorage.clear(); + sessionStorage.clear(); + + if (window.indexedDB) { + const databases = await window.indexedDB.databases(); + for (const db of databases) { + if (db.name) window.indexedDB.deleteDatabase(db.name); + } + } + + location.reload(true); + }; + + function showSetup() { + divSetupPanel.classList.remove('clsHidden'); + divWalletContent.classList.add('clsHidden'); + divWalletStatus.classList.add('clsHidden'); + + walletStatusReady = false; + hasFullWalletBalanceReady = false; + updateCheckProofsButtonState(); + + if (!taSetupMints.value.trim()) { + taSetupMints.value = walletController?.DEFAULT_MINTS?.join('\n') || ''; + } + } + + function updateCheckProofsButtonState() { + const isReady = walletStatusReady; + if (btnCheckProofs) { + btnCheckProofs.disabled = !isReady; + btnCheckProofs.title = isReady + ? 'Validate local proofs against each mint and remove spent proofs' + : 'Waiting for wallet startup to finish before proof checking'; + } + } + + function showWallet() { + divSetupPanel.classList.add('clsHidden'); + divWalletContent.classList.remove('clsHidden'); + divWalletStatus.classList.remove('clsHidden'); + setStatus('Wallet ready'); + updateCheckProofsButtonState(); + } + + function setCheckProofsStatus(message, isError = false) { + if (!divCheckProofsResult) return; + divCheckProofsResult.textContent = String(message || ''); + divCheckProofsResult.classList.toggle('error', Boolean(isError)); + } + + function setZapsStatus(message, isError = false) { + if (!divZapsStatus) return; + divZapsStatus.textContent = String(message || ''); + divZapsStatus.classList.toggle('error', Boolean(isError)); + } + + function normalizePreferredZapMethod(value) { + const method = String(value || '').trim().toLowerCase(); + if (method === 'nutzap' || method === 'lightning') return method; + return 'auto'; + } + + function normalizeZapSettings(raw = {}) { + const amount = Number(raw?.zaps?.defaultAmountSats); + const defaultAmountSats = Number.isFinite(amount) && amount > 0 ? Math.floor(amount) : 21; + const defaultComment = String(raw?.zaps?.defaultComment || '').trim(); + const preferredMethod = normalizePreferredZapMethod(raw?.zaps?.preferredMethod || 'auto'); + const receiveMintAllowlist = Array.from(new Set((Array.isArray(raw?.zaps?.receiveMintAllowlist) ? raw.zaps.receiveMintAllowlist : []) + .map((value) => normalizeMintUrl(value)) + .filter(Boolean))); + return { defaultAmountSats, defaultComment, preferredMethod, receiveMintAllowlist }; + } + + function applyZapSettingsForm() { + if (inpZapDefaultAmount) inpZapDefaultAmount.value = String(zapSettings.defaultAmountSats || 21); + if (taZapDefaultComment) taZapDefaultComment.value = String(zapSettings.defaultComment || ''); + if (selZapPreferredMethod) selZapPreferredMethod.value = normalizePreferredZapMethod(zapSettings.preferredMethod); + if (taNutzapReceiveMints) taNutzapReceiveMints.value = (zapSettings.receiveMintAllowlist || []).join('\n'); + } + + function formatNutzapPublishText() { + const eventId = String(latestMintListStatus?.eventId || '').trim(); + const relayCount = Number(latestMintListStatus?.publishedRelayCount || 0); + if (!eventId) return 'Not published in this session'; + return `${eventId.slice(0, 12)}… (${relayCount} relays)`; + } + + function renderNutzapStatusFromLocalState() { + const walletMints = walletController?.hasWallet?.() + ? (walletController.getMints() || []).map((url) => normalizeMintUrl(url)).filter(Boolean) + : []; + + const relayList = Array.isArray(currentRelayUrls) ? currentRelayUrls.filter((url) => /^wss?:\/\//i.test(String(url || '').trim())) : []; + const effectiveMints = Array.isArray(latestMintListStatus?.mints) && latestMintListStatus.mints.length > 0 + ? latestMintListStatus.mints + : walletMints; + const effectiveRelays = Array.isArray(latestMintListStatus?.relays) && latestMintListStatus.relays.length > 0 + ? latestMintListStatus.relays + : relayList; + + if (divNutzapMintCount) { + divNutzapMintCount.textContent = `${effectiveMints.length} configured`; + } + + const allowlist = Array.from(new Set((Array.isArray(zapSettings?.receiveMintAllowlist) ? zapSettings.receiveMintAllowlist : []) + .map((value) => normalizeMintUrl(value)) + .filter(Boolean))); + if (divNutzapReceiveMintCount) { + divNutzapReceiveMintCount.textContent = allowlist.length > 0 + ? `${allowlist.length} selected` + : 'All wallet mints'; + } + + if (divNutzapRelayCount) { + divNutzapRelayCount.textContent = `${effectiveRelays.length} connected/known`; + } + + if (divNutzapP2pk) { + const p2pk = String(latestMintListStatus?.p2pk || '').trim(); + divNutzapP2pk.textContent = p2pk || '(not published yet)'; + } + + if (divNutzapLastPublish) { + divNutzapLastPublish.textContent = formatNutzapPublishText(); + } + + const canRepublish = Boolean(walletController?.hasWallet?.()); + if (btnRepublishMintList) { + btnRepublishMintList.disabled = !canRepublish; + btnRepublishMintList.title = canRepublish + ? 'Publish kind 10019 mint list with current mints/relays' + : 'Create a wallet first'; + } + } + + async function hydrateZapSettingsFromStore({ silent = true } = {}) { + try { + const settings = await getUserSettings(); + zapSettings = normalizeZapSettings(settings || {}); + zapSettingsLoaded = true; + applyZapSettingsForm(); + if (!silent) { + setZapsStatus('Loaded zap settings'); + } + } catch (error) { + console.error('[cashu.html] Failed to load zap settings:', error); + if (!silent) { + setZapsStatus(error?.message || 'Failed to load zap settings', true); + } + } + } + + async function refreshZapsSection() { + if (!zapSettingsLoaded) { + await hydrateZapSettingsFromStore({ silent: true }); + } else { + applyZapSettingsForm(); + } + + renderNutzapStatusFromLocalState(); + } + + function parseMintLines(text) { + return String(text || '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + } + + function normalizeMintUrl(value) { + const raw = String(value || '').trim(); + if (!raw) return ''; + + let parsed; + try { + parsed = new URL(raw); + } catch (_error) { + return ''; + } + + if (parsed.protocol !== 'https:') return ''; + if (!parsed.hostname) return ''; + + return parsed.toString().replace(/\/+$/, ''); + } + + function parseNutzapReceiveAllowlistInput(inputText) { + const rawLines = String(inputText || '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + + const invalid = []; + const normalized = []; + const seen = new Set(); + + for (const line of rawLines) { + const mintUrl = normalizeMintUrl(line); + if (!mintUrl) { + invalid.push(line); + continue; + } + if (seen.has(mintUrl)) continue; + seen.add(mintUrl); + normalized.push(mintUrl); + } + + return { normalized, invalid }; + } + + function flattenEventsByRelay(eventsByRelay) { + const out = []; + for (const events of Object.values(eventsByRelay || {})) { + if (!Array.isArray(events)) continue; + out.push(...events); + } + return out; + } + + function escapeHtml(value) { + const div = document.createElement('div'); + div.textContent = String(value || ''); + return div.innerHTML; + } + + function renderMintDiscoveryRows(container, mints, emptyMessage) { + if (!container) return; + + if (!Array.isArray(mints) || mints.length === 0) { + container.textContent = emptyMessage; + return; + } + + container.innerHTML = mints + .map((entry) => { + const url = String(entry?.url || ''); + const safeUrl = escapeHtml(url); + const mentions = Number(entry?.count || 0); + const votes = Number(entry?.starVotes || 0); + const reviewCount = Number(entry?.recCount || 0); + const rating = votes > 0 + ? `${Number(entry?.avgStars || 0).toFixed(1)}/${votes}` + : (reviewCount > 0 ? `0.0/${reviewCount}` : '—'); + const isWalletMint = Boolean(entry?.isWalletMint); + return ` +
+ `; + }) + .join(''); + + container.querySelectorAll('.mintDiscoveryAdd').forEach((button) => { + button.addEventListener('click', async () => { + const mintUrl = button.getAttribute('data-mint-url'); + await handleAddDiscoveredMint(mintUrl); + }); + }); + + container.querySelectorAll('.mintDiscoveryRemove').forEach((button) => { + button.addEventListener('click', async () => { + const mintUrl = button.getAttribute('data-mint-url'); + await handleRemoveDiscoveredMint(mintUrl); + }); + }); + } + + function getDiscoverySinceSec() { + return Math.floor(Date.now() / 1000) - MINT_DISCOVERY_WINDOW_SEC; + } + + function dedupeEvents(events) { + const byId = new Map(); + for (const evt of events || []) { + if (!evt || !evt.id) continue; + const prev = byId.get(evt.id); + if (!prev || Number(evt.created_at || 0) > Number(prev.created_at || 0)) { + byId.set(evt.id, evt); + } + } + return Array.from(byId.values()); + } + + function extractFollowedPubkeysFromContactEvents(contactEvents) { + const latest = (contactEvents || []) + .filter((evt) => evt && evt.kind === 3 && evt.pubkey === currentPubkey) + .sort((a, b) => Number(b.created_at || 0) - Number(a.created_at || 0))[0]; + + if (!latest) return []; + + return Array.from( + new Set( + (latest.tags || []) + .filter((tag) => tag[0] === 'p' && tag[1]) + .map((tag) => tag[1]) + ) + ); + } + + function extractRecommendationStars(evt) { + const parseStars = (value) => { + const text = String(value || '').trim(); + if (!text) return null; + + const starEmojiCount = (text.match(/[⭐★]/g) || []).length; + if (starEmojiCount > 0 && starEmojiCount <= 5) return starEmojiCount; + + const direct = Number(text); + if (Number.isFinite(direct) && direct >= 0 && direct <= 5) return direct; + + const slashMatch = text.match(/([0-5](?:\.\d+)?)\s*\/\s*5/i); + if (slashMatch) { + const parsed = Number(slashMatch[1]); + if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 5) return parsed; + } + + const labelledMatch = text.match(/([0-5](?:\.\d+)?)\s*(?:stars?|star|⭐|★)/i); + if (labelledMatch) { + const parsed = Number(labelledMatch[1]); + if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 5) return parsed; + } + + return null; + }; + + const tags = Array.isArray(evt?.tags) ? evt.tags : []; + for (const tag of tags) { + const key = String(tag?.[0] || '').toLowerCase(); + if (!['rating', 'stars', 'star', 'score', 'review', 'rank', 'r'].includes(key)) continue; + + const candidates = [tag?.[1], tag?.[2], tag?.[3]].filter((v) => v !== undefined); + for (const candidate of candidates) { + const parsed = parseStars(candidate); + if (parsed !== null) return parsed; + } + } + + const content = String(evt?.content || '').trim(); + return parseStars(content); + } + + function aggregateMintLists(rawMintListEvents, followedPubkeys = null) { + const followedSet = Array.isArray(followedPubkeys) ? new Set(followedPubkeys) : null; + const latestByAuthor = new Map(); + + for (const evt of rawMintListEvents || []) { + if (!evt || evt.kind !== 10019) continue; + if (followedSet && !followedSet.has(evt.pubkey)) continue; + + const prev = latestByAuthor.get(evt.pubkey); + if (!prev || Number(evt.created_at || 0) > Number(prev.created_at || 0)) { + latestByAuthor.set(evt.pubkey, evt); + } + } + + const mintCounts = new Map(); + for (const evt of latestByAuthor.values()) { + const authorMints = new Set( + (evt.tags || []) + .filter((tag) => tag[0] === 'mint' && tag[1]) + .map((tag) => normalizeMintUrl(tag[1])) + .filter(Boolean) + ); + + for (const mintUrl of authorMints) { + mintCounts.set(mintUrl, (mintCounts.get(mintUrl) || 0) + 1); + } + } + + return Array.from(mintCounts.entries()) + .map(([url, count]) => ({ url, count })) + .sort((a, b) => b.count - a.count || a.url.localeCompare(b.url)); + } + + function aggregateRecommendations(rawRecommendationEvents, followedPubkeys = null) { + const followedSet = Array.isArray(followedPubkeys) ? new Set(followedPubkeys) : null; + const mintCounts = new Map(); + + for (const evt of rawRecommendationEvents || []) { + if (!evt || evt.kind !== 38000) continue; + if (followedSet && !followedSet.has(evt.pubkey)) continue; + + const stars = extractRecommendationStars(evt); + const eventMints = new Set( + (evt.tags || []) + .filter((tag) => tag[0] === 'u' && tag[1]) + .map((tag) => normalizeMintUrl(tag[1])) + .filter(Boolean) + ); + + const contentMatches = String(evt.content || '').match(/https:\/\/[^\s"'<>]+/gi) || []; + for (const candidate of contentMatches) { + const normalized = normalizeMintUrl(candidate); + if (normalized) eventMints.add(normalized); + } + + for (const mintUrl of eventMints) { + const prev = mintCounts.get(mintUrl) || { count: 0, starSum: 0, starVotes: 0 }; + prev.count += 1; + if (stars !== null) { + prev.starSum += stars; + prev.starVotes += 1; + } + mintCounts.set(mintUrl, prev); + } + } + + return Array.from(mintCounts.entries()) + .map(([url, stats]) => { + const starVotes = Number(stats?.starVotes || 0); + const starSum = Number(stats?.starSum || 0); + const avgStars = starVotes > 0 ? (starSum / starVotes) : 0; + return { + url, + count: Number(stats?.count || 0), + starSum, + starVotes, + avgStars + }; + }) + .sort((a, b) => { + if (b.starSum !== a.starSum) return b.starSum - a.starSum; + if (b.avgStars !== a.avgStars) return b.avgStars - a.avgStars; + if (b.count !== a.count) return b.count - a.count; + return a.url.localeCompare(b.url); + }); + } + + function recomputeMintDiscoveryViews() { + const followed = mintDiscoveryCache.followedPubkeys || []; + mintDiscoveryCache.contactMintsByScope = { + all: aggregateMintLists(mintDiscoveryCache.rawMintListEvents, null), + wot: aggregateMintLists(mintDiscoveryCache.rawMintListEvents, followed) + }; + mintDiscoveryCache.recommendationMintsByScope = { + all: aggregateRecommendations(mintDiscoveryCache.rawRecommendationEvents, null), + wot: aggregateRecommendations(mintDiscoveryCache.rawRecommendationEvents, followed) + }; + } + + async function hydrateMintDiscoveryFromCache() { + const since = getDiscoverySinceSec(); + + const [cachedContacts, cachedMintLists, cachedRecommendations] = await Promise.all([ + queryCache({ kinds: [3], authors: [currentPubkey], limit: 1 }).catch(() => []), + queryCache({ kinds: [10019], limit: 4000 }).catch(() => []), + queryCache({ kinds: [38000], limit: 4000 }).catch(() => []) + ]); + + mintDiscoveryCache.rawContactEvents = dedupeEvents((cachedContacts || []).filter((evt) => evt && evt.kind === 3 && evt.pubkey === currentPubkey)); + mintDiscoveryCache.followedPubkeys = extractFollowedPubkeysFromContactEvents(mintDiscoveryCache.rawContactEvents); + mintDiscoveryCache.rawMintListEvents = dedupeEvents((cachedMintLists || []).filter((evt) => evt && evt.kind === 10019 && Number(evt.created_at || 0) >= since)); + mintDiscoveryCache.rawRecommendationEvents = dedupeEvents((cachedRecommendations || []).filter((evt) => evt && evt.kind === 38000 && Number(evt.created_at || 0) >= since)); + mintDiscoveryCache.hydratedFromCache = true; + mintDiscoveryCache.error = null; + + recomputeMintDiscoveryViews(); + + console.log('[cashu.html] mint-discovery:cache-hydrated', { + follows: mintDiscoveryCache.followedPubkeys.length, + mintListEvents: mintDiscoveryCache.rawMintListEvents.length, + recommendationEvents: mintDiscoveryCache.rawRecommendationEvents.length + }); + } + + async function refreshMintDiscoveryFromRelays() { + const since = getDiscoverySinceSec(); + + const [relayContacts, relayMintLists, relayRecommendations] = await Promise.all([ + ndkFetchEvents({ kinds: [3], authors: [currentPubkey], limit: 1 }), + ndkFetchEvents({ kinds: [10019], since, limit: 2000 }), + ndkFetchEvents({ kinds: [38000], since, limit: 2000 }) + ]); + + mintDiscoveryCache.rawContactEvents = dedupeEvents([...(mintDiscoveryCache.rawContactEvents || []), ...(relayContacts || [])]); + mintDiscoveryCache.followedPubkeys = extractFollowedPubkeysFromContactEvents(mintDiscoveryCache.rawContactEvents); + + mintDiscoveryCache.rawMintListEvents = dedupeEvents([...(mintDiscoveryCache.rawMintListEvents || []), ...(relayMintLists || [])]); + mintDiscoveryCache.rawRecommendationEvents = dedupeEvents([...(mintDiscoveryCache.rawRecommendationEvents || []), ...(relayRecommendations || [])]); + mintDiscoveryCache.refreshedFromRelays = true; + mintDiscoveryCache.error = null; + + recomputeMintDiscoveryViews(); + + console.log('[cashu.html] mint-discovery:relay-refresh', { + follows: mintDiscoveryCache.followedPubkeys.length, + mintListEvents: mintDiscoveryCache.rawMintListEvents.length, + recommendationEvents: mintDiscoveryCache.rawRecommendationEvents.length + }); + } + + function startMintDiscoveryInBackground(force = false) { + console.log('[cashu.html] mint-discovery:bg-start:called', { + hasPromise: Boolean(mintDiscoveryPromise), + hasWalletController: Boolean(walletController), + scope: mintDiscoveryScope, + force + }); + + if (!walletController) return; + if (mintDiscoveryPromise && !force) return; + + mintDiscoveryPromise = (async () => { + try { + if (!mintDiscoveryCache.hydratedFromCache || force) { + await hydrateMintDiscoveryFromCache(); + if (isNavOpen) { + renderMintDiscoverySidebar({ skipBackgroundKickoff: true }); + } + } + + await refreshMintDiscoveryFromRelays(); + if (isNavOpen) { + renderMintDiscoverySidebar({ skipBackgroundKickoff: true }); + } + } catch (error) { + console.error('[cashu.html] Mint discovery failed:', error); + mintDiscoveryCache.error = error; + if (isNavOpen) { + renderMintDiscoverySidebar({ skipBackgroundKickoff: true }); + } + } finally { + mintDiscoveryPromise = null; + } + })(); + } + + function mergeMintDiscoveryRows(scope) { + const mintLists = mintDiscoveryCache.contactMintsByScope?.[scope] || []; + const recs = mintDiscoveryCache.recommendationMintsByScope?.[scope] || []; + const walletMints = walletController?.hasWallet() + ? (walletController.getMints() || []).map((url) => normalizeMintUrl(url)).filter(Boolean) + : []; + + const byUrl = new Map(); + + for (const row of mintLists) { + const url = normalizeMintUrl(row?.url); + if (!url) continue; + const prev = byUrl.get(url) || { url, count: 0, recCount: 0, starVotes: 0, starSum: 0, avgStars: 0, isWalletMint: false }; + prev.count += Number(row?.count || 0); + byUrl.set(url, prev); + } + + for (const row of recs) { + const url = normalizeMintUrl(row?.url); + if (!url) continue; + const prev = byUrl.get(url) || { url, count: 0, recCount: 0, starVotes: 0, starSum: 0, avgStars: 0, isWalletMint: false }; + prev.count += Number(row?.count || 0); + prev.recCount += Number(row?.count || 0); + prev.starVotes += Number(row?.starVotes || 0); + prev.starSum += Number(row?.starSum || 0); + prev.avgStars = prev.starVotes > 0 ? prev.starSum / prev.starVotes : 0; + byUrl.set(url, prev); + } + + const walletOrder = new Map(walletMints.map((url, idx) => [url, idx])); + for (const mintUrl of walletMints) { + const prev = byUrl.get(mintUrl) || { url: mintUrl, count: 0, recCount: 0, starVotes: 0, starSum: 0, avgStars: 0, isWalletMint: false }; + prev.isWalletMint = true; + byUrl.set(mintUrl, prev); + } + + const rows = Array.from(byUrl.values()); + rows.sort((a, b) => { + const aWallet = a.isWalletMint ? 1 : 0; + const bWallet = b.isWalletMint ? 1 : 0; + if (aWallet !== bWallet) return bWallet - aWallet; + if (a.isWalletMint && b.isWalletMint) { + return (walletOrder.get(a.url) ?? 99999) - (walletOrder.get(b.url) ?? 99999); + } + + if (mintDiscoverySortMode === 'rating') { + if (b.avgStars !== a.avgStars) return b.avgStars - a.avgStars; + if (b.starVotes !== a.starVotes) return b.starVotes - a.starVotes; + if (b.count !== a.count) return b.count - a.count; + return a.url.localeCompare(b.url); + } + + if (b.count !== a.count) return b.count - a.count; + if (b.avgStars !== a.avgStars) return b.avgStars - a.avgStars; + return a.url.localeCompare(b.url); + }); + + return rows; + } + + function applyMintDiscoveryScopeUi() { + const isAll = mintDiscoveryScope === 'all'; + btnToggleWot?.classList.toggle('active', !isAll); + if (btnToggleWot) { + btnToggleWot.textContent = isAll ? 'Scope: All' : 'Scope: WoT'; + } + + btnSortByCount?.classList.toggle('active', mintDiscoverySortMode === 'count'); + btnSortByRating?.classList.toggle('active', mintDiscoverySortMode === 'rating'); + + if (divContactMintsTitle) { + divContactMintsTitle.textContent = 'Mints'; + } + } + + function bindMintDiscoveryControls() { + applyMintDiscoveryScopeUi(); + + btnSortByCount?.addEventListener('click', () => { + if (mintDiscoverySortMode === 'count') return; + mintDiscoverySortMode = 'count'; + applyMintDiscoveryScopeUi(); + renderMintDiscoverySidebar({ skipBackgroundKickoff: true }); + }); + + btnSortByRating?.addEventListener('click', () => { + if (mintDiscoverySortMode === 'rating') return; + mintDiscoverySortMode = 'rating'; + applyMintDiscoveryScopeUi(); + renderMintDiscoverySidebar({ skipBackgroundKickoff: true }); + }); + + btnToggleWot?.addEventListener('click', () => { + mintDiscoveryScope = mintDiscoveryScope === 'all' ? 'wot' : 'all'; + applyMintDiscoveryScopeUi(); + renderMintDiscoverySidebar({ skipBackgroundKickoff: true }); + }); + } + + function renderMintDiscoverySidebar({ skipBackgroundKickoff = false } = {}) { + console.log('[cashu.html] mint-discovery:sidebar-render:start', { + scope: mintDiscoveryScope, + hydratedFromCache: mintDiscoveryCache.hydratedFromCache, + refreshedFromRelays: mintDiscoveryCache.refreshedFromRelays, + skipBackgroundKickoff + }); + + if (!walletController) { + console.warn('[cashu.html] mint-discovery:sidebar-render:no-wallet-controller'); + if (divContactMintsList) divContactMintsList.textContent = 'Wallet controller not ready yet'; + return; + } + + if (!skipBackgroundKickoff) { + startMintDiscoveryInBackground(); + } + + if (!mintDiscoveryCache.hydratedFromCache && !mintDiscoveryCache.refreshedFromRelays) { + if (divContactMintsList) divContactMintsList.textContent = 'Loading mints from cache…'; + return; + } + + if (mintDiscoveryCache.error && !mintDiscoveryCache.refreshedFromRelays) { + if (divContactMintsList) divContactMintsList.textContent = 'Failed to load mints'; + return; + } + + const mergedMints = mergeMintDiscoveryRows(mintDiscoveryScope); + const viewKey = `${mintDiscoveryScope}:${mintDiscoverySortMode}`; + if (mergedMints.length > 0) { + mintDiscoveryLastNonEmptyByView.set(viewKey, mergedMints.map((row) => ({ ...row }))); + } + + const renderedMints = mergedMints.length > 0 + ? mergedMints + : (mintDiscoveryLastNonEmptyByView.get(viewKey) || []); + + if (mergedMints.length === 0 && renderedMints.length > 0) { + console.warn('[cashu.html] mint-discovery:sidebar-render:using-last-non-empty-view', { + scope: mintDiscoveryScope, + sort: mintDiscoverySortMode, + cachedCount: renderedMints.length + }); + } + + console.log('[cashu.html] mint-discovery:sidebar-render:success', { + scope: mintDiscoveryScope, + mintCount: renderedMints.length + }); + + const emptyMessage = mintDiscoveryScope === 'all' + ? 'No mints found in the last year' + : 'No web-of-trust mints found in the last year'; + + renderMintDiscoveryRows(divContactMintsList, renderedMints, emptyMessage); + } + + async function handleAddDiscoveredMint(mintUrl) { + const url = String(mintUrl || '').trim(); + if (!url) return; + + if (walletController?.hasWallet()) { + try { + setStatus('Adding mint...'); + await walletController.addMint(url); + await refreshAllWalletViews(); + renderMintDiscoverySidebar({ skipBackgroundKickoff: true }); + setStatus('Mint added'); + } catch (error) { + console.error('[cashu.html] Add discovered mint failed:', error); + setStatus(error.message || 'Failed to add mint', true); + } + return; + } + + const existing = parseMintLines(taSetupMints.value); + if (!existing.includes(url)) { + taSetupMints.value = [...existing, url].join('\n'); + } + setStatus('Mint added to setup list'); + } + + async function handleRemoveDiscoveredMint(mintUrl) { + const url = String(mintUrl || '').trim(); + if (!url || !walletController?.hasWallet()) return; + + try { + setStatus('Removing mint...'); + await walletController.removeMint(url); + await refreshAllWalletViews(); + renderMintDiscoverySidebar({ skipBackgroundKickoff: true }); + setStatus('Mint removed'); + } catch (error) { + console.error('[cashu.html] Remove discovered mint failed:', error); + setStatus(error.message || 'Failed to remove mint', true); + } + } + + function renderMintSelectors(mints) { + const options = (mints || []).map((mint) => ``).join(''); + selSendMint.innerHTML = options; + selDepositMint.innerHTML = options; + } + + function switchPanel(panelName) { + const panelMap = { + receive: panelReceive, + send: panelSend, + deposit: panelDeposit, + withdraw: panelWithdraw + }; + + for (const [name, panelEl] of Object.entries(panelMap)) { + panelEl.classList.toggle('active', name === panelName); + } + + btnTabReceive.classList.toggle('active', panelName === 'receive'); + btnTabSend.classList.toggle('active', panelName === 'send'); + btnTabDeposit.classList.toggle('active', panelName === 'deposit'); + btnTabWithdraw.classList.toggle('active', panelName === 'withdraw'); + } + + async function renderBalance() { + if (!walletController?.hasWallet()) return; + + const total = walletController.getBalance(); + const byMint = walletController.getBalanceByMint(); + + divBalanceAmount.textContent = formatSats(total); + + const mintRows = Object.entries(byMint || {}); + if (mintRows.length === 0) { + divMintBalances.innerHTML = 'No mint balances yet'; + } else { + divMintBalances.innerHTML = mintRows + .map(([mint, amount]) => ` +
+ `) + .join(''); + } + } + + async function renderSettings() { + if (!walletController?.hasWallet()) return; + + const fromWallet = walletController.getMints(); + const fromBalance = Object.keys(walletController.getBalanceByMint() || {}); + const mints = Array.from(new Set([...(fromWallet || []), ...(fromBalance || [])])) + .map((mint) => normalizeMintUrl(mint)) + .filter(Boolean); + + renderMintSelectors(mints); + if (selWithdrawMint) { + selWithdrawMint.innerHTML = (mints || []).map((mint) => ``).join(''); + } + + if (isNavOpen) { + renderMintDiscoverySidebar({ skipBackgroundKickoff: true }); + } + } + + async function renderTransactions() { + if (!walletController) return; + + const hasWalletNow = Boolean(walletController?.hasWallet?.()); + const hasWorkerWallet = Boolean(latestWorkerWalletBalance?.hasWallet); + console.log('[cashu.html] renderTransactions invoked', { hasWalletNow, hasWorkerWallet }); + if (!hasWalletNow && !hasWorkerWallet) return; + + const txs = await walletController.getTransactionHistory(); + + if (!hasSeededInitialSendTxState) { + txs.forEach((tx) => { + const txId = String(tx?.id || '').trim(); + if (tx?.direction === 'out' && txId) { + redeemedSendTxIds.add(txId); + } + }); + hasSeededInitialSendTxState = true; + } + + if (!txs.length) { + divTxList.innerHTML = 'No transactions yet'; + return; + } + + divTxList.innerHTML = txs + .map((tx) => { + const direction = tx.direction === 'in' ? '↓' : '↑'; + const txId = String(tx.id || '').trim(); + const isRedeemedOut = tx.direction === 'out' && txId && redeemedSendTxIds.has(txId); + const dirClass = tx.direction === 'in' + ? 'cashuTxIn' + : (isRedeemedOut ? 'cashuTxOutRedeemed' : 'cashuTxOut'); + const amount = Number(tx.amount || 0); + const mint = tx.mint ? ` · ${shortenMintName(tx.mint)}` : ''; + const description = tx.description ? ` · ${tx.description}` : ''; + const redeemedSuffix = isRedeemedOut ? ' · Redeemed' : ''; + const formattedTime = formatTimeAgo(tx.timestamp); + + return ` +
+ `; + }) + .join(''); + } + + async function refreshAllWalletViews() { + await renderBalance(); + await renderSettings(); + await renderTransactions(); + await refreshZapsSection(); + } + + async function ensureWalletStarted({ force = false } = {}) { + if (!walletController?.hasWallet()) return; + + if (walletStartupPromise && !force) { + return walletStartupPromise; + } + + walletStartupPromise = (async () => { + await walletController.startWallet(); + })(); + + try { + await walletStartupPromise; + } finally { + walletStartupPromise = null; + } + } + + function startWalletInBackground() { + if (!walletController?.hasWallet()) return; + + setStatus('Wallet loaded from cache, syncing in background…'); + + ensureWalletStarted() + .then(async () => { + await refreshAllWalletViews(); + setStatus('Wallet loaded'); + }) + .catch((error) => { + console.error('[cashu.html] Background wallet startup failed:', error); + setStatus(error.message || 'Wallet startup delayed', true); + }); + } + + async function activateWalletUiFromWorkerEvent(reason = 'worker-event') { + if (!walletController || workerWalletUiActivated) return; + workerWalletUiActivated = true; + + try { + console.log('[cashu.html] activateWalletUiFromWorkerEvent', { reason }); + await walletController.loadWallet(); + + showWallet(); + setStatus('Wallet detected, loading details...'); + + unsubscribeTx?.(); + unsubscribeTx = walletController.subscribeTransactions(async () => { + await renderBalance(); + await renderTransactions(); + }); + + walletController.onWalletEvent('balance_updated', async () => { + await renderBalance(); + }); + + walletController.onWalletEvent('status_changed', async (payload) => { + if (payload?.status !== 'ready') return; + try { + await walletController.refreshBalance(); + await refreshAllWalletViews(); + } catch (error) { + console.error('[cashu.html] Status-based wallet refresh failed:', error); + } + }); + + await refreshAllWalletViews(); + startWalletInBackground(); + } catch (error) { + workerWalletUiActivated = false; + console.error('[cashu.html] activateWalletUiFromWorkerEvent failed:', error); + } + } + + function bindTabs() { + btnTabReceive.addEventListener('click', () => switchPanel('receive')); + btnTabSend.addEventListener('click', () => switchPanel('send')); + btnTabDeposit.addEventListener('click', () => switchPanel('deposit')); + btnTabWithdraw.addEventListener('click', () => switchPanel('withdraw')); + } + + function bindActions() { + btnCreateWallet.addEventListener('click', async () => { + console.log('[cashu.html] Create wallet button clicked'); + + try { + const mints = parseMintLines(taSetupMints.value); + setStatus('Creating wallet…'); + + const relaysToUse = Array.isArray(currentRelayUrls) ? [...currentRelayUrls] : []; + await walletController.createWallet(mints, relaysToUse); + + unsubscribeTx?.(); + unsubscribeTx = walletController.subscribeTransactions(async () => { + await renderBalance(); + await renderTransactions(); + }); + + showWallet(); + await refreshAllWalletViews(); + setStatus('Wallet created successfully'); + } catch (error) { + console.error('[cashu.html] Create wallet failed:', error); + setStatus(error.message || 'Failed to create wallet', true); + } + }); + + btnReceiveToken.addEventListener('click', async () => { + try { + const token = taReceiveToken.value.trim(); + if (!token) { + setStatus('Paste a token first', true); + return; + } + + setStatus('Receiving token...'); + await walletController.receiveToken(token, 'Manual receive'); + taReceiveToken.value = ''; + + if (divSendTokenOutput.textContent.trim()) { + const shouldDiscardSendOutput = confirmDiscardTransactionOutputs({ send: true }); + if (shouldDiscardSendOutput) { + clearTransactionOutputs({ send: true, deposit: false, withdraw: false }); + } + } + + setStatus('Token received'); + } catch (error) { + console.error('[cashu.html] Receive token failed:', error); + setStatus(error.message || 'Receive failed', true); + } finally { + await refreshAllWalletViews(); + } + }); + + btnSendToken.addEventListener('click', async () => { + try { + const amount = Number(inpSendAmount.value); + const memo = inpSendMemo.value.trim() || 'Cashu token'; + + if (!amount || amount <= 0) { + setStatus('Enter a valid send amount', true); + return; + } + + setStatus('Generating token...'); + const result = await walletController.sendToken(amount, memo, selSendMint.value); + + divSendTokenOutput.textContent = result.token; + divSendTokenOutput.classList.remove('clsHidden'); + renderQrCode(divSendTokenQr, result.token); + if (divSendTokenPreview) { + divSendTokenPreview.classList.remove('clsHidden'); + } + btnCopySendToken.classList.remove('clsHidden'); + if (btnClearSendToken) { + btnClearSendToken.classList.remove('clsHidden'); + } + + await refreshAllWalletViews(); + setStatus('Token generated (kept visible for scanning)'); + } catch (error) { + console.error('[cashu.html] Send token failed:', error); + setStatus(error.message || 'Send failed', true); + } + }); + + btnCopySendToken.addEventListener('click', async () => { + await copyToClipboard(divSendTokenOutput.textContent, 'Token copied'); + }); + + if (btnCloseSendTokenPreview) { + btnCloseSendTokenPreview.addEventListener('click', () => { + clearTransactionOutputs({ send: true, deposit: false, withdraw: false }); + setStatus('Generated token closed'); + }); + } + + if (btnClearSendToken) { + btnClearSendToken.addEventListener('click', () => { + clearTransactionOutputs({ send: true, deposit: false, withdraw: false }); + setStatus('Generated token cleared'); + }); + } + + btnCreateInvoice.addEventListener('click', async () => { + try { + const amount = Number(inpDepositAmount.value); + const mint = selDepositMint.value; + + if (!amount || amount <= 0) { + setStatus('Enter a valid deposit amount', true); + return; + } + + setStatus('Creating Lightning invoice...'); + const { deposit, invoice } = await walletController.createDeposit(amount, mint); + + divDepositInvoice.textContent = invoice; + divDepositInvoice.classList.remove('clsHidden'); + renderQrCode(divDepositInvoiceQr, invoice); + btnCopyInvoice.classList.remove('clsHidden'); + + setStatus('Invoice created, waiting for payment...'); + + walletController.waitForDeposit(deposit) + .then(async () => { + await refreshAllWalletViews(); + const shouldDiscardDepositOutput = confirmDiscardTransactionOutputs({ deposit: true }); + if (shouldDiscardDepositOutput) { + clearTransactionOutputs({ send: false, deposit: true, withdraw: false }); + } + setStatus('Deposit confirmed'); + }) + .catch((error) => { + console.error('[cashu.html] Deposit wait failed:', error); + setStatus(error.message || 'Deposit failed', true); + }); + } catch (error) { + console.error('[cashu.html] Create invoice failed:', error); + setStatus(error.message || 'Deposit setup failed', true); + } + }); + + btnCopyInvoice.addEventListener('click', async () => { + await copyToClipboard(divDepositInvoice.textContent, 'Invoice copied'); + }); + + btnPayInvoice.addEventListener('click', async () => { + try { + const invoice = taWithdrawInvoice.value.trim(); + const mint = selWithdrawMint.value; + if (!invoice) { + setStatus('Paste a Lightning invoice to withdraw', true); + return; + } + + setStatus('Paying Lightning invoice...'); + await walletController.payInvoice(invoice, mint); + taWithdrawInvoice.value = ''; + await refreshAllWalletViews(); + const shouldDiscardWithdrawOutput = confirmDiscardTransactionOutputs({ withdraw: true }); + if (shouldDiscardWithdrawOutput) { + clearTransactionOutputs({ send: false, deposit: false, withdraw: true }); + } + setStatus('Lightning payment sent'); + } catch (error) { + console.error('[cashu.html] Lightning withdrawal failed:', error); + setStatus(error.message || 'Lightning withdrawal failed', true); + } + }); + + btnAddMint.addEventListener('click', async () => { + const mintUrl = inpAddMint.value.trim(); + if (!mintUrl) { + setStatus('Enter a mint URL to add', true); + return; + } + + await handleAddDiscoveredMint(mintUrl); + if (!divWalletContent.classList.contains('clsHidden')) { + inpAddMint.value = ''; + } + }); + + btnCheckProofs.addEventListener('click', async () => { + try { + if (btnCheckProofs.disabled) { + const disabledMessage = 'Wallet still syncing proofs, wait for sync to complete before checking'; + setStatus(disabledMessage, true); + setCheckProofsStatus(disabledMessage, true); + return; + } + + setStatus('Checking proofs with mints...'); + setCheckProofsStatus('Checking proofs with mints...'); + await ensureWalletStarted(); + const result = await walletController.checkProofs(); + await refreshAllWalletViews(); + + const proofCheck = result?.proofCheck; + if (proofCheck) { + const mintsChecked = Number(proofCheck?.mintsCheckedBefore || 0); + const checked = Number(proofCheck?.proofsChecked || 0); + const removed = Number(proofCheck?.proofsRemoved || 0); + const after = Number(proofCheck?.balanceAfter || 0); + const delta = Number(proofCheck?.balanceDelta || 0); + const source = String(proofCheck?.sync?.source || proofCheck?.balanceSource || 'unknown'); + const warning = proofCheck?.warning ? ` ⚠ ${proofCheck.warning}` : ''; + const deltaText = delta === 0 ? 'Δ0' : `Δ${delta > 0 ? '+' : ''}${delta}`; + const summary = `Proof check: ${checked} proofs / ${mintsChecked} mints, removed ${removed}, balance ${formatSats(after)} (${deltaText}, ${source})${warning}`; + setStatus(summary); + setCheckProofsStatus(summary); + } else { + const doneMessage = 'Proof check complete'; + setStatus(doneMessage); + setCheckProofsStatus(doneMessage); + } + } catch (error) { + console.error('[cashu.html] Check proofs failed:', error); + const errorMessage = error.message || 'Proof check failed'; + setStatus(errorMessage, true); + setCheckProofsStatus(errorMessage, true); + } + }); + + btnRefreshWallet.addEventListener('click', async () => { + try { + setStatus('Refreshing wallet state...'); + await ensureWalletStarted({ force: true }); + await refreshAllWalletViews(); + setStatus('Wallet refreshed'); + } catch (error) { + console.error('[cashu.html] Wallet refresh failed:', error); + setStatus(error.message || 'Wallet refresh failed', true); + } + }); + + btnSaveZapsSettings?.addEventListener('click', async () => { + try { + const amount = Number(inpZapDefaultAmount?.value || 0); + const comment = String(taZapDefaultComment?.value || '').trim(); + const preferredMethod = normalizePreferredZapMethod(selZapPreferredMethod?.value || 'auto'); + + if (!Number.isFinite(amount) || amount <= 0) { + setZapsStatus('Enter a valid default zap amount (sats)', true); + return; + } + + const allowlistInput = parseNutzapReceiveAllowlistInput(taNutzapReceiveMints?.value || ''); + if (allowlistInput.invalid.length > 0) { + setZapsStatus(`Invalid mint URL(s): ${allowlistInput.invalid[0]}`, true); + return; + } + + await patchUserSettings({ + zaps: { + defaultAmountSats: Math.floor(amount), + defaultComment: comment, + preferredMethod, + receiveMintAllowlist: allowlistInput.normalized + } + }); + + zapSettings = { + defaultAmountSats: Math.floor(amount), + defaultComment: comment, + preferredMethod, + receiveMintAllowlist: allowlistInput.normalized + }; + applyZapSettingsForm(); + renderNutzapStatusFromLocalState(); + setZapsStatus('Zap defaults saved'); + } catch (error) { + console.error('[cashu.html] Save zaps settings failed:', error); + setZapsStatus(error?.message || 'Failed to save zap defaults', true); + } + }); + + btnResetZapsSettings?.addEventListener('click', async () => { + try { + const reset = { + defaultAmountSats: 21, + defaultComment: '', + preferredMethod: 'auto', + receiveMintAllowlist: [] + }; + + await patchUserSettings({ zaps: reset }); + zapSettings = { ...reset }; + applyZapSettingsForm(); + setZapsStatus('Zap defaults reset'); + } catch (error) { + console.error('[cashu.html] Reset zaps settings failed:', error); + setZapsStatus(error?.message || 'Failed to reset zap defaults', true); + } + }); + + btnUseWalletMintsForNutzap?.addEventListener('click', () => { + const walletMints = walletController?.hasWallet?.() + ? (walletController.getMints() || []).map((url) => normalizeMintUrl(url)).filter(Boolean) + : []; + if (taNutzapReceiveMints) { + taNutzapReceiveMints.value = walletMints.join('\n'); + } + setZapsStatus(walletMints.length > 0 ? 'Loaded wallet mints into allowlist editor' : 'No wallet mints available yet', walletMints.length === 0); + }); + + btnRepublishMintList?.addEventListener('click', async () => { + try { + if (!walletController?.hasWallet?.()) { + setZapsStatus('Create a wallet before publishing nutzap mint list', true); + return; + } + + const allowlistInput = parseNutzapReceiveAllowlistInput(taNutzapReceiveMints?.value || ''); + if (allowlistInput.invalid.length > 0) { + setZapsStatus(`Invalid mint URL(s): ${allowlistInput.invalid[0]}`, true); + return; + } + + setZapsStatus('Publishing kind 10019 mint list...'); + await ensureWalletStarted(); + + await patchUserSettings({ + zaps: { + receiveMintAllowlist: allowlistInput.normalized + } + }); + + zapSettings = { + ...zapSettings, + receiveMintAllowlist: allowlistInput.normalized + }; + + const relaysToUse = Array.isArray(currentRelayUrls) ? [...currentRelayUrls] : []; + const result = await walletController.publishMintList(relaysToUse, allowlistInput.normalized); + latestMintListStatus = { + mints: Array.isArray(result?.mints) ? result.mints : [], + relays: Array.isArray(result?.relays) ? result.relays : [], + p2pk: result?.p2pk || null, + eventId: result?.eventId || null, + publishedRelayCount: Number(result?.publishedRelayCount || 0), + updatedAtSec: Math.floor(Date.now() / 1000) + }; + + renderNutzapStatusFromLocalState(); + const relayCount = Number(result?.publishedRelayCount || 0); + const mintScopeText = allowlistInput.normalized.length > 0 + ? `${allowlistInput.normalized.length} selected mints` + : 'all wallet mints'; + setZapsStatus(`Published nutzap mint list (${relayCount} relays, ${mintScopeText})`); + } catch (error) { + console.error('[cashu.html] Republish mint list failed:', error); + setZapsStatus(error?.message || 'Failed to publish nutzap mint list', true); + } + }); + } + + (async function main() { + console.log('[cashu.html] Starting initialization...'); + + try { + initHamburgerMenu(); + + const divSvgHam = document.getElementById('divSvgHam'); + if (divSvgHam) { + divSvgHam.addEventListener('click', toggleNav); + } + + const themeToggleButton = document.getElementById('themeToggleButton'); + const logoutButton = document.getElementById('logoutButton'); + + if (themeToggleButton) { + themeToggleButton.addEventListener('click', () => { + isDarkMode = !isDarkMode; + localStorage.setItem('theme', isDarkMode ? 'dark' : 'light'); + localStorage.setItem('sidenavWasOpen', isNavOpen ? 'true' : 'false'); + window.location.reload(); + }); + } + + if (logoutButton) { + logoutButton.addEventListener('click', async () => { + try { + await Logout(); + } catch (error) { + console.error('[cashu.html] Logout failed:', error); + } + }); + } + + await initNDKPage(); + currentPubkey = await getPubkey(); + await injectHeaderAvatar(currentPubkey); + + unsubscribeUserSettings?.(); + unsubscribeUserSettings = onUserSettings((settings) => { + zapSettings = normalizeZapSettings(settings || {}); + zapSettingsLoaded = true; + applyZapSettingsForm(); + }); + + await hydrateZapSettingsFromStore({ silent: true }); + + window.addEventListener('ndkWalletBalance', (event) => { + const payload = event?.detail || {}; + latestWorkerWalletBalance = payload; + + if (payload?.balanceSource === 'full' && payload?.isApproximate === false) { + hasFullWalletBalanceReady = true; + } + + if (payload?.hasWallet) { + walletStatusReady = true; + showWallet(); + void activateWalletUiFromWorkerEvent('ndkWalletBalance'); + } + + renderImmediateWorkerBalance(payload); + updateCheckProofsButtonState(); + }); + + window.addEventListener('ndkWalletStatus', (event) => { + const payload = event?.detail || {}; + walletStatusReady = payload?.status === 'ready' || payload?.hasWallet === true; + if (payload?.hasWallet) { + showWallet(); + void activateWalletUiFromWorkerEvent('ndkWalletStatus'); + } + updateCheckProofsButtonState(); + }); + + window.addEventListener('ndkWalletSendRedeemed', async (event) => { + const payload = event?.detail || {}; + console.log('[cashu.html] ndkWalletSendRedeemed event received', payload); + const amount = Number(payload?.amount || 0); + const mint = String(payload?.mint || '').trim(); + const memo = String(payload?.memo || '').trim(); + const txId = String(payload?.txId || '').trim(); + const mintLabel = mint ? ` on ${shortenMintName(mint)}` : ''; + const memoLabel = memo ? ` (${memo})` : ''; + + if (txId) { + redeemedSendTxIds.add(txId); + } + + clearTransactionOutputs({ send: true, deposit: false, withdraw: false }); + await renderTransactions(); + setStatus(`✓ Sent token redeemed${mintLabel}: ${formatSats(amount)}${memoLabel}`); + }); + + window.addEventListener('ndkWalletTransaction', async (event) => { + const payload = event?.detail || {}; + console.log('[cashu.html] ndkWalletTransaction event received', payload); + await renderBalance(); + await renderTransactions(); + }); + + initFooterRelayStatus(); + initSidenavRelaySection(); + await initBlossomSection(); + initAiSectionWithLocalConfig(); + await UpdateFooter(); + + window.addEventListener('ndkRelayActivity', (event) => { + const { relayUrl, activity } = event.detail; + setRelayActivityState(relayUrl, activity); + }); + + window.addEventListener('message', (event) => { + if (event.data && event.data.type === 'relayActivity') { + const { relayUrl, activity } = event.data; + setRelayActivityState(relayUrl, activity); + } + }); + + const relayData = await getRelayData(); + const relayUrls = (relayData || []).map((relay) => relay.url).filter(Boolean); + currentRelayUrls = [...relayUrls]; + + walletController = await createCashuWalletController({ + pubkey: currentPubkey, + relayUrls + }); + await walletController.loadWallet(); + + console.log('[cashu.html] mint-discovery:wallet-controller-ready', { + hasDiscoverMintsFromContacts: typeof walletController?.discoverMintsFromContacts, + hasDiscoverMintRecommendations: typeof walletController?.discoverMintRecommendations + }); + + bindTabs(); + bindActions(); + bindMintDiscoveryControls(); + updateCheckProofsButtonState(); + + if (walletController.hasWallet()) { + console.log('[cashu.html] startup branch: walletController.hasWallet() === true'); + showWallet(); + + unsubscribeTx?.(); + unsubscribeTx = walletController.subscribeTransactions(async () => { + await renderBalance(); + await renderTransactions(); + }); + + walletController.onWalletEvent('balance_updated', async () => { + await renderBalance(); + }); + + walletController.onWalletEvent('status_changed', async (payload) => { + if (payload?.status !== 'ready') return; + try { + await walletController.refreshBalance(); + await refreshAllWalletViews(); + } catch (error) { + console.error('[cashu.html] Status-based wallet refresh failed:', error); + } + }); + + await refreshAllWalletViews(); + startWalletInBackground(); + } else if (latestWorkerWalletBalance?.hasWallet) { + console.log('[cashu.html] startup branch: latestWorkerWalletBalance.hasWallet === true'); + showWallet(); + renderImmediateWorkerBalance(latestWorkerWalletBalance); + setStatus('Wallet detected, loading details...'); + + unsubscribeTx?.(); + unsubscribeTx = walletController.subscribeTransactions(async () => { + await renderBalance(); + await renderTransactions(); + }); + + walletController.onWalletEvent('balance_updated', async () => { + await renderBalance(); + }); + + walletController.onWalletEvent('status_changed', async (payload) => { + if (payload?.status !== 'ready') return; + try { + await walletController.refreshBalance(); + await refreshAllWalletViews(); + } catch (error) { + console.error('[cashu.html] Status-based wallet refresh failed:', error); + } + }); + + await renderTransactions(); + startWalletInBackground(); + } else { + console.log('[cashu.html] startup branch: no wallet detected'); + showSetup(); + setStatus('No wallet yet — add one or more mints to create one'); + } + + const sidenavWasOpen = localStorage.getItem('sidenavWasOpen'); + if (sidenavWasOpen === 'true') { + localStorage.removeItem('sidenavWasOpen'); + openNav(); + } + + updateIntervalId = setInterval(UpdateFooter, 1000); + startTimestampUpdater(); + await updateVersionDisplay(); + + console.log('[cashu.html] mint-discovery:post-init-scheduled'); + setTimeout(() => { + console.log('[cashu.html] mint-discovery:post-init-trigger', { + hasWalletController: Boolean(walletController), + hasPromise: Boolean(mintDiscoveryPromise) + }); + startMintDiscoveryInBackground(); + }, 0); + + console.log('[cashu.html] Initialization complete'); + } catch (error) { + unsubscribeUserSettings?.(); + unsubscribeUserSettings = null; + console.error('[cashu.html] Initialization failed:', error); + setStatus(error.message || 'Initialization failed', true); + divBody.innerHTML = `
`; + } + })(); + \ No newline at end of file diff --git a/build/.cashu-module-check.mjs b/build/.cashu-module-check.mjs new file mode 100644 index 0000000..78ba069 --- /dev/null +++ b/build/.cashu-module-check.mjs @@ -0,0 +1,1661 @@ + import { initNDKPage, getPubkey, injectHeaderAvatar, disconnect, getVersion, updateVersionDisplay, getRelayData, ndkFetchEvents, queryCache } from './js/init-ndk.mjs'; + import { HamburgerMorphing } from './hamburger_morphing/hamburger.mjs'; + import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs'; + import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs'; + + import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs'; +import { createCashuWalletController } from './js/cashu-wallet.mjs?v=mint-discovery-3'; + + const versionInfo = await getVersion(); + const VERSION = versionInfo.VERSION; + console.log(`[cashu.html ${VERSION}] Loading...`); + + let updateIntervalId = null; + let currentPubkey = null; + + let hamburgerInstance = null; + let isNavOpen = false; + + let logoutHamburger = null; + let themeToggleHamburger = null; + let isDarkMode = false; + + let walletController = null; + let unsubscribeTx = null; + let currentRelayUrls = []; + let latestWorkerWalletBalance = null; + + let mintDiscoveryPromise = null; + let walletStartupPromise = null; + let mintDiscoveryCache = { + contactMintsByScope: { all: [], wot: [] }, + recommendationMintsByScope: { all: [], wot: [] }, + followedPubkeys: [], + rawContactEvents: [], + rawMintListEvents: [], + rawRecommendationEvents: [], + error: null, + scope: 'all', + hydratedFromCache: false, + refreshedFromRelays: false + }; + + const divBody = document.getElementById('divBody'); + const divSideNav = document.getElementById('divSideNav'); + const divSideNavBody = document.getElementById('divSideNavBody'); + const divFooterCenter = document.getElementById('divFooterCenter'); + const divFooterRight = document.getElementById('divFooterRight'); + + const divWalletStatus = document.getElementById('divWalletStatus'); + const divSetupPanel = document.getElementById('divSetupPanel'); + const divSetupStatus = document.getElementById('divSetupStatus'); + const taSetupMints = document.getElementById('taSetupMints'); + const btnCreateWallet = document.getElementById('btnCreateWallet'); + + const divWalletContent = document.getElementById('divWalletContent'); + const divBalanceAmount = document.getElementById('divBalanceAmount'); + const divMintBalances = document.getElementById('divMintBalances'); + + const btnTabReceive = document.getElementById('btnTabReceive'); + const btnTabSend = document.getElementById('btnTabSend'); + const btnTabDeposit = document.getElementById('btnTabDeposit'); + const btnTabWithdraw = document.getElementById('btnTabWithdraw'); + + const panelReceive = document.getElementById('panelReceive'); + const panelSend = document.getElementById('panelSend'); + const panelDeposit = document.getElementById('panelDeposit'); + const panelWithdraw = document.getElementById('panelWithdraw'); + + const taReceiveToken = document.getElementById('taReceiveToken'); + const btnReceiveToken = document.getElementById('btnReceiveToken'); + + const inpSendAmount = document.getElementById('inpSendAmount'); + const inpSendMemo = document.getElementById('inpSendMemo'); + const selSendMint = document.getElementById('selSendMint'); + const btnSendToken = document.getElementById('btnSendToken'); + const divSendTokenOutput = document.getElementById('divSendTokenOutput'); + const divSendTokenPreview = document.getElementById('divSendTokenPreview'); + const divSendTokenQr = document.getElementById('divSendTokenQr'); + const btnCloseSendTokenPreview = document.getElementById('btnCloseSendTokenPreview'); + const btnCopySendToken = document.getElementById('btnCopySendToken'); + const btnClearSendToken = document.getElementById('btnClearSendToken'); + + const inpDepositAmount = document.getElementById('inpDepositAmount'); + const selDepositMint = document.getElementById('selDepositMint'); + const btnCreateInvoice = document.getElementById('btnCreateInvoice'); + const divDepositInvoice = document.getElementById('divDepositInvoice'); + const divDepositInvoiceQr = document.getElementById('divDepositInvoiceQr'); + const btnCopyInvoice = document.getElementById('btnCopyInvoice'); + + const selWithdrawMint = document.getElementById('selWithdrawMint'); + const taWithdrawInvoice = document.getElementById('taWithdrawInvoice'); + const btnPayInvoice = document.getElementById('btnPayInvoice'); + + const divTxList = document.getElementById('divTxList'); + + const inpAddMint = document.getElementById('inpAddMint'); + const btnAddMint = document.getElementById('btnAddMint'); + const btnCheckProofs = document.getElementById('btnCheckProofs'); + const divCheckProofsResult = document.getElementById('divCheckProofsResult'); + const btnRefreshWallet = document.getElementById('btnRefreshWallet'); + + const divContactMintsTitle = document.getElementById('divContactMintsTitle'); + const divContactMintsList = document.getElementById('divContactMintsList'); + const btnSortByCount = document.getElementById('btnSortByCount'); + const btnSortByRating = document.getElementById('btnSortByRating'); + const btnToggleWot = document.getElementById('btnToggleWot'); + + const MINT_DISCOVERY_WINDOW_SEC = 365 * 24 * 60 * 60; + let mintDiscoveryScope = 'all'; + let mintDiscoverySortMode = 'count'; + const mintDiscoveryLastNonEmptyByView = new Map(); + + let walletStatusReady = false; + let hasFullWalletBalanceReady = false; + let txTimeAgoIntervalId = null; + let workerWalletUiActivated = false; + const redeemedSendTxIds = new Set(); + + function setStatus(message, isError = false) { + divWalletStatus.textContent = message; + divWalletStatus.classList.toggle('error', Boolean(isError)); + + if (divSetupStatus) { + divSetupStatus.textContent = message; + divSetupStatus.classList.toggle('error', Boolean(isError)); + } + } + + function formatSats(amount, { approximate = false } = {}) { + const value = Number(amount || 0); + const prefix = approximate ? '~' : ''; + return `${prefix}${value.toLocaleString()} sats`; + } + + function renderImmediateWorkerBalance(payload = {}) { + const total = Number(payload?.balance || 0); + const byMint = (payload?.balancesByMint && typeof payload?.balancesByMint === 'object') + ? payload.balancesByMint + : {}; + + const isApproximate = payload?.isApproximate === true || payload?.balanceSource === 'lightweight'; + divBalanceAmount.textContent = formatSats(total, { approximate: isApproximate }); + + const mintRows = Object.entries(byMint || {}); + if (mintRows.length === 0) { + divMintBalances.innerHTML = total > 0 ? '' : 'No mint balances yet'; + } else { + divMintBalances.innerHTML = mintRows + .map(([mint, amount]) => ` +
+ `) + .join(''); + } + + const payloadMints = Object.keys(byMint || {}) + .map((mint) => normalizeMintUrl(mint)) + .filter(Boolean); + + if (payloadMints.length > 0) { + renderMintSelectors(payloadMints); + if (selWithdrawMint) { + selWithdrawMint.innerHTML = payloadMints + .map((mint) => ``) + .join(''); + } + } + } + + function formatTimeAgo(timestamp, nowSec = Math.floor(Date.now() / 1000)) { + const ts = Number(timestamp || 0); + const diff = Math.max(0, nowSec - ts); + + if (diff < 60) { + return { + text: `${diff}s ago`, + nextUpdateAt: nowSec + 1 + }; + } + + if (diff < 3600) { + const remainder = diff % 60; + return { + text: `${Math.floor(diff / 60)}m ago`, + nextUpdateAt: nowSec + (remainder === 0 ? 60 : 60 - remainder) + }; + } + + if (diff < 86400) { + const remainder = diff % 3600; + return { + text: `${Math.floor(diff / 3600)}h ago`, + nextUpdateAt: nowSec + (remainder === 0 ? 3600 : 3600 - remainder) + }; + } + + const remainder = diff % 86400; + return { + text: `${Math.floor(diff / 86400)}d ago`, + nextUpdateAt: nowSec + (remainder === 0 ? 86400 : 86400 - remainder) + }; + } + + function shortenMintName(mintUrl) { + const normalized = normalizeMintUrl(mintUrl) || String(mintUrl || ''); + if (!normalized) return ''; + + try { + const host = new URL(normalized).hostname || normalized; + return host.replace(/^www\./i, ''); + } catch (_error) { + return normalized + .replace(/^https?:\/\//i, '') + .replace(/\/$/, ''); + } + } + + function clearTransactionOutputs({ send = true, deposit = true, withdraw = true } = {}) { + if (send) { + divSendTokenOutput.textContent = ''; + divSendTokenOutput.classList.add('clsHidden'); + renderQrCode(divSendTokenQr, ''); + if (divSendTokenPreview) { + divSendTokenPreview.classList.add('clsHidden'); + } + btnCopySendToken.classList.add('clsHidden'); + if (btnClearSendToken) { + btnClearSendToken.classList.add('clsHidden'); + } + } + + if (deposit) { + divDepositInvoice.textContent = ''; + divDepositInvoice.classList.add('clsHidden'); + renderQrCode(divDepositInvoiceQr, ''); + btnCopyInvoice.classList.add('clsHidden'); + } + + if (withdraw && taWithdrawInvoice) { + taWithdrawInvoice.value = ''; + } + } + + function confirmDiscardTransactionOutputs({ send = false, deposit = false, withdraw = false } = {}) { + const parts = []; + if (send) parts.push('generated token QR/code'); + if (deposit) parts.push('deposit invoice QR/code'); + if (withdraw) parts.push('withdraw invoice input'); + + if (parts.length === 0) return true; + + return window.confirm(`Transaction complete. Discard ${parts.join(' and ')}?`); + } + + function startTimestampUpdater() { + if (txTimeAgoIntervalId) return; + + txTimeAgoIntervalId = setInterval(() => { + const nowSec = Math.floor(Date.now() / 1000); + const nodes = divTxList.querySelectorAll('.cashuTxTimeAgo[data-timestamp]'); + + for (const node of nodes) { + const timestamp = Number(node.dataset.timestamp || 0); + const nextUpdateAt = Number(node.dataset.nextUpdateAt || 0); + if (nextUpdateAt > nowSec) continue; + + const formatted = formatTimeAgo(timestamp, nowSec); + node.textContent = formatted.text; + node.dataset.nextUpdateAt = String(formatted.nextUpdateAt); + } + }, 1000); + } + + async function copyToClipboard(value, successMessage) { + try { + await navigator.clipboard.writeText(String(value || '')); + setStatus(successMessage); + } catch (error) { + console.error('[cashu.html] Clipboard error:', error); + setStatus('Failed to copy to clipboard', true); + } + } + + function renderQrCode(container, text) { + if (!container) return; + + const raw = String(text || '').trim(); + if (!raw) { + container.innerHTML = ''; + container.classList.add('clsHidden'); + return; + } + + const isLightningInvoice = /^ln[a-z0-9]+/i.test(raw); + const value = isLightningInvoice ? raw.toUpperCase() : raw; + const margin = isLightningInvoice ? 2 : 1; + + if (typeof qrcode !== 'function') { + console.warn('[cashu.html] qrcode-generator not available'); + container.innerHTML = ''; + container.classList.add('clsHidden'); + return; + } + + try { + container.innerHTML = ''; + + // Use byte mode for cashuB tokens so long payloads are encoded correctly. + const qr = qrcode(0, 'L'); + qr.addData(value, 'Byte'); + qr.make(); + + const svgMarkup = qr.createSvgTag(4, margin); + container.innerHTML = svgMarkup; + + const svg = container.querySelector('svg'); + if (svg) { + svg.removeAttribute('width'); + svg.removeAttribute('height'); + } + + container.classList.remove('clsHidden'); + } catch (error) { + console.error('[cashu.html] QR render failed:', error); + container.innerHTML = ''; + container.classList.add('clsHidden'); + } + } + + function initHamburgerMenu() { + hamburgerInstance = new HamburgerMorphing('#divSvgHam', { + foreground: 'var(--primary-color)', + background: 'var(--secondary-color)', + hover: 'var(--accent-color)' + }); + hamburgerInstance.animateTo('burger'); + } + + function openNav() { + divSideNav.style.zIndex = 3; + divSideNav.style.width = 'clamp(400px, 50vw, 600px)'; + isNavOpen = true; + + if (hamburgerInstance) { + hamburgerInstance.animateTo('arrow_left'); + } + + renderMintDiscoverySidebar(); + + if (!logoutHamburger) { + logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', { + size: 24, + foreground: 'var(--primary-color)', + background: 'var(--secondary-color)', + hover: 'var(--accent-color)' + }); + logoutHamburger.animateTo('x'); + } + + if (!themeToggleHamburger) { + themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', { + size: 24, + foreground: 'var(--primary-color)', + background: 'var(--secondary-color)', + hover: 'var(--accent-color)' + }); + + const savedTheme = localStorage.getItem('theme'); + isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode'); + themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle'); + } + } + + function closeNav() { + divSideNav.style.width = '0vw'; + divSideNav.style.zIndex = -1; + isNavOpen = false; + + if (hamburgerInstance) { + hamburgerInstance.animateTo('burger'); + } + } + + function toggleNav() { + if (isNavOpen) closeNav(); + else openNav(); + } + + const UpdateFooter = async () => { + try { + await updateFooterRelayStatus(); + await updateSidenavRelaySection(); + await updateBlossomSection(); + divFooterCenter.innerHTML = ''; + divFooterRight.innerHTML = ''; + } catch (error) { + console.error('[cashu.html] Error updating footer:', error); + } + }; + + const Logout = async () => { + console.log('[cashu.html] Starting logout process...'); + + if (updateIntervalId) { + clearInterval(updateIntervalId); + updateIntervalId = null; + } + + try { + await walletController?.shutdown?.(); + } catch (_error) { + // no-op + } + + disconnect(); + + if (window.NOSTR_LOGIN_LITE && window.NOSTR_LOGIN_LITE.logout) { + await window.NOSTR_LOGIN_LITE.logout(); + } + + localStorage.clear(); + sessionStorage.clear(); + + if (window.indexedDB) { + const databases = await window.indexedDB.databases(); + for (const db of databases) { + if (db.name) window.indexedDB.deleteDatabase(db.name); + } + } + + location.reload(true); + }; + + function showSetup() { + divSetupPanel.classList.remove('clsHidden'); + divWalletContent.classList.add('clsHidden'); + divWalletStatus.classList.add('clsHidden'); + + walletStatusReady = false; + hasFullWalletBalanceReady = false; + updateCheckProofsButtonState(); + + if (!taSetupMints.value.trim()) { + taSetupMints.value = walletController?.DEFAULT_MINTS?.join('\n') || ''; + } + } + + function updateCheckProofsButtonState() { + const isReady = walletStatusReady; + if (btnCheckProofs) { + btnCheckProofs.disabled = !isReady; + btnCheckProofs.title = isReady + ? 'Validate local proofs against each mint and remove spent proofs' + : 'Waiting for wallet startup to finish before proof checking'; + } + } + + function showWallet() { + divSetupPanel.classList.add('clsHidden'); + divWalletContent.classList.remove('clsHidden'); + divWalletStatus.classList.remove('clsHidden'); + setStatus('Wallet ready'); + updateCheckProofsButtonState(); + } + + function setCheckProofsStatus(message, isError = false) { + if (!divCheckProofsResult) return; + divCheckProofsResult.textContent = String(message || ''); + divCheckProofsResult.classList.toggle('error', Boolean(isError)); + } + + function parseMintLines(text) { + return String(text || '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + } + + function normalizeMintUrl(value) { + const raw = String(value || '').trim(); + if (!raw) return ''; + + let parsed; + try { + parsed = new URL(raw); + } catch (_error) { + return ''; + } + + if (parsed.protocol !== 'https:') return ''; + if (!parsed.hostname) return ''; + + return parsed.toString().replace(/\/+$/, ''); + } + + function flattenEventsByRelay(eventsByRelay) { + const out = []; + for (const events of Object.values(eventsByRelay || {})) { + if (!Array.isArray(events)) continue; + out.push(...events); + } + return out; + } + + function escapeHtml(value) { + const div = document.createElement('div'); + div.textContent = String(value || ''); + return div.innerHTML; + } + + function renderMintDiscoveryRows(container, mints, emptyMessage) { + if (!container) return; + + if (!Array.isArray(mints) || mints.length === 0) { + container.textContent = emptyMessage; + return; + } + + container.innerHTML = mints + .map((entry) => { + const url = String(entry?.url || ''); + const safeUrl = escapeHtml(url); + const mentions = Number(entry?.count || 0); + const votes = Number(entry?.starVotes || 0); + const reviewCount = Number(entry?.recCount || 0); + const rating = votes > 0 + ? `${Number(entry?.avgStars || 0).toFixed(1)}/${votes}` + : (reviewCount > 0 ? `0.0/${reviewCount}` : '—'); + const isWalletMint = Boolean(entry?.isWalletMint); + return ` +
+ `; + }) + .join(''); + + container.querySelectorAll('.mintDiscoveryAdd').forEach((button) => { + button.addEventListener('click', async () => { + const mintUrl = button.getAttribute('data-mint-url'); + await handleAddDiscoveredMint(mintUrl); + }); + }); + + container.querySelectorAll('.mintDiscoveryRemove').forEach((button) => { + button.addEventListener('click', async () => { + const mintUrl = button.getAttribute('data-mint-url'); + await handleRemoveDiscoveredMint(mintUrl); + }); + }); + } + + function getDiscoverySinceSec() { + return Math.floor(Date.now() / 1000) - MINT_DISCOVERY_WINDOW_SEC; + } + + function dedupeEvents(events) { + const byId = new Map(); + for (const evt of events || []) { + if (!evt || !evt.id) continue; + const prev = byId.get(evt.id); + if (!prev || Number(evt.created_at || 0) > Number(prev.created_at || 0)) { + byId.set(evt.id, evt); + } + } + return Array.from(byId.values()); + } + + function extractFollowedPubkeysFromContactEvents(contactEvents) { + const latest = (contactEvents || []) + .filter((evt) => evt && evt.kind === 3 && evt.pubkey === currentPubkey) + .sort((a, b) => Number(b.created_at || 0) - Number(a.created_at || 0))[0]; + + if (!latest) return []; + + return Array.from( + new Set( + (latest.tags || []) + .filter((tag) => tag[0] === 'p' && tag[1]) + .map((tag) => tag[1]) + ) + ); + } + + function extractRecommendationStars(evt) { + const parseStars = (value) => { + const text = String(value || '').trim(); + if (!text) return null; + + const starEmojiCount = (text.match(/[⭐★]/g) || []).length; + if (starEmojiCount > 0 && starEmojiCount <= 5) return starEmojiCount; + + const direct = Number(text); + if (Number.isFinite(direct) && direct >= 0 && direct <= 5) return direct; + + const slashMatch = text.match(/([0-5](?:\.\d+)?)\s*\/\s*5/i); + if (slashMatch) { + const parsed = Number(slashMatch[1]); + if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 5) return parsed; + } + + const labelledMatch = text.match(/([0-5](?:\.\d+)?)\s*(?:stars?|star|⭐|★)/i); + if (labelledMatch) { + const parsed = Number(labelledMatch[1]); + if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 5) return parsed; + } + + return null; + }; + + const tags = Array.isArray(evt?.tags) ? evt.tags : []; + for (const tag of tags) { + const key = String(tag?.[0] || '').toLowerCase(); + if (!['rating', 'stars', 'star', 'score', 'review', 'rank', 'r'].includes(key)) continue; + + const candidates = [tag?.[1], tag?.[2], tag?.[3]].filter((v) => v !== undefined); + for (const candidate of candidates) { + const parsed = parseStars(candidate); + if (parsed !== null) return parsed; + } + } + + const content = String(evt?.content || '').trim(); + return parseStars(content); + } + + function aggregateMintLists(rawMintListEvents, followedPubkeys = null) { + const followedSet = Array.isArray(followedPubkeys) ? new Set(followedPubkeys) : null; + const latestByAuthor = new Map(); + + for (const evt of rawMintListEvents || []) { + if (!evt || evt.kind !== 10019) continue; + if (followedSet && !followedSet.has(evt.pubkey)) continue; + + const prev = latestByAuthor.get(evt.pubkey); + if (!prev || Number(evt.created_at || 0) > Number(prev.created_at || 0)) { + latestByAuthor.set(evt.pubkey, evt); + } + } + + const mintCounts = new Map(); + for (const evt of latestByAuthor.values()) { + const authorMints = new Set( + (evt.tags || []) + .filter((tag) => tag[0] === 'mint' && tag[1]) + .map((tag) => normalizeMintUrl(tag[1])) + .filter(Boolean) + ); + + for (const mintUrl of authorMints) { + mintCounts.set(mintUrl, (mintCounts.get(mintUrl) || 0) + 1); + } + } + + return Array.from(mintCounts.entries()) + .map(([url, count]) => ({ url, count })) + .sort((a, b) => b.count - a.count || a.url.localeCompare(b.url)); + } + + function aggregateRecommendations(rawRecommendationEvents, followedPubkeys = null) { + const followedSet = Array.isArray(followedPubkeys) ? new Set(followedPubkeys) : null; + const mintCounts = new Map(); + + for (const evt of rawRecommendationEvents || []) { + if (!evt || evt.kind !== 38000) continue; + if (followedSet && !followedSet.has(evt.pubkey)) continue; + + const stars = extractRecommendationStars(evt); + const eventMints = new Set( + (evt.tags || []) + .filter((tag) => tag[0] === 'u' && tag[1]) + .map((tag) => normalizeMintUrl(tag[1])) + .filter(Boolean) + ); + + const contentMatches = String(evt.content || '').match(/https:\/\/[^\s"'<>]+/gi) || []; + for (const candidate of contentMatches) { + const normalized = normalizeMintUrl(candidate); + if (normalized) eventMints.add(normalized); + } + + for (const mintUrl of eventMints) { + const prev = mintCounts.get(mintUrl) || { count: 0, starSum: 0, starVotes: 0 }; + prev.count += 1; + if (stars !== null) { + prev.starSum += stars; + prev.starVotes += 1; + } + mintCounts.set(mintUrl, prev); + } + } + + return Array.from(mintCounts.entries()) + .map(([url, stats]) => { + const starVotes = Number(stats?.starVotes || 0); + const starSum = Number(stats?.starSum || 0); + const avgStars = starVotes > 0 ? (starSum / starVotes) : 0; + return { + url, + count: Number(stats?.count || 0), + starSum, + starVotes, + avgStars + }; + }) + .sort((a, b) => { + if (b.starSum !== a.starSum) return b.starSum - a.starSum; + if (b.avgStars !== a.avgStars) return b.avgStars - a.avgStars; + if (b.count !== a.count) return b.count - a.count; + return a.url.localeCompare(b.url); + }); + } + + function recomputeMintDiscoveryViews() { + const followed = mintDiscoveryCache.followedPubkeys || []; + mintDiscoveryCache.contactMintsByScope = { + all: aggregateMintLists(mintDiscoveryCache.rawMintListEvents, null), + wot: aggregateMintLists(mintDiscoveryCache.rawMintListEvents, followed) + }; + mintDiscoveryCache.recommendationMintsByScope = { + all: aggregateRecommendations(mintDiscoveryCache.rawRecommendationEvents, null), + wot: aggregateRecommendations(mintDiscoveryCache.rawRecommendationEvents, followed) + }; + } + + async function hydrateMintDiscoveryFromCache() { + const since = getDiscoverySinceSec(); + + const [cachedContacts, cachedMintLists, cachedRecommendations] = await Promise.all([ + queryCache({ kinds: [3], authors: [currentPubkey], limit: 1 }).catch(() => []), + queryCache({ kinds: [10019], limit: 4000 }).catch(() => []), + queryCache({ kinds: [38000], limit: 4000 }).catch(() => []) + ]); + + mintDiscoveryCache.rawContactEvents = dedupeEvents((cachedContacts || []).filter((evt) => evt && evt.kind === 3 && evt.pubkey === currentPubkey)); + mintDiscoveryCache.followedPubkeys = extractFollowedPubkeysFromContactEvents(mintDiscoveryCache.rawContactEvents); + mintDiscoveryCache.rawMintListEvents = dedupeEvents((cachedMintLists || []).filter((evt) => evt && evt.kind === 10019 && Number(evt.created_at || 0) >= since)); + mintDiscoveryCache.rawRecommendationEvents = dedupeEvents((cachedRecommendations || []).filter((evt) => evt && evt.kind === 38000 && Number(evt.created_at || 0) >= since)); + mintDiscoveryCache.hydratedFromCache = true; + mintDiscoveryCache.error = null; + + recomputeMintDiscoveryViews(); + + console.log('[cashu.html] mint-discovery:cache-hydrated', { + follows: mintDiscoveryCache.followedPubkeys.length, + mintListEvents: mintDiscoveryCache.rawMintListEvents.length, + recommendationEvents: mintDiscoveryCache.rawRecommendationEvents.length + }); + } + + async function refreshMintDiscoveryFromRelays() { + const since = getDiscoverySinceSec(); + + const [relayContacts, relayMintLists, relayRecommendations] = await Promise.all([ + ndkFetchEvents({ kinds: [3], authors: [currentPubkey], limit: 1 }), + ndkFetchEvents({ kinds: [10019], since, limit: 2000 }), + ndkFetchEvents({ kinds: [38000], since, limit: 2000 }) + ]); + + mintDiscoveryCache.rawContactEvents = dedupeEvents([...(mintDiscoveryCache.rawContactEvents || []), ...(relayContacts || [])]); + mintDiscoveryCache.followedPubkeys = extractFollowedPubkeysFromContactEvents(mintDiscoveryCache.rawContactEvents); + + mintDiscoveryCache.rawMintListEvents = dedupeEvents([...(mintDiscoveryCache.rawMintListEvents || []), ...(relayMintLists || [])]); + mintDiscoveryCache.rawRecommendationEvents = dedupeEvents([...(mintDiscoveryCache.rawRecommendationEvents || []), ...(relayRecommendations || [])]); + mintDiscoveryCache.refreshedFromRelays = true; + mintDiscoveryCache.error = null; + + recomputeMintDiscoveryViews(); + + console.log('[cashu.html] mint-discovery:relay-refresh', { + follows: mintDiscoveryCache.followedPubkeys.length, + mintListEvents: mintDiscoveryCache.rawMintListEvents.length, + recommendationEvents: mintDiscoveryCache.rawRecommendationEvents.length + }); + } + + function startMintDiscoveryInBackground(force = false) { + console.log('[cashu.html] mint-discovery:bg-start:called', { + hasPromise: Boolean(mintDiscoveryPromise), + hasWalletController: Boolean(walletController), + scope: mintDiscoveryScope, + force + }); + + if (!walletController) return; + if (mintDiscoveryPromise && !force) return; + + mintDiscoveryPromise = (async () => { + try { + if (!mintDiscoveryCache.hydratedFromCache || force) { + await hydrateMintDiscoveryFromCache(); + if (isNavOpen) { + renderMintDiscoverySidebar({ skipBackgroundKickoff: true }); + } + } + + await refreshMintDiscoveryFromRelays(); + if (isNavOpen) { + renderMintDiscoverySidebar({ skipBackgroundKickoff: true }); + } + } catch (error) { + console.error('[cashu.html] Mint discovery failed:', error); + mintDiscoveryCache.error = error; + if (isNavOpen) { + renderMintDiscoverySidebar({ skipBackgroundKickoff: true }); + } + } finally { + mintDiscoveryPromise = null; + } + })(); + } + + function mergeMintDiscoveryRows(scope) { + const mintLists = mintDiscoveryCache.contactMintsByScope?.[scope] || []; + const recs = mintDiscoveryCache.recommendationMintsByScope?.[scope] || []; + const walletMints = walletController?.hasWallet() + ? (walletController.getMints() || []).map((url) => normalizeMintUrl(url)).filter(Boolean) + : []; + + const byUrl = new Map(); + + for (const row of mintLists) { + const url = normalizeMintUrl(row?.url); + if (!url) continue; + const prev = byUrl.get(url) || { url, count: 0, recCount: 0, starVotes: 0, starSum: 0, avgStars: 0, isWalletMint: false }; + prev.count += Number(row?.count || 0); + byUrl.set(url, prev); + } + + for (const row of recs) { + const url = normalizeMintUrl(row?.url); + if (!url) continue; + const prev = byUrl.get(url) || { url, count: 0, recCount: 0, starVotes: 0, starSum: 0, avgStars: 0, isWalletMint: false }; + prev.count += Number(row?.count || 0); + prev.recCount += Number(row?.count || 0); + prev.starVotes += Number(row?.starVotes || 0); + prev.starSum += Number(row?.starSum || 0); + prev.avgStars = prev.starVotes > 0 ? prev.starSum / prev.starVotes : 0; + byUrl.set(url, prev); + } + + const walletOrder = new Map(walletMints.map((url, idx) => [url, idx])); + for (const mintUrl of walletMints) { + const prev = byUrl.get(mintUrl) || { url: mintUrl, count: 0, recCount: 0, starVotes: 0, starSum: 0, avgStars: 0, isWalletMint: false }; + prev.isWalletMint = true; + byUrl.set(mintUrl, prev); + } + + const rows = Array.from(byUrl.values()); + rows.sort((a, b) => { + const aWallet = a.isWalletMint ? 1 : 0; + const bWallet = b.isWalletMint ? 1 : 0; + if (aWallet !== bWallet) return bWallet - aWallet; + if (a.isWalletMint && b.isWalletMint) { + return (walletOrder.get(a.url) ?? 99999) - (walletOrder.get(b.url) ?? 99999); + } + + if (mintDiscoverySortMode === 'rating') { + if (b.avgStars !== a.avgStars) return b.avgStars - a.avgStars; + if (b.starVotes !== a.starVotes) return b.starVotes - a.starVotes; + if (b.count !== a.count) return b.count - a.count; + return a.url.localeCompare(b.url); + } + + if (b.count !== a.count) return b.count - a.count; + if (b.avgStars !== a.avgStars) return b.avgStars - a.avgStars; + return a.url.localeCompare(b.url); + }); + + return rows; + } + + function applyMintDiscoveryScopeUi() { + const isAll = mintDiscoveryScope === 'all'; + btnToggleWot?.classList.toggle('active', !isAll); + if (btnToggleWot) { + btnToggleWot.textContent = isAll ? 'Scope: All' : 'Scope: WoT'; + } + + btnSortByCount?.classList.toggle('active', mintDiscoverySortMode === 'count'); + btnSortByRating?.classList.toggle('active', mintDiscoverySortMode === 'rating'); + + if (divContactMintsTitle) { + divContactMintsTitle.textContent = 'Mints'; + } + } + + function bindMintDiscoveryControls() { + applyMintDiscoveryScopeUi(); + + btnSortByCount?.addEventListener('click', () => { + if (mintDiscoverySortMode === 'count') return; + mintDiscoverySortMode = 'count'; + applyMintDiscoveryScopeUi(); + renderMintDiscoverySidebar({ skipBackgroundKickoff: true }); + }); + + btnSortByRating?.addEventListener('click', () => { + if (mintDiscoverySortMode === 'rating') return; + mintDiscoverySortMode = 'rating'; + applyMintDiscoveryScopeUi(); + renderMintDiscoverySidebar({ skipBackgroundKickoff: true }); + }); + + btnToggleWot?.addEventListener('click', () => { + mintDiscoveryScope = mintDiscoveryScope === 'all' ? 'wot' : 'all'; + applyMintDiscoveryScopeUi(); + renderMintDiscoverySidebar({ skipBackgroundKickoff: true }); + }); + } + + function renderMintDiscoverySidebar({ skipBackgroundKickoff = false } = {}) { + console.log('[cashu.html] mint-discovery:sidebar-render:start', { + scope: mintDiscoveryScope, + hydratedFromCache: mintDiscoveryCache.hydratedFromCache, + refreshedFromRelays: mintDiscoveryCache.refreshedFromRelays, + skipBackgroundKickoff + }); + + if (!walletController) { + console.warn('[cashu.html] mint-discovery:sidebar-render:no-wallet-controller'); + if (divContactMintsList) divContactMintsList.textContent = 'Wallet controller not ready yet'; + return; + } + + if (!skipBackgroundKickoff) { + startMintDiscoveryInBackground(); + } + + if (!mintDiscoveryCache.hydratedFromCache && !mintDiscoveryCache.refreshedFromRelays) { + if (divContactMintsList) divContactMintsList.textContent = 'Loading mints from cache…'; + return; + } + + if (mintDiscoveryCache.error && !mintDiscoveryCache.refreshedFromRelays) { + if (divContactMintsList) divContactMintsList.textContent = 'Failed to load mints'; + return; + } + + const mergedMints = mergeMintDiscoveryRows(mintDiscoveryScope); + const viewKey = `${mintDiscoveryScope}:${mintDiscoverySortMode}`; + if (mergedMints.length > 0) { + mintDiscoveryLastNonEmptyByView.set(viewKey, mergedMints.map((row) => ({ ...row }))); + } + + const renderedMints = mergedMints.length > 0 + ? mergedMints + : (mintDiscoveryLastNonEmptyByView.get(viewKey) || []); + + if (mergedMints.length === 0 && renderedMints.length > 0) { + console.warn('[cashu.html] mint-discovery:sidebar-render:using-last-non-empty-view', { + scope: mintDiscoveryScope, + sort: mintDiscoverySortMode, + cachedCount: renderedMints.length + }); + } + + console.log('[cashu.html] mint-discovery:sidebar-render:success', { + scope: mintDiscoveryScope, + mintCount: renderedMints.length + }); + + const emptyMessage = mintDiscoveryScope === 'all' + ? 'No mints found in the last year' + : 'No web-of-trust mints found in the last year'; + + renderMintDiscoveryRows(divContactMintsList, renderedMints, emptyMessage); + } + + async function handleAddDiscoveredMint(mintUrl) { + const url = String(mintUrl || '').trim(); + if (!url) return; + + if (walletController?.hasWallet()) { + try { + setStatus('Adding mint...'); + await walletController.addMint(url); + await refreshAllWalletViews(); + renderMintDiscoverySidebar({ skipBackgroundKickoff: true }); + setStatus('Mint added'); + } catch (error) { + console.error('[cashu.html] Add discovered mint failed:', error); + setStatus(error.message || 'Failed to add mint', true); + } + return; + } + + const existing = parseMintLines(taSetupMints.value); + if (!existing.includes(url)) { + taSetupMints.value = [...existing, url].join('\n'); + } + setStatus('Mint added to setup list'); + } + + async function handleRemoveDiscoveredMint(mintUrl) { + const url = String(mintUrl || '').trim(); + if (!url || !walletController?.hasWallet()) return; + + try { + setStatus('Removing mint...'); + await walletController.removeMint(url); + await refreshAllWalletViews(); + renderMintDiscoverySidebar({ skipBackgroundKickoff: true }); + setStatus('Mint removed'); + } catch (error) { + console.error('[cashu.html] Remove discovered mint failed:', error); + setStatus(error.message || 'Failed to remove mint', true); + } + } + + function renderMintSelectors(mints) { + const options = (mints || []).map((mint) => ``).join(''); + selSendMint.innerHTML = options; + selDepositMint.innerHTML = options; + } + + function switchPanel(panelName) { + const panelMap = { + receive: panelReceive, + send: panelSend, + deposit: panelDeposit, + withdraw: panelWithdraw + }; + + for (const [name, panelEl] of Object.entries(panelMap)) { + panelEl.classList.toggle('active', name === panelName); + } + + btnTabReceive.classList.toggle('active', panelName === 'receive'); + btnTabSend.classList.toggle('active', panelName === 'send'); + btnTabDeposit.classList.toggle('active', panelName === 'deposit'); + btnTabWithdraw.classList.toggle('active', panelName === 'withdraw'); + } + + async function renderBalance() { + if (!walletController?.hasWallet()) return; + + const total = walletController.getBalance(); + const byMint = walletController.getBalanceByMint(); + + divBalanceAmount.textContent = formatSats(total); + + const mintRows = Object.entries(byMint || {}); + if (mintRows.length === 0) { + divMintBalances.innerHTML = 'No mint balances yet'; + } else { + divMintBalances.innerHTML = mintRows + .map(([mint, amount]) => ` +
+ `) + .join(''); + } + } + + async function renderSettings() { + if (!walletController?.hasWallet()) return; + + const fromWallet = walletController.getMints(); + const fromBalance = Object.keys(walletController.getBalanceByMint() || {}); + const mints = Array.from(new Set([...(fromWallet || []), ...(fromBalance || [])])) + .map((mint) => normalizeMintUrl(mint)) + .filter(Boolean); + + renderMintSelectors(mints); + if (selWithdrawMint) { + selWithdrawMint.innerHTML = (mints || []).map((mint) => ``).join(''); + } + + if (isNavOpen) { + renderMintDiscoverySidebar({ skipBackgroundKickoff: true }); + } + } + + async function renderTransactions() { + if (!walletController) return; + + const hasWalletNow = Boolean(walletController?.hasWallet?.()); + const hasWorkerWallet = Boolean(latestWorkerWalletBalance?.hasWallet); + console.log('[cashu.html] renderTransactions invoked', { hasWalletNow, hasWorkerWallet }); + if (!hasWalletNow && !hasWorkerWallet) return; + + const txs = await walletController.getTransactionHistory(); + + if (!txs.length) { + divTxList.innerHTML = 'No transactions yet'; + return; + } + + divTxList.innerHTML = txs + .map((tx) => { + const direction = tx.direction === 'in' ? '↓' : '↑'; + const txId = String(tx.id || '').trim(); + const isRedeemedOut = tx.direction === 'out' && txId && redeemedSendTxIds.has(txId); + const dirClass = tx.direction === 'in' + ? 'cashuTxIn' + : (isRedeemedOut ? 'cashuTxOutRedeemed' : 'cashuTxOut'); + const amount = Number(tx.amount || 0); + const mint = tx.mint ? ` · ${shortenMintName(tx.mint)}` : ''; + const description = tx.description ? ` · ${tx.description}` : ''; + const redeemedSuffix = isRedeemedOut ? ' · Redeemed' : ''; + const formattedTime = formatTimeAgo(tx.timestamp); + + return ` +
+ `; + }) + .join(''); + } + + async function refreshAllWalletViews() { + await renderBalance(); + await renderSettings(); + await renderTransactions(); + } + + async function ensureWalletStarted({ force = false } = {}) { + if (!walletController?.hasWallet()) return; + + if (walletStartupPromise && !force) { + return walletStartupPromise; + } + + walletStartupPromise = (async () => { + await walletController.startWallet(); + })(); + + try { + await walletStartupPromise; + } finally { + walletStartupPromise = null; + } + } + + function startWalletInBackground() { + if (!walletController?.hasWallet()) return; + + setStatus('Wallet loaded from cache, syncing in background…'); + + ensureWalletStarted() + .then(async () => { + await refreshAllWalletViews(); + setStatus('Wallet loaded'); + }) + .catch((error) => { + console.error('[cashu.html] Background wallet startup failed:', error); + setStatus(error.message || 'Wallet startup delayed', true); + }); + } + + async function activateWalletUiFromWorkerEvent(reason = 'worker-event') { + if (!walletController || workerWalletUiActivated) return; + workerWalletUiActivated = true; + + try { + console.log('[cashu.html] activateWalletUiFromWorkerEvent', { reason }); + await walletController.loadWallet(); + + showWallet(); + setStatus('Wallet detected, loading details...'); + + unsubscribeTx?.(); + unsubscribeTx = walletController.subscribeTransactions(async () => { + await renderBalance(); + await renderTransactions(); + }); + + walletController.onWalletEvent('balance_updated', async () => { + await renderBalance(); + }); + + walletController.onWalletEvent('status_changed', async (payload) => { + if (payload?.status !== 'ready') return; + try { + await walletController.refreshBalance(); + await refreshAllWalletViews(); + } catch (error) { + console.error('[cashu.html] Status-based wallet refresh failed:', error); + } + }); + + await refreshAllWalletViews(); + startWalletInBackground(); + } catch (error) { + workerWalletUiActivated = false; + console.error('[cashu.html] activateWalletUiFromWorkerEvent failed:', error); + } + } + + function bindTabs() { + btnTabReceive.addEventListener('click', () => switchPanel('receive')); + btnTabSend.addEventListener('click', () => switchPanel('send')); + btnTabDeposit.addEventListener('click', () => switchPanel('deposit')); + btnTabWithdraw.addEventListener('click', () => switchPanel('withdraw')); + } + + function bindActions() { + btnCreateWallet.addEventListener('click', async () => { + console.log('[cashu.html] Create wallet button clicked'); + + try { + const mints = parseMintLines(taSetupMints.value); + setStatus('Creating wallet…'); + + const relaysToUse = Array.isArray(currentRelayUrls) ? [...currentRelayUrls] : []; + await walletController.createWallet(mints, relaysToUse); + + unsubscribeTx?.(); + unsubscribeTx = walletController.subscribeTransactions(async () => { + await renderBalance(); + await renderTransactions(); + }); + + showWallet(); + await refreshAllWalletViews(); + setStatus('Wallet created successfully'); + } catch (error) { + console.error('[cashu.html] Create wallet failed:', error); + setStatus(error.message || 'Failed to create wallet', true); + } + }); + + btnReceiveToken.addEventListener('click', async () => { + try { + const token = taReceiveToken.value.trim(); + if (!token) { + setStatus('Paste a token first', true); + return; + } + + setStatus('Receiving token...'); + await walletController.receiveToken(token, 'Manual receive'); + taReceiveToken.value = ''; + await refreshAllWalletViews(); + + if (divSendTokenOutput.textContent.trim()) { + const shouldDiscardSendOutput = confirmDiscardTransactionOutputs({ send: true }); + if (shouldDiscardSendOutput) { + clearTransactionOutputs({ send: true, deposit: false, withdraw: false }); + } + } + + setStatus('Token received'); + } catch (error) { + console.error('[cashu.html] Receive token failed:', error); + setStatus(error.message || 'Receive failed', true); + } + }); + + btnSendToken.addEventListener('click', async () => { + try { + const amount = Number(inpSendAmount.value); + const memo = inpSendMemo.value.trim() || 'Cashu token'; + + if (!amount || amount <= 0) { + setStatus('Enter a valid send amount', true); + return; + } + + setStatus('Generating token...'); + const result = await walletController.sendToken(amount, memo, selSendMint.value); + + divSendTokenOutput.textContent = result.token; + divSendTokenOutput.classList.remove('clsHidden'); + renderQrCode(divSendTokenQr, result.token); + if (divSendTokenPreview) { + divSendTokenPreview.classList.remove('clsHidden'); + } + btnCopySendToken.classList.remove('clsHidden'); + if (btnClearSendToken) { + btnClearSendToken.classList.remove('clsHidden'); + } + + await refreshAllWalletViews(); + setStatus('Token generated (kept visible for scanning)'); + } catch (error) { + console.error('[cashu.html] Send token failed:', error); + setStatus(error.message || 'Send failed', true); + } + }); + + btnCopySendToken.addEventListener('click', async () => { + await copyToClipboard(divSendTokenOutput.textContent, 'Token copied'); + }); + + if (btnCloseSendTokenPreview) { + btnCloseSendTokenPreview.addEventListener('click', () => { + clearTransactionOutputs({ send: true, deposit: false, withdraw: false }); + setStatus('Generated token closed'); + }); + } + + if (btnClearSendToken) { + btnClearSendToken.addEventListener('click', () => { + clearTransactionOutputs({ send: true, deposit: false, withdraw: false }); + setStatus('Generated token cleared'); + }); + } + + btnCreateInvoice.addEventListener('click', async () => { + try { + const amount = Number(inpDepositAmount.value); + const mint = selDepositMint.value; + + if (!amount || amount <= 0) { + setStatus('Enter a valid deposit amount', true); + return; + } + + setStatus('Creating Lightning invoice...'); + const { deposit, invoice } = await walletController.createDeposit(amount, mint); + + divDepositInvoice.textContent = invoice; + divDepositInvoice.classList.remove('clsHidden'); + renderQrCode(divDepositInvoiceQr, invoice); + btnCopyInvoice.classList.remove('clsHidden'); + + setStatus('Invoice created, waiting for payment...'); + + walletController.waitForDeposit(deposit) + .then(async () => { + await refreshAllWalletViews(); + const shouldDiscardDepositOutput = confirmDiscardTransactionOutputs({ deposit: true }); + if (shouldDiscardDepositOutput) { + clearTransactionOutputs({ send: false, deposit: true, withdraw: false }); + } + setStatus('Deposit confirmed'); + }) + .catch((error) => { + console.error('[cashu.html] Deposit wait failed:', error); + setStatus(error.message || 'Deposit failed', true); + }); + } catch (error) { + console.error('[cashu.html] Create invoice failed:', error); + setStatus(error.message || 'Deposit setup failed', true); + } + }); + + btnCopyInvoice.addEventListener('click', async () => { + await copyToClipboard(divDepositInvoice.textContent, 'Invoice copied'); + }); + + btnPayInvoice.addEventListener('click', async () => { + try { + const invoice = taWithdrawInvoice.value.trim(); + const mint = selWithdrawMint.value; + if (!invoice) { + setStatus('Paste a Lightning invoice to withdraw', true); + return; + } + + setStatus('Paying Lightning invoice...'); + await walletController.payInvoice(invoice, mint); + taWithdrawInvoice.value = ''; + await refreshAllWalletViews(); + const shouldDiscardWithdrawOutput = confirmDiscardTransactionOutputs({ withdraw: true }); + if (shouldDiscardWithdrawOutput) { + clearTransactionOutputs({ send: false, deposit: false, withdraw: true }); + } + setStatus('Lightning payment sent'); + } catch (error) { + console.error('[cashu.html] Lightning withdrawal failed:', error); + setStatus(error.message || 'Lightning withdrawal failed', true); + } + }); + + btnAddMint.addEventListener('click', async () => { + const mintUrl = inpAddMint.value.trim(); + if (!mintUrl) { + setStatus('Enter a mint URL to add', true); + return; + } + + await handleAddDiscoveredMint(mintUrl); + if (!divWalletContent.classList.contains('clsHidden')) { + inpAddMint.value = ''; + } + }); + + btnCheckProofs.addEventListener('click', async () => { + try { + if (btnCheckProofs.disabled) { + const disabledMessage = 'Wallet still syncing proofs, wait for sync to complete before checking'; + setStatus(disabledMessage, true); + setCheckProofsStatus(disabledMessage, true); + return; + } + + setStatus('Checking proofs with mints...'); + setCheckProofsStatus('Checking proofs with mints...'); + await ensureWalletStarted(); + const result = await walletController.checkProofs(); + await refreshAllWalletViews(); + + const proofCheck = result?.proofCheck; + if (proofCheck) { + const mintsChecked = Number(proofCheck?.mintsCheckedBefore || 0); + const checked = Number(proofCheck?.proofsChecked || 0); + const removed = Number(proofCheck?.proofsRemoved || 0); + const after = Number(proofCheck?.balanceAfter || 0); + const delta = Number(proofCheck?.balanceDelta || 0); + const source = String(proofCheck?.sync?.source || proofCheck?.balanceSource || 'unknown'); + const warning = proofCheck?.warning ? ` ⚠ ${proofCheck.warning}` : ''; + const deltaText = delta === 0 ? 'Δ0' : `Δ${delta > 0 ? '+' : ''}${delta}`; + const summary = `Proof check: ${checked} proofs / ${mintsChecked} mints, removed ${removed}, balance ${formatSats(after)} (${deltaText}, ${source})${warning}`; + setStatus(summary); + setCheckProofsStatus(summary); + } else { + const doneMessage = 'Proof check complete'; + setStatus(doneMessage); + setCheckProofsStatus(doneMessage); + } + } catch (error) { + console.error('[cashu.html] Check proofs failed:', error); + const errorMessage = error.message || 'Proof check failed'; + setStatus(errorMessage, true); + setCheckProofsStatus(errorMessage, true); + } + }); + + btnRefreshWallet.addEventListener('click', async () => { + try { + setStatus('Refreshing wallet state...'); + await ensureWalletStarted({ force: true }); + await refreshAllWalletViews(); + setStatus('Wallet refreshed'); + } catch (error) { + console.error('[cashu.html] Wallet refresh failed:', error); + setStatus(error.message || 'Wallet refresh failed', true); + } + }); + } + + (async function main() { + console.log('[cashu.html] Starting initialization...'); + + try { + initHamburgerMenu(); + + const divSvgHam = document.getElementById('divSvgHam'); + if (divSvgHam) { + divSvgHam.addEventListener('click', toggleNav); + } + + const themeToggleButton = document.getElementById('themeToggleButton'); + const logoutButton = document.getElementById('logoutButton'); + + if (themeToggleButton) { + themeToggleButton.addEventListener('click', () => { + isDarkMode = !isDarkMode; + localStorage.setItem('theme', isDarkMode ? 'dark' : 'light'); + localStorage.setItem('sidenavWasOpen', isNavOpen ? 'true' : 'false'); + window.location.reload(); + }); + } + + if (logoutButton) { + logoutButton.addEventListener('click', async () => { + try { + await Logout(); + } catch (error) { + console.error('[cashu.html] Logout failed:', error); + } + }); + } + + await initNDKPage(); + currentPubkey = await getPubkey(); + await injectHeaderAvatar(currentPubkey); + + window.addEventListener('ndkWalletBalance', (event) => { + const payload = event?.detail || {}; + latestWorkerWalletBalance = payload; + + if (payload?.balanceSource === 'full' && payload?.isApproximate === false) { + hasFullWalletBalanceReady = true; + } + + if (payload?.hasWallet) { + walletStatusReady = true; + showWallet(); + void activateWalletUiFromWorkerEvent('ndkWalletBalance'); + } + + renderImmediateWorkerBalance(payload); + updateCheckProofsButtonState(); + }); + + window.addEventListener('ndkWalletStatus', (event) => { + const payload = event?.detail || {}; + walletStatusReady = payload?.status === 'ready' || payload?.hasWallet === true; + if (payload?.hasWallet) { + showWallet(); + void activateWalletUiFromWorkerEvent('ndkWalletStatus'); + } + updateCheckProofsButtonState(); + }); + + window.addEventListener('ndkWalletSendRedeemed', async (event) => { + const payload = event?.detail || {}; + console.log('[cashu.html] ndkWalletSendRedeemed event received', payload); + const amount = Number(payload?.amount || 0); + const mint = String(payload?.mint || '').trim(); + const memo = String(payload?.memo || '').trim(); + const txId = String(payload?.txId || '').trim(); + const mintLabel = mint ? ` on ${shortenMintName(mint)}` : ''; + const memoLabel = memo ? ` (${memo})` : ''; + + if (txId) { + redeemedSendTxIds.add(txId); + } + + clearTransactionOutputs({ send: true, deposit: false, withdraw: false }); + await renderTransactions(); + setStatus(`✓ Sent token redeemed${mintLabel}: ${formatSats(amount)}${memoLabel}`); + }); + + initFooterRelayStatus(); + initSidenavRelaySection(); + await initBlossomSection(); + initAiSectionWithLocalConfig(); + await UpdateFooter(); + + window.addEventListener('ndkRelayActivity', (event) => { + const { relayUrl, activity } = event.detail; + setRelayActivityState(relayUrl, activity); + }); + + window.addEventListener('message', (event) => { + if (event.data && event.data.type === 'relayActivity') { + const { relayUrl, activity } = event.data; + setRelayActivityState(relayUrl, activity); + } + }); + + const relayData = await getRelayData(); + const relayUrls = (relayData || []).map((relay) => relay.url).filter(Boolean); + currentRelayUrls = [...relayUrls]; + + walletController = await createCashuWalletController({ + pubkey: currentPubkey, + relayUrls + }); + await walletController.loadWallet(); + + console.log('[cashu.html] mint-discovery:wallet-controller-ready', { + hasDiscoverMintsFromContacts: typeof walletController?.discoverMintsFromContacts, + hasDiscoverMintRecommendations: typeof walletController?.discoverMintRecommendations + }); + + bindTabs(); + bindActions(); + bindMintDiscoveryControls(); + updateCheckProofsButtonState(); + + if (walletController.hasWallet()) { + console.log('[cashu.html] startup branch: walletController.hasWallet() === true'); + showWallet(); + + unsubscribeTx?.(); + unsubscribeTx = walletController.subscribeTransactions(async () => { + await renderBalance(); + await renderTransactions(); + }); + + walletController.onWalletEvent('balance_updated', async () => { + await renderBalance(); + }); + + walletController.onWalletEvent('status_changed', async (payload) => { + if (payload?.status !== 'ready') return; + try { + await walletController.refreshBalance(); + await refreshAllWalletViews(); + } catch (error) { + console.error('[cashu.html] Status-based wallet refresh failed:', error); + } + }); + + await refreshAllWalletViews(); + startWalletInBackground(); + } else if (latestWorkerWalletBalance?.hasWallet) { + console.log('[cashu.html] startup branch: latestWorkerWalletBalance.hasWallet === true'); + showWallet(); + renderImmediateWorkerBalance(latestWorkerWalletBalance); + setStatus('Wallet detected, loading details...'); + + unsubscribeTx?.(); + unsubscribeTx = walletController.subscribeTransactions(async () => { + await renderBalance(); + await renderTransactions(); + }); + + walletController.onWalletEvent('balance_updated', async () => { + await renderBalance(); + }); + + walletController.onWalletEvent('status_changed', async (payload) => { + if (payload?.status !== 'ready') return; + try { + await walletController.refreshBalance(); + await refreshAllWalletViews(); + } catch (error) { + console.error('[cashu.html] Status-based wallet refresh failed:', error); + } + }); + + await renderTransactions(); + startWalletInBackground(); + } else { + console.log('[cashu.html] startup branch: no wallet detected'); + showSetup(); + setStatus('No wallet yet — add one or more mints to create one'); + } + + const sidenavWasOpen = localStorage.getItem('sidenavWasOpen'); + if (sidenavWasOpen === 'true') { + localStorage.removeItem('sidenavWasOpen'); + openNav(); + } + + updateIntervalId = setInterval(UpdateFooter, 1000); + startTimestampUpdater(); + await updateVersionDisplay(); + + console.log('[cashu.html] mint-discovery:post-init-scheduled'); + setTimeout(() => { + console.log('[cashu.html] mint-discovery:post-init-trigger', { + hasWalletController: Boolean(walletController), + hasPromise: Boolean(mintDiscoveryPromise) + }); + startMintDiscoveryInBackground(); + }, 0); + + console.log('[cashu.html] Initialization complete'); + } catch (error) { + console.error('[cashu.html] Initialization failed:', error); + setStatus(error.message || 'Initialization failed', true); + divBody.innerHTML = `
`; + } + })(); + \ No newline at end of file diff --git a/build/.event-management-inline-check.mjs b/build/.event-management-inline-check.mjs new file mode 100644 index 0000000..197a188 --- /dev/null +++ b/build/.event-management-inline-check.mjs @@ -0,0 +1,1473 @@ + + import { + initNDKPage, + getPubkey, + injectHeaderAvatar, + subscribe, + fetchEvents, + ndkFetchEvents, + queryCache, + getRelayData, + publishEvent, + publishRawEventToRelay, + disconnect, + getVersion, + updateVersionDisplay + } from './js/init-ndk.mjs'; + import { HamburgerMorphing } from './hamburger_morphing/hamburger.mjs'; + import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs'; + import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs'; + import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs'; + import { configureMuteList, loadMuteList, addMute } from './js/mute-list.mjs'; + + const versionInfo = await getVersion(); + const VERSION = versionInfo.VERSION; + console.log(`[event-management.html ${VERSION}] Loading...`); + + const KIND_LABELS = { + 0: 'Profile', + 1: 'Note', + 2: 'Recommend Relay', + 3: 'Contacts', + 4: 'Encrypted DM', + 5: 'Deletion', + 6: 'Repost', + 7: 'Reaction', + 8: 'Badge Award', + 40: 'Channel Create', + 41: 'Channel Metadata', + 42: 'Channel Message', + 9734: 'Zap Request', + 9735: 'Zap Receipt', + 10000: 'Mute List', + 10002: 'Relay List', + 10019: 'Wallet Info', + 10123: 'Skill Adoption', + 17375: 'Cashu Wallet', + 30023: 'Long-form Article', + 30024: 'Draft Article', + 30078: 'App-specific Data', + 31123: 'Skill (Public)', + 31124: 'Skill (Private)', + 7375: 'Cashu Token', + 7376: 'Cashu History' + }; + + let updateIntervalId = null; + let currentPubkey = null; + let hamburgerInstance = null; + let isNavOpen = false; + let logoutHamburger = null; + let themeToggleHamburger = null; + let isDarkMode = false; + + let db = null; + const DB_NAME = 'ndk-shared'; + let selectedKind = null; + let selectedEventKey = null; + let selectedKindSub = null; + let currentDetailEvent = null; + + const eventsByKind = new Map(); // kind -> Map(eventKey, event) + const selectedEventKeys = new Set(); + const eventRelayPresence = new Map(); // eventKey -> Map(relayUrl, boolean) + const eventRelayPublishState = new Map(); // eventKey -> Map(relayUrl, 'idle'|'checking'|'publishing'|'failed') + let subscribedRelayUrls = []; + let refreshKindDebugInFlight = false; + const autoRefreshedKinds = new Set(); + const BRAILLE_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + let brailleSpinnerFrameIndex = 0; + let brailleSpinnerTimerId = null; + + const divBody = document.getElementById('divBody'); + const divSideNav = document.getElementById('divSideNav'); + const divFooterCenter = document.getElementById('divFooterCenter'); + const divFooterRight = document.getElementById('divFooterRight'); + + const btnRefreshKinds = document.getElementById('btnRefreshKinds'); + const divKindsStatus = document.getElementById('divKindsStatus'); + const divKindsList = document.getElementById('divKindsList'); + + const inpEventSearch = document.getElementById('inpEventSearch'); + const chkSelectAllEvents = document.getElementById('chkSelectAllEvents'); + const chkShowSuperseded = document.getElementById('chkShowSuperseded'); + const divEventsCount = document.getElementById('divEventsCount'); + const divEventsList = document.getElementById('divEventsList'); + const btnPublishSelectedMissing = document.getElementById('btnPublishSelectedMissing'); + + const divDetailMeta = document.getElementById('divDetailMeta'); + const preEventJson = document.getElementById('preEventJson'); + const btnCopyEventId = document.getElementById('btnCopyEventId'); + const btnCopyEventJson = document.getElementById('btnCopyEventJson'); + const btnDeleteEvent = document.getElementById('btnDeleteEvent'); + const btnBlockEvent = document.getElementById('btnBlockEvent'); + const btnRemoveEventFromCache = document.getElementById('btnRemoveEventFromCache'); + const divDecryptMeta = document.getElementById('divDecryptMeta'); + const preDecryptedContent = document.getElementById('preDecryptedContent'); + + function setDetailActionButtonsEnabled(enabled) { + const disabled = !Boolean(enabled); + [btnCopyEventId, btnCopyEventJson, btnDeleteEvent, btnBlockEvent, btnRemoveEventFromCache] + .filter(Boolean) + .forEach((btn) => { + btn.disabled = disabled; + }); + } + + function escapeHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); + } + + function shortHex(value = '') { + const s = String(value || ''); + if (s.length < 16) return s; + return `${s.slice(0, 10)}…${s.slice(-8)}`; + } + + function kindLabel(kind) { + const n = Number(kind); + return KIND_LABELS[n] || 'Unknown Kind'; + } + + function normalizeDisplayId(id, kind) { + const rawId = String(id ?? ''); + const prefix = `${kind}:`; + if (rawId.startsWith(prefix)) return rawId.slice(prefix.length); + return rawId; + } + + function getDTag(tags) { + const t = (Array.isArray(tags) ? tags : []).find(x => Array.isArray(x) && x[0] === 'd'); + return t && t[1] ? String(t[1]) : ''; + } + + function eventKeyFor(evt) { + return `${Number(evt.kind)}:${String(evt.id || '')}`; + } + + function setDecryptedOutput(text, meta = 'Decrypted content will appear here.') { + if (divDecryptMeta) divDecryptMeta.textContent = String(meta || 'Decrypted content will appear here.'); + if (preDecryptedContent) preDecryptedContent.textContent = String(text || '(none)'); + } + + function isLikelyNip04Content(content) { + return typeof content === 'string' && content.includes('?iv='); + } + + function isLikelyEncryptedEvent(evt) { + if (!evt) return false; + const kind = Number(evt.kind || 0); + const content = String(evt.content || '').trim(); + if (!content) return false; + if (kind === 4 || kind === 44 || kind === 30078) return true; + if (isLikelyNip04Content(content)) return true; + if (content.startsWith('nip44v2:')) return true; + return false; + } + + function getTagValue(tags, name) { + const arr = Array.isArray(tags) ? tags : []; + const row = arr.find((t) => Array.isArray(t) && String(t[0] || '') === String(name || '')); + return row && row[1] ? String(row[1]) : ''; + } + + function resolveDecryptPeerPubkey(evt) { + const eventObj = evt || {}; + const authorPubkey = String(eventObj.pubkey || ''); + const pTagPubkey = getTagValue(eventObj.tags, 'p'); + const kind = Number(eventObj.kind || 0); + const authoredByMe = Boolean(currentPubkey && authorPubkey && currentPubkey === authorPubkey); + + if (kind === 4 || kind === 44) { + if (authoredByMe) return pTagPubkey || authorPubkey || String(currentPubkey || ''); + return authorPubkey || pTagPubkey || String(currentPubkey || ''); + } + + return pTagPubkey || authorPubkey || String(currentPubkey || ''); + } + + async function decryptCurrentDetailContent() { + if (!currentDetailEvent?.id) throw new Error('No selected event'); + + const eventObj = currentDetailEvent; + const kind = Number(eventObj.kind || 0); + const ciphertext = String(eventObj.content || ''); + if (!ciphertext) throw new Error('Event content is empty'); + + const peerPubkey = resolveDecryptPeerPubkey(eventObj); + if (!peerPubkey) throw new Error('Could not determine decrypt peer pubkey'); + + const likelyNip04 = kind === 4 || isLikelyNip04Content(ciphertext); + const likelyNip44 = kind === 44 || kind === 30078 || !likelyNip04; + + const errors = []; + const tryNip04First = likelyNip04 && !likelyNip44; + const methods = tryNip04First ? ['nip04', 'nip44'] : ['nip44', 'nip04']; + + for (const method of methods) { + try { + if (method === 'nip44') { + if (!window?.nostr?.nip44?.decrypt) throw new Error('NIP-44 decrypt unavailable'); + const plaintext = await window.nostr.nip44.decrypt(peerPubkey, ciphertext); + return { plaintext: String(plaintext || ''), method: 'NIP-44', peerPubkey }; + } + + if (!window?.nostr?.nip04?.decrypt) throw new Error('NIP-04 decrypt unavailable'); + const plaintext = await window.nostr.nip04.decrypt(peerPubkey, ciphertext); + return { plaintext: String(plaintext || ''), method: 'NIP-04', peerPubkey }; + } catch (error) { + errors.push(`${method}: ${error?.message || String(error)}`); + } + } + + throw new Error(`Decrypt failed (${errors.join(' | ')})`); + } + + let decryptAttemptSequence = 0; + + async function autoDecryptDetailEvent(detailEvent, sequence) { + if (!detailEvent?.id) return; + + try { + const result = await decryptCurrentDetailContent(); + + if (sequence !== decryptAttemptSequence) return; + if (!currentDetailEvent || eventKeyFor(currentDetailEvent) !== eventKeyFor(detailEvent)) return; + + const prettyPlain = (() => { + const raw = String(result.plaintext || ''); + try { + return JSON.stringify(JSON.parse(raw), null, 2); + } catch (_error) { + return raw; + } + })(); + + setDecryptedOutput( + prettyPlain, + `Decrypted via ${result.method} using peer ${shortHex(result.peerPubkey)}` + ); + } catch (error) { + if (sequence !== decryptAttemptSequence) return; + if (!currentDetailEvent || eventKeyFor(currentDetailEvent) !== eventKeyFor(detailEvent)) return; + setDecryptedOutput('(decrypt failed)', `Decrypt failed: ${error?.message || String(error)}`); + } + } + + function setSubscribedRelayUrlsFromRelayData(relayData) { + const rows = Array.isArray(relayData) ? relayData : []; + const hasRelayListScopedRows = rows.some((r) => r?.fromRelayList === true); + const scopedRows = hasRelayListScopedRows + ? rows.filter((r) => r?.fromRelayList === true) + : rows; + + const urls = [...new Set(scopedRows + .map((r) => String(r?.url || '').trim()) + .filter(Boolean))]; + + subscribedRelayUrls = urls; + + for (const relayMap of eventRelayPresence.values()) { + for (const url of subscribedRelayUrls) { + if (!relayMap.has(url)) relayMap.set(url, false); + } + } + + for (const relayStateMap of eventRelayPublishState.values()) { + for (const url of subscribedRelayUrls) { + if (!relayStateMap.has(url)) relayStateMap.set(url, 'idle'); + } + } + } + + function ensureRelayMapForEvent(eventKey) { + let relayMap = eventRelayPresence.get(eventKey); + if (!relayMap) { + relayMap = new Map(); + for (const url of subscribedRelayUrls) relayMap.set(url, false); + eventRelayPresence.set(eventKey, relayMap); + } else { + for (const url of subscribedRelayUrls) { + if (!relayMap.has(url)) relayMap.set(url, false); + } + } + return relayMap; + } + + function ensureRelayPublishStateForEvent(eventKey) { + let relayStateMap = eventRelayPublishState.get(eventKey); + if (!relayStateMap) { + relayStateMap = new Map(); + for (const url of subscribedRelayUrls) relayStateMap.set(url, 'idle'); + eventRelayPublishState.set(eventKey, relayStateMap); + } else { + for (const url of subscribedRelayUrls) { + if (!relayStateMap.has(url)) relayStateMap.set(url, 'idle'); + } + } + return relayStateMap; + } + + function markRelayPublishState(eventKey, relayUrl, state) { + if (!eventKey || !relayUrl) return; + const relayStateMap = ensureRelayPublishStateForEvent(eventKey); + relayStateMap.set(String(relayUrl), String(state || 'idle')); + } + + function markRelayPresence(eventKey, relayUrl, present) { + if (!eventKey || !relayUrl) return; + const relayMap = ensureRelayMapForEvent(eventKey); + const presentBool = Boolean(present); + const relayUrlStr = String(relayUrl); + relayMap.set(relayUrlStr, presentBool); + if (presentBool) markRelayPublishState(eventKey, relayUrlStr, 'idle'); + } + + function shortRelayLabel(url) { + return String(url || '') + .replace(/^wss?:\/\//i, '') + .replace(/\/$/, ''); + } + + function getBrailleSpinnerFrame() { + return BRAILLE_SPINNER_FRAMES[brailleSpinnerFrameIndex % BRAILLE_SPINNER_FRAMES.length]; + } + + function hasAnyCheckingRelayState() { + for (const relayStateMap of eventRelayPublishState.values()) { + for (const state of relayStateMap.values()) { + if (String(state) === 'checking') return true; + } + } + return false; + } + + function syncBrailleSpinnerAnimation() { + const needsAnimation = hasAnyCheckingRelayState(); + + if (needsAnimation && !brailleSpinnerTimerId) { + brailleSpinnerTimerId = setInterval(() => { + brailleSpinnerFrameIndex = (brailleSpinnerFrameIndex + 1) % BRAILLE_SPINNER_FRAMES.length; + renderEvents(); + }, 120); + } else if (!needsAnimation && brailleSpinnerTimerId) { + clearInterval(brailleSpinnerTimerId); + brailleSpinnerTimerId = null; + } + } + + function renderRelayPresenceChips(eventKey) { + if (!subscribedRelayUrls.length) return ''; + const relayMap = ensureRelayMapForEvent(eventKey); + const relayStateMap = ensureRelayPublishStateForEvent(eventKey); + const chips = subscribedRelayUrls.map((url) => { + const present = Boolean(relayMap.get(url)); + const state = String(relayStateMap.get(url) || 'idle'); + + const stateLabel = state === 'checking' + ? `${getBrailleSpinnerFrame()} checking` + : (state === 'publishing' ? 'posting...' : (state === 'failed' ? 'failed' : (present ? 'yes' : 'no'))); + + const stateClass = state === 'checking' + ? 'checking' + : (state === 'publishing' ? 'publishing' : (state === 'failed' ? 'failed' : (present ? 'yes' : 'no'))); + + const clickable = !present && state !== 'publishing' && state !== 'checking'; + + return ``; + }).join(''); + return `
`; + } + + function upsertEvent(evt) { + if (!evt || !evt.id || !Number.isFinite(Number(evt.kind))) return; + const kind = Number(evt.kind); + if (!eventsByKind.has(kind)) eventsByKind.set(kind, new Map()); + + const byId = eventsByKind.get(kind); + const key = eventKeyFor(evt); + const prev = byId.get(key); + if (!prev || Number(evt.created_at || 0) >= Number(prev.created_at || 0)) { + const merged = { + ...(prev || {}), + ...evt, + sig: String(evt?.sig || prev?.sig || '') + }; + byId.set(key, merged); + } + + ensureRelayMapForEvent(key); + ensureRelayPublishStateForEvent(key); + if (evt.relay) { + markRelayPresence(key, String(evt.relay), true); + } + } + + async function openDatabase() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + }); + } + + function resolveEventsStoreName(database) { + const availableStores = Array.from(database.objectStoreNames); + const possibleStores = ['events', 'Event', 'ndk_events', 'cache', 'nostr_events']; + const storeName = possibleStores.find((s) => availableStores.includes(s)) || availableStores[0]; + if (!storeName) throw new Error('No object stores found in database'); + return storeName; + } + + async function getAllEvents() { + if (!db) db = await openDatabase(); + const storeName = resolveEventsStoreName(db); + + return new Promise((resolve, reject) => { + const tx = db.transaction([storeName], 'readonly'); + const store = tx.objectStore(storeName); + const req = store.getAll(); + req.onerror = () => reject(req.error); + req.onsuccess = () => resolve(req.result || []); + }); + } + + async function removeEventFromCache(evt) { + if (!evt || !evt.id || !Number.isFinite(Number(evt.kind))) return 0; + if (!db) db = await openDatabase(); + + const targetId = String(evt.id || ''); + const targetKind = Number(evt.kind); + const targetPubkey = String(evt.pubkey || ''); + const storeName = resolveEventsStoreName(db); + + return new Promise((resolve, reject) => { + const tx = db.transaction([storeName], 'readwrite'); + const store = tx.objectStore(storeName); + const req = store.openCursor(); + let removed = 0; + + req.onerror = () => reject(req.error); + + req.onsuccess = () => { + const cursor = req.result; + if (!cursor) { + resolve(removed); + return; + } + + const row = cursor.value || {}; + let rowKind = Number(row.kind || 0); + let rowId = normalizeDisplayId(row.id, rowKind); + let rowPubkey = String(row.pubkey || ''); + + try { + const parsed = JSON.parse(row.event); + const parsedKind = Array.isArray(parsed) ? parsed[0] : parsed.kind; + const parsedId = Array.isArray(parsed) ? parsed[6] : parsed.id; + rowKind = Number(row.kind ?? parsedKind); + rowId = normalizeDisplayId(parsedId ?? row.id, rowKind); + rowPubkey = String(row.pubkey || ''); + } catch (_error) { + // keep fallback values + } + + const kindMatch = rowKind === targetKind; + const idMatch = String(rowId || '') === targetId; + const pubkeyMatch = !targetPubkey || rowPubkey === targetPubkey; + + if (kindMatch && idMatch && pubkeyMatch) { + removed += 1; + cursor.delete(); + } + + cursor.continue(); + }; + }); + } + + function parseCachedEventRow(row) { + try { + const parsed = JSON.parse(row.event); + const parsedKind = Array.isArray(parsed) ? parsed[0] : parsed.kind; + const parsedId = Array.isArray(parsed) ? parsed[6] : parsed.id; + const kind = Number(row.kind ?? parsedKind); + const content = Array.isArray(parsed) ? (parsed[5] || '') : (parsed.content || ''); + const tags = Array.isArray(parsed) ? (parsed[4] || []) : (parsed.tags || []); + const sig = Array.isArray(parsed) ? parsed[7] : parsed.sig; + return { + id: normalizeDisplayId(parsedId ?? row.id, kind), + kind, + pubkey: String(row.pubkey || ''), + created_at: Number(row.createdAt ?? row.created_at ?? 0), + content: String(content || ''), + tags: Array.isArray(tags) ? tags : [], + sig: String(sig || row.sig || ''), + relay: String(row.relay || ''), + raw_event: row.event + }; + } catch (_error) { + const kind = Number(row.kind || 0); + return { + id: normalizeDisplayId(row.id, kind), + kind, + pubkey: String(row.pubkey || ''), + created_at: Number(row.createdAt ?? row.created_at ?? 0), + content: 'PARSE ERROR', + tags: [], + sig: String(row.sig || ''), + relay: String(row.relay || ''), + raw_event: row.event + }; + } + } + + function getKindsSorted() { + return Array.from(eventsByKind.keys()).sort((a, b) => a - b); + } + + function getEventsForKind(kind) { + const map = eventsByKind.get(Number(kind)); + if (!map) return []; + return Array.from(map.values()).sort((a, b) => { + const ta = Number(a.created_at || 0); + const tb = Number(b.created_at || 0); + if (tb !== ta) return tb - ta; + return String(a.id || '').localeCompare(String(b.id || '')); + }); + } + + function isReplaceableKind(kind) { + const k = Number(kind); + return k === 0 || k === 3 || (k >= 10000 && k < 20000); + } + + function isParameterizedReplaceableKind(kind) { + const k = Number(kind); + return k >= 30000 && k < 40000; + } + + function getReplaceableGroupKey(evt) { + if (!evt || !Number.isFinite(Number(evt.kind))) return null; + const kind = Number(evt.kind); + + if (isParameterizedReplaceableKind(kind)) { + return `p:${kind}:${getDTag(evt.tags)}`; + } + + if (isReplaceableKind(kind)) { + return `r:${kind}`; + } + + return null; + } + + function getSupersededEventKeys(events) { + const latestByGroup = new Map(); + const superseded = new Set(); + + for (const evt of (Array.isArray(events) ? events : [])) { + const groupKey = getReplaceableGroupKey(evt); + if (!groupKey) continue; + + const candidateTs = Number(evt.created_at || 0); + const candidateId = String(evt.id || ''); + const prev = latestByGroup.get(groupKey); + + if (!prev) { + latestByGroup.set(groupKey, evt); + continue; + } + + const prevTs = Number(prev.created_at || 0); + const prevId = String(prev.id || ''); + + const candidateNewer = candidateTs > prevTs || (candidateTs === prevTs && candidateId > prevId); + if (candidateNewer) { + superseded.add(eventKeyFor(prev)); + latestByGroup.set(groupKey, evt); + } else { + superseded.add(eventKeyFor(evt)); + } + } + + return superseded; + } + + function getFilteredEventsForSelectedKind() { + if (!Number.isFinite(Number(selectedKind))) { + return { visibleEvents: [], supersededKeys: new Set(), hiddenSupersededCount: 0 }; + } + + const all = getEventsForKind(selectedKind); + const supersededKeys = getSupersededEventKeys(all); + + const includeSuperseded = Boolean(chkShowSuperseded?.checked); + const base = includeSuperseded + ? all + : all.filter((evt) => !supersededKeys.has(eventKeyFor(evt))); + + const q = String(inpEventSearch.value || '').trim().toLowerCase(); + const visibleEvents = !q + ? base + : base.filter((evt) => { + const dTag = getDTag(evt.tags); + const hay = `${evt.id || ''} ${dTag} ${evt.content || ''}`.toLowerCase(); + return hay.includes(q); + }); + + const hiddenSupersededCount = includeSuperseded ? 0 : all.reduce((count, evt) => { + return count + (supersededKeys.has(eventKeyFor(evt)) ? 1 : 0); + }, 0); + + return { + visibleEvents, + supersededKeys, + hiddenSupersededCount + }; + } + + function renderKinds() { + const kinds = getKindsSorted(); + if (!kinds.length) { + divKindsList.innerHTML = '
'; + divKindsStatus.textContent = '0 kinds · 0 events'; + return; + } + + const totalEvents = kinds.reduce((sum, k) => sum + getEventsForKind(k).length, 0); + divKindsStatus.textContent = `${kinds.length} kinds · ${totalEvents.toLocaleString()} cached events`; + + divKindsList.innerHTML = kinds.map((kind) => { + const count = getEventsForKind(kind).length; + const active = Number(selectedKind) === Number(kind) ? 'active' : ''; + return ` +
+ `; + }).join(''); + + Array.from(divKindsList.querySelectorAll('.kindItem[data-kind]')).forEach((el) => { + el.addEventListener('click', async () => { + await selectKind(Number(el.getAttribute('data-kind'))); + }); + }); + + Array.from(divKindsList.querySelectorAll('button[data-refresh-kind]')).forEach((btn) => { + btn.addEventListener('click', async (ev) => { + ev.stopPropagation(); + const kind = Number(btn.getAttribute('data-refresh-kind')); + if (!Number.isFinite(kind)) return; + await refreshKindFromRelays(kind); + }); + }); + } + + function renderEvents() { + const { visibleEvents: events, supersededKeys, hiddenSupersededCount } = getFilteredEventsForSelectedKind(); + divEventsCount.textContent = hiddenSupersededCount > 0 + ? `${events.length.toLocaleString()} visible · ${hiddenSupersededCount.toLocaleString()} superseded hidden` + : `${events.length.toLocaleString()} visible`; + + if (!Number.isFinite(Number(selectedKind))) { + divEventsList.innerHTML = '
'; + chkSelectAllEvents.checked = false; + return; + } + + if (!events.length) { + divEventsList.innerHTML = '
'; + chkSelectAllEvents.checked = false; + return; + } + + divEventsList.innerHTML = events.map((evt) => { + const key = eventKeyFor(evt); + const active = selectedEventKey === key ? 'active' : ''; + const checked = selectedEventKeys.has(key) ? 'checked' : ''; + const dTag = getDTag(evt.tags); + const preview = dTag || String(evt.content || '').replace(/\s+/g, ' ').slice(0, 110) || '(no content)'; + const created = Number(evt.created_at || 0) > 0 ? new Date(Number(evt.created_at) * 1000).toLocaleString() : '-'; + + return ` +
+ `; + }).join(''); + + const visibleKeys = new Set(events.map(eventKeyFor)); + const allVisibleChecked = events.length > 0 && events.every((e) => selectedEventKeys.has(eventKeyFor(e))); + chkSelectAllEvents.checked = allVisibleChecked; + + Array.from(divEventsList.querySelectorAll('.eventItem[data-key]')).forEach((el) => { + el.addEventListener('click', (ev) => { + if (ev.target && (ev.target.matches('input[type="checkbox"]') || ev.target.closest('[data-relay-chip]'))) return; + const key = String(el.getAttribute('data-key') || ''); + const evt = findEventByKey(key); + if (!evt) return; + selectedEventKey = key; + renderEvents(); + renderDetail(evt); + }); + }); + + Array.from(divEventsList.querySelectorAll('input[data-check-key]')).forEach((box) => { + box.addEventListener('change', () => { + const key = String(box.getAttribute('data-check-key') || ''); + if (!visibleKeys.has(key)) return; + if (box.checked) selectedEventKeys.add(key); + else selectedEventKeys.delete(key); + renderEvents(); + }); + }); + } + + function findEventByKey(key) { + const [kindRaw] = String(key || '').split(':'); + const kind = Number(kindRaw); + if (!Number.isFinite(kind)) return null; + return eventsByKind.get(kind)?.get(key) || null; + } + + function removeEventFromUiState(targetKind, targetEventId) { + if (!Number.isFinite(Number(targetKind)) || !targetEventId) return; + + const kind = Number(targetKind); + const eventId = String(targetEventId); + const targetKey = `${kind}:${eventId}`; + + const byKind = eventsByKind.get(kind); + if (byKind) { + byKind.delete(targetKey); + if (byKind.size === 0) eventsByKind.delete(kind); + } + + selectedEventKeys.delete(targetKey); + eventRelayPresence.delete(targetKey); + eventRelayPublishState.delete(targetKey); + + if (selectedEventKey === targetKey) { + selectedEventKey = null; + renderDetail(null); + } + + renderKinds(); + renderEvents(); + } + + function renderDetail(evt) { + if (!evt) { + decryptAttemptSequence += 1; + currentDetailEvent = null; + divDetailMeta.textContent = 'Select an event from the middle column.'; + preEventJson.textContent = '{}'; + setDecryptedOutput('(none)', 'Decrypted content will appear here.'); + setDetailActionButtonsEnabled(false); + return; + } + + const dTag = getDTag(evt.tags); + const created = Number(evt.created_at || 0) > 0 ? new Date(Number(evt.created_at) * 1000).toLocaleString() : '-'; + divDetailMeta.textContent = `Kind ${evt.kind} · ${kindLabel(evt.kind)} · ${shortHex(evt.id)} · ${created}${dTag ? ` · d:${dTag}` : ''}`; + + const pretty = { + id: evt.id, + kind: evt.kind, + pubkey: evt.pubkey, + created_at: evt.created_at, + tags: evt.tags, + content: evt.content, + sig: evt.sig + }; + currentDetailEvent = pretty; + preEventJson.textContent = JSON.stringify(pretty, null, 2); + setDecryptedOutput('(none)', 'Decrypted content will appear here.'); + setDetailActionButtonsEnabled(true); + + const sequence = ++decryptAttemptSequence; + if (!isLikelyEncryptedEvent(pretty)) { + setDecryptedOutput('(none)', 'Content does not appear encrypted.'); + return; + } + + setDecryptedOutput('(decrypting...)', 'Detected encrypted content. Attempting automatic decryption...'); + void autoDecryptDetailEvent(pretty, sequence); + } + + function clearKindSubscription() { + try { + if (selectedKindSub && typeof selectedKindSub.close === 'function') selectedKindSub.close(); + } catch (_error) { } + selectedKindSub = null; + } + + function startKindSubscription(kind) { + clearKindSubscription(); + if (!Number.isFinite(Number(kind)) || !currentPubkey) return; + selectedKindSub = subscribe({ kinds: [Number(kind)], authors: [currentPubkey], limit: 500 }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' }); + } + + async function selectKind(kind, options = {}) { + const { autoRefresh = true } = options; + + selectedKind = Number(kind); + selectedEventKey = null; + selectedEventKeys.clear(); + renderKinds(); + renderEvents(); + renderDetail(null); + startKindSubscription(selectedKind); + + const shouldAutoRefresh = autoRefresh + && Number.isFinite(Number(selectedKind)) + && !autoRefreshedKinds.has(Number(selectedKind)); + + if (shouldAutoRefresh) { + await refreshKindFromRelays(selectedKind); + autoRefreshedKinds.add(Number(selectedKind)); + } + } + + async function loadKindsFromCache() { + const rows = await getAllEvents(); + eventsByKind.clear(); + eventRelayPresence.clear(); + eventRelayPublishState.clear(); + for (const row of rows) { + const evt = parseCachedEventRow(row); + if (!evt || !evt.id || !Number.isFinite(Number(evt.kind))) continue; + if (evt.pubkey && currentPubkey && evt.pubkey !== currentPubkey) continue; + upsertEvent(evt); + } + + renderKinds(); + renderEvents(); + + if (!Number.isFinite(Number(selectedKind))) { + const kinds = getKindsSorted(); + if (kinds.length) await selectKind(kinds[0]); + } + } + + async function refreshKindFromRelays(kind) { + if (!currentPubkey || !Number.isFinite(Number(kind))) return; + + const targetKind = Number(kind); + const beforeCount = getEventsForKind(targetKind).length; + divKindsStatus.textContent = `Refreshing kind ${targetKind} from relays...`; + + refreshKindDebugInFlight = true; + console.group(`[event-management] Refresh Kind ${targetKind}`); + console.log('[event-management] refresh:start', { + kind: targetKind, + pubkey: currentPubkey, + beforeCount + }); + + try { + const relayData = await getRelayData(); + console.log('[event-management] refresh:getRelayData result', { + totalRows: Array.isArray(relayData) ? relayData.length : 0, + rows: relayData + }); + + setSubscribedRelayUrlsFromRelayData(relayData); + const relayUrls = [...subscribedRelayUrls]; + console.log('[event-management] refresh:relayUrls scoped for query', relayUrls); + + if (!relayUrls.length) { + divKindsStatus.textContent = `Kind ${targetKind}: no relays found in kind 10002 relay list`; + console.warn('[event-management] refresh:abort no relayUrls after scoping'); + return; + } + + let completedQueries = 0; + + const queryResults = await Promise.all(relayUrls.map(async (relayUrl, index) => { + const relayKindEvents = getEventsForKind(targetKind); + const checkedKeys = new Set(relayKindEvents.map((evt) => eventKeyFor(evt))); + const seenKeys = new Set(); + + for (const evt of relayKindEvents) { + markRelayPublishState(eventKeyFor(evt), relayUrl, 'checking'); + } + syncBrailleSpinnerAnimation(); + if (Number(selectedKind) === targetKind) renderEvents(); + + divKindsStatus.textContent = `Refreshing kind ${targetKind}: querying relay ${index + 1}/${relayUrls.length} (${shortRelayLabel(relayUrl)})...`; + const startedAt = Date.now(); + console.log('[event-management] refresh:query:start', { + index: index + 1, + total: relayUrls.length, + relayUrl, + filter: { + kinds: [targetKind], + authors: [currentPubkey], + limit: 2000 + } + }); + + let acceptedForUser = 0; + let ok = false; + + try { + const events = await fetchEvents({ + kinds: [targetKind], + authors: [currentPubkey], + limit: 2000 + }, relayUrl); + + const fetchedEvents = Array.isArray(events) ? events : []; + + for (const evt of fetchedEvents) { + if (!evt || !evt.id || !Number.isFinite(Number(evt.kind))) continue; + if (String(evt.pubkey || '') !== String(currentPubkey)) continue; + + const normalizedKind = Number(evt.kind); + const normalized = { + id: normalizeDisplayId(String(evt.id || ''), normalizedKind), + kind: normalizedKind, + pubkey: String(evt.pubkey || ''), + created_at: Number(evt.created_at || 0), + content: String(evt.content || ''), + tags: Array.isArray(evt.tags) ? evt.tags : [], + sig: String(evt.sig || ''), + relay: String(relayUrl || '') + }; + upsertEvent(normalized); + const eventKey = eventKeyFor(normalized); + seenKeys.add(eventKey); + markRelayPresence(eventKey, relayUrl, true); + acceptedForUser += 1; + } + + console.log('[event-management] refresh:query:success', { + relayUrl, + fetchedCount: fetchedEvents.length, + acceptedForUser, + elapsedMs: Date.now() - startedAt + }); + ok = true; + } catch (error) { + console.error('[event-management] refresh:query:error', { + relayUrl, + elapsedMs: Date.now() - startedAt, + error: error?.message || String(error) + }); + } finally { + const relayKindEventsAfter = getEventsForKind(targetKind); + for (const evt of relayKindEventsAfter) { + const key = eventKeyFor(evt); + const wasCheckedBefore = checkedKeys.has(key); + const isPresentFromFetch = seenKeys.has(key); + + if (wasCheckedBefore) { + markRelayPresence(key, relayUrl, isPresentFromFetch); + } + + const relayMap = ensureRelayMapForEvent(key); + if (!relayMap.get(relayUrl)) markRelayPublishState(key, relayUrl, 'idle'); + } + + completedQueries += 1; + divKindsStatus.textContent = `Refreshing kind ${targetKind}: ${completedQueries}/${relayUrls.length} relays done...`; + syncBrailleSpinnerAnimation(); + if (Number(selectedKind) === targetKind) renderEvents(); + } + + return { + ok, + relayUrl, + acceptedForUser + }; + })); + + const queried = relayUrls.length; + const successQueries = queryResults.reduce((n, r) => n + (r.ok ? 1 : 0), 0); + const failedQueries = queried - successQueries; + + const afterCount = getEventsForKind(targetKind).length; + const added = Math.max(0, afterCount - beforeCount); + + renderKinds(); + if (Number(selectedKind) === targetKind) { + renderEvents(); + if (selectedEventKey) { + const selected = findEventByKey(selectedEventKey); + if (selected) renderDetail(selected); + } + } + + divKindsStatus.textContent = `Kind ${targetKind} refreshed from ${queried}/${relayUrls.length} relays (${successQueries} ok, ${failedQueries} failed) · +${added} events (${afterCount.toLocaleString()} total)`; + console.log('[event-management] refresh:done', { + kind: targetKind, + queried, + successQueries, + failedQueries, + beforeCount, + afterCount, + added + }); + } catch (error) { + divKindsStatus.textContent = `Kind ${targetKind} refresh failed: ${error?.message || String(error)}`; + console.error('[event-management] refresh:fatal', { + kind: targetKind, + error: error?.message || String(error) + }); + } finally { + refreshKindDebugInFlight = false; + syncBrailleSpinnerAnimation(); + console.groupEnd(); + } + } + + function initHamburgerMenu() { + hamburgerInstance = new HamburgerMorphing('#divSvgHam', { + foreground: 'var(--primary-color)', + background: 'var(--secondary-color)', + hover: 'var(--accent-color)' + }); + hamburgerInstance.animateTo('burger'); + } + + function openNav() { + divSideNav.style.zIndex = 3; + divSideNav.style.width = 'clamp(400px, 50vw, 600px)'; + isNavOpen = true; + if (hamburgerInstance) hamburgerInstance.animateTo('arrow_left'); + + if (!logoutHamburger) { + logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', { + size: 24, + foreground: 'var(--primary-color)', + background: 'var(--secondary-color)', + hover: 'var(--accent-color)' + }); + logoutHamburger.animateTo('x'); + } + + if (!themeToggleHamburger) { + themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', { + size: 24, + foreground: 'var(--primary-color)', + background: 'var(--secondary-color)', + hover: 'var(--accent-color)' + }); + const savedTheme = localStorage.getItem('theme'); + isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode'); + themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle'); + } + } + + function closeNav() { + divSideNav.style.width = '0vw'; + divSideNav.style.zIndex = -1; + isNavOpen = false; + if (hamburgerInstance) hamburgerInstance.animateTo('burger'); + } + + function toggleNav() { + if (isNavOpen) closeNav(); + else openNav(); + } + + async function updateFooter() { + try { + await updateFooterRelayStatus(); + await updateSidenavRelaySection(); + await updateBlossomSection(); + divFooterCenter.innerHTML = ''; + divFooterRight.innerHTML = ''; + } catch (error) { + console.error('[event-management.html] footer update error:', error); + } + } + + async function logout() { + if (updateIntervalId) { + clearInterval(updateIntervalId); + updateIntervalId = null; + } + clearKindSubscription(); + disconnect(); + if (window.NOSTR_LOGIN_LITE?.logout) await window.NOSTR_LOGIN_LITE.logout(); + localStorage.clear(); + sessionStorage.clear(); + if (window.indexedDB) { + const databases = await window.indexedDB.databases(); + for (const dbInfo of databases) if (dbInfo.name) window.indexedDB.deleteDatabase(dbInfo.name); + } + location.reload(true); + } + + function wireEvents() { + document.getElementById('divSvgHam')?.addEventListener('click', toggleNav); + + const themeToggleButton = document.getElementById('themeToggleButton'); + const logoutButton = document.getElementById('logoutButton'); + + themeToggleButton?.addEventListener('click', () => { + isDarkMode = !isDarkMode; + localStorage.setItem('theme', isDarkMode ? 'dark' : 'light'); + localStorage.setItem('sidenavWasOpen', isNavOpen ? 'true' : 'false'); + window.location.reload(); + }); + + logoutButton?.addEventListener('click', async () => { + try { await logout(); } catch (error) { console.error('[event-management.html] logout failed', error); } + }); + + btnRefreshKinds.addEventListener('click', async () => { + try { + divKindsStatus.textContent = 'Refreshing cache...'; + await loadKindsFromCache(); + } catch (error) { + divKindsStatus.textContent = `Refresh failed: ${error?.message || String(error)}`; + } + }); + + inpEventSearch.addEventListener('input', renderEvents); + chkShowSuperseded?.addEventListener('change', renderEvents); + + chkSelectAllEvents.addEventListener('change', () => { + const { visibleEvents } = getFilteredEventsForSelectedKind(); + if (chkSelectAllEvents.checked) { + for (const evt of visibleEvents) selectedEventKeys.add(eventKeyFor(evt)); + } else { + for (const evt of visibleEvents) selectedEventKeys.delete(eventKeyFor(evt)); + } + renderEvents(); + }); + + btnCopyEventId?.addEventListener('click', async () => { + if (!currentDetailEvent?.id) return; + try { + await navigator.clipboard.writeText(String(currentDetailEvent.id)); + divKindsStatus.textContent = 'Copied event ID to clipboard'; + } catch (_error) { + divKindsStatus.textContent = 'Copy event ID failed'; + } + }); + + btnCopyEventJson?.addEventListener('click', async () => { + if (!currentDetailEvent) return; + try { + await navigator.clipboard.writeText(JSON.stringify(currentDetailEvent, null, 2)); + divKindsStatus.textContent = 'Copied event JSON to clipboard'; + } catch (_error) { + divKindsStatus.textContent = 'Copy event JSON failed'; + } + }); + + btnDeleteEvent?.addEventListener('click', async () => { + if (!currentDetailEvent?.id) return; + + const ok = window.confirm(`Publish deletion event for ${shortHex(String(currentDetailEvent.id || ''))}?`); + if (!ok) return; + + try { + const deletionEvent = { + kind: 5, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['e', String(currentDetailEvent.id || '')], + ['k', String(Number(currentDetailEvent.kind || 0))] + ], + content: '' + }; + + await publishEvent(deletionEvent); + const deletedEventId = String(currentDetailEvent.id || ''); + const deletedEventKind = Number(currentDetailEvent.kind || 0); + removeEventFromUiState(deletedEventKind, deletedEventId); + divKindsStatus.textContent = `Published deletion request for ${shortHex(deletedEventId)}`; + } catch (error) { + divKindsStatus.textContent = `Delete event failed: ${error?.message || String(error)}`; + } + }); + + btnBlockEvent?.addEventListener('click', async () => { + if (!currentDetailEvent?.id) return; + + const targetEventId = String(currentDetailEvent.id || ''); + const ok = window.confirm(`Add event ${shortHex(targetEventId)} to mute/block list?`); + if (!ok) return; + + try { + await addMute('e', targetEventId, false); + divKindsStatus.textContent = `Blocked event ${shortHex(targetEventId)} via mute list`; + } catch (error) { + divKindsStatus.textContent = `Block event failed: ${error?.message || String(error)}`; + } + }); + + btnRemoveEventFromCache?.addEventListener('click', async () => { + if (!currentDetailEvent?.id || !Number.isFinite(Number(currentDetailEvent?.kind))) return; + + const targetEventId = String(currentDetailEvent.id || ''); + const targetKind = Number(currentDetailEvent.kind || 0); + const ok = window.confirm(`Remove event ${shortHex(targetEventId)} (kind ${targetKind}) from cache?`); + if (!ok) return; + + try { + const removed = await removeEventFromCache(currentDetailEvent); + removeEventFromUiState(targetKind, targetEventId); + divKindsStatus.textContent = `Removed ${removed} cached row(s) for ${shortHex(targetEventId)}`; + } catch (error) { + divKindsStatus.textContent = `Remove from cache failed: ${error?.message || String(error)}`; + } + }); + + btnPublishSelectedMissing?.addEventListener('click', async () => { + const keys = Array.from(selectedEventKeys); + if (!keys.length) { + divKindsStatus.textContent = 'No selected events'; + return; + } + + const jobs = []; + for (const key of keys) { + const evt = findEventByKey(key); + if (!evt) continue; + const relayMap = ensureRelayMapForEvent(key); + for (const relayUrl of subscribedRelayUrls) { + if (!relayMap.get(relayUrl)) { + jobs.push({ key, relayUrl, evt }); + } + } + } + + if (!jobs.length) { + divKindsStatus.textContent = 'Selected events already present on all relays'; + return; + } + + for (const job of jobs) markRelayPublishState(job.key, job.relayUrl, 'publishing'); + renderEvents(); + + let successCount = 0; + let failedCount = 0; + let completed = 0; + + await Promise.all(jobs.map(async (job) => { + try { + const payload = { + id: String(job.evt.id || ''), + pubkey: String(job.evt.pubkey || ''), + created_at: Number(job.evt.created_at || 0), + kind: Number(job.evt.kind), + tags: Array.isArray(job.evt.tags) ? job.evt.tags : [], + content: String(job.evt.content || ''), + sig: String(job.evt.sig || '') + }; + + const response = await publishRawEventToRelay(payload, job.relayUrl); + const successful = Array.isArray(response?.relayResults?.successful) + ? response.relayResults.successful.map((x) => String(x || '')) + : []; + const accepted = successful.includes(job.relayUrl) || Boolean(response?.success); + + if (accepted) { + markRelayPresence(job.key, job.relayUrl, true); + markRelayPublishState(job.key, job.relayUrl, 'idle'); + successCount += 1; + } else { + markRelayPublishState(job.key, job.relayUrl, 'failed'); + failedCount += 1; + } + } catch (_error) { + markRelayPublishState(job.key, job.relayUrl, 'failed'); + failedCount += 1; + } finally { + completed += 1; + divKindsStatus.textContent = `Publishing selected events: ${completed}/${jobs.length} done (${successCount} ok, ${failedCount} failed)`; + renderEvents(); + } + })); + + divKindsStatus.textContent = `Publishing selected events done: ${successCount} ok, ${failedCount} failed`; + }); + + divEventsList.addEventListener('click', async (event) => { + const chip = event.target?.closest?.('[data-relay-chip]'); + if (!chip) return; + + const eventKey = String(chip.getAttribute('data-event-key') || ''); + const relayUrl = String(chip.getAttribute('data-relay-url') || ''); + if (!eventKey || !relayUrl) return; + + const evt = findEventByKey(eventKey); + if (!evt) return; + + const relayMap = ensureRelayMapForEvent(eventKey); + if (relayMap.get(relayUrl)) return; + + const relayStateMap = ensureRelayPublishStateForEvent(eventKey); + if (relayStateMap.get(relayUrl) === 'publishing') return; + + markRelayPublishState(eventKey, relayUrl, 'publishing'); + renderEvents(); + + try { + const payload = { + id: String(evt.id || ''), + pubkey: String(evt.pubkey || ''), + created_at: Number(evt.created_at || 0), + kind: Number(evt.kind), + tags: Array.isArray(evt.tags) ? evt.tags : [], + content: String(evt.content || ''), + sig: String(evt.sig || '') + }; + + const response = await publishRawEventToRelay(payload, relayUrl); + const successful = Array.isArray(response?.relayResults?.successful) + ? response.relayResults.successful.map((x) => String(x || '')) + : []; + + const accepted = successful.includes(relayUrl) || Boolean(response?.success); + if (accepted) { + markRelayPresence(eventKey, relayUrl, true); + markRelayPublishState(eventKey, relayUrl, 'idle'); + } else { + markRelayPublishState(eventKey, relayUrl, 'failed'); + } + } catch (_error) { + markRelayPublishState(eventKey, relayUrl, 'failed'); + } + + renderEvents(); + }); + + window.addEventListener('ndkEvent', (event) => { + const evt = event?.detail; + if (!evt || !Number.isFinite(Number(evt.kind)) || !evt.id) return; + if (evt.pubkey !== currentPubkey) return; + + const parsedKind = Number(evt.kind); + const parsed = { + id: normalizeDisplayId(String(evt.id || ''), parsedKind), + kind: parsedKind, + pubkey: String(evt.pubkey || ''), + created_at: Number(evt.created_at || 0), + content: String(evt.content || ''), + tags: Array.isArray(evt.tags) ? evt.tags : [], + sig: String(evt.sig || ''), + relay: '' + }; + + upsertEvent(parsed); + renderKinds(); + + if (Number(selectedKind) === Number(parsed.kind)) { + renderEvents(); + if (selectedEventKey === eventKeyFor(parsed)) renderDetail(parsed); + } + }); + + window.addEventListener('ndkRelayActivity', (event) => { + const { relayUrl, activity, stats } = event.detail || {}; + if (relayUrl && activity) { + setRelayActivityState(relayUrl, activity); + if (refreshKindDebugInFlight) { + console.log('[event-management] ndkRelayActivity during refresh', { + relayUrl, + activity, + stats + }); + } + } + }); + } + + async function main() { + try { + initHamburgerMenu(); + wireEvents(); + + await initNDKPage(); + currentPubkey = await getPubkey(); + await injectHeaderAvatar(currentPubkey); + + configureMuteList({ + ndkFetchEvents, + queryCache, + publishEvent, + getPubkey, + nip44Encrypt: async (recipientPubkey, plaintext) => { + if (window?.nostr?.nip44?.encrypt) { + return await window.nostr.nip44.encrypt(recipientPubkey, plaintext); + } + if (window?.nostr?.nip04?.encrypt) { + return await window.nostr.nip04.encrypt(recipientPubkey, plaintext); + } + throw new Error('No encryption support available in signer'); + }, + nip44Decrypt: async (senderPubkey, ciphertext) => { + const content = String(ciphertext || ''); + if (content.includes('?iv=')) { + if (!window?.nostr?.nip04?.decrypt) throw new Error('NIP-04 decrypt unavailable'); + return await window.nostr.nip04.decrypt(senderPubkey, content); + } + if (window?.nostr?.nip44?.decrypt) { + return await window.nostr.nip44.decrypt(senderPubkey, content); + } + if (window?.nostr?.nip04?.decrypt) { + return await window.nostr.nip04.decrypt(senderPubkey, content); + } + throw new Error('No decryption support available in signer'); + } + }); + + await loadMuteList().catch((error) => { + console.warn('[event-management] loadMuteList failed:', error?.message || error); + }); + + setSubscribedRelayUrlsFromRelayData(await getRelayData()); + await loadKindsFromCache(); + + initFooterRelayStatus(); + initSidenavRelaySection(); + await initBlossomSection(); + initAiSectionWithLocalConfig(); + await updateFooter(); + updateIntervalId = setInterval(updateFooter, 1000); + + const sidenavWasOpen = localStorage.getItem('sidenavWasOpen'); + if (sidenavWasOpen === 'true') { + localStorage.removeItem('sidenavWasOpen'); + openNav(); + } + + await updateVersionDisplay(); + } catch (error) { + console.error('[event-management.html] Initialization failed:', error); + divBody.innerHTML = `
`; + } + } + + main(); + \ No newline at end of file diff --git a/build/.feed-check.mjs b/build/.feed-check.mjs new file mode 100644 index 0000000..19f692f --- /dev/null +++ b/build/.feed-check.mjs @@ -0,0 +1,369 @@ + + import { + initNDKPage, + getPubkey, + injectHeaderAvatar, + subscribe, + disconnect, + getVersion, + queryCache, + fetchEventsFromAllRelays, + fetchCachedProfile, + storeProfile, + publishEvent, + getUserSettings, + patchUserSettings, + onUserSettings + } from './js/init-ndk.mjs'; + import { HamburgerMorphing } from './hamburger_morphing/hamburger.mjs'; + import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs'; + import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs'; + import { initPostCards } from './js/post-interactions2.mjs'; + + const INITIAL_POSTS_LOAD = 10; + const SEE_MORE_INCREMENT = 20; + + let currentPubkey = null; + let updateIntervalId = null; + + let unsubscribeUserSettings = null; + + let hamburgerInstance = null; + let logoutHamburger = null; + let themeToggleHamburger = null; + let isDarkMode = false; + let isNavOpen = false; + + const posts = []; + const postIds = new Set(); + const renderedPostIds = new Set(); + let displayCount = INITIAL_POSTS_LOAD; + + let followedPubkeys = []; + const feedPubkeys = new Set(); + + const divBody = document.getElementById('divBody'); + const divSideNav = document.getElementById('divSideNav'); + const divFooterRight = document.getElementById('divFooterRight'); + const divFeed = document.getElementById('divFeed'); + const divHint = document.getElementById('divHint'); + const btnSeeMore = document.getElementById('btnSeeMore'); + const chkAutoplayVideos = document.getElementById('chkAutoplayVideos'); + + let autoplayVideosEnabled = false; + let liveFeedSubscriptionKey = ''; + + const cards = initPostCards({ + subscribe, + publishEvent, + getPubkey, + fetchEventsFromAllRelays, + fetchCachedProfile, + storeProfile + }); + + function initHamburgerMenu() { + hamburgerInstance = new HamburgerMorphing('#divSvgHam', { + foreground: 'var(--primary-color)', + background: 'var(--secondary-color)', + hover: 'var(--accent-color)' + }); + hamburgerInstance.animateTo('burger'); + } + + function openNav() { + divSideNav.style.zIndex = 3; + divSideNav.style.width = '50vw'; + isNavOpen = true; + if (hamburgerInstance) hamburgerInstance.animateTo('arrow_left'); + + if (!logoutHamburger) { + logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', { + size: 24, + foreground: 'var(--primary-color)', + background: 'var(--secondary-color)', + hover: 'var(--accent-color)' + }); + logoutHamburger.animateTo('x'); + } + + if (!themeToggleHamburger) { + themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', { + size: 24, + foreground: 'var(--primary-color)', + background: 'var(--secondary-color)', + hover: 'var(--accent-color)' + }); + const savedTheme = localStorage.getItem('theme'); + isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode'); + themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle'); + } + } + + function closeNav() { + divSideNav.style.width = '0vw'; + divSideNav.style.zIndex = -1; + isNavOpen = false; + if (hamburgerInstance) hamburgerInstance.animateTo('burger'); + } + + function toggleNav() { + isNavOpen ? closeNav() : openNav(); + } + + function isOriginalPost(event) { + const eTags = event.tags?.filter(t => t[0] === 'e') || []; + if (eTags.length === 0) return true; + return eTags.every(t => t[3] === 'mention'); + } + + function applyVideoAutoplaySetting(enabled) { + autoplayVideosEnabled = !!enabled; + if (chkAutoplayVideos) chkAutoplayVideos.checked = autoplayVideosEnabled; + + const videoEls = Array.from(divFeed.querySelectorAll('video')); + videoEls.forEach((videoEl) => { + videoEl.controls = true; + videoEl.playsInline = true; + videoEl.setAttribute('playsinline', ''); + videoEl.preload = 'metadata'; + + if (autoplayVideosEnabled) { + videoEl.autoplay = true; + videoEl.muted = true; + videoEl.setAttribute('autoplay', ''); + videoEl.setAttribute('muted', ''); + const playPromise = videoEl.play?.(); + if (playPromise?.catch) playPromise.catch(() => {}); + } else { + videoEl.autoplay = false; + videoEl.muted = false; + videoEl.removeAttribute('autoplay'); + videoEl.removeAttribute('muted'); + } + }); + } + + function applySettings(settings) { + const nextAutoplayEnabled = !!settings?.feed?.videoAutoplay; + applyVideoAutoplaySetting(nextAutoplayEnabled); + } + + async function persistAutoplaySetting(enabled) { + try { + await patchUserSettings({ + feed: { + videoAutoplay: !!enabled + } + }); + } catch (error) { + console.warn('[feed.html] Failed to persist feed autoplay setting:', error?.message || error); + } + } + + function renderFeed() { + posts.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); + const toShow = posts.slice(0, displayCount); + + toShow.forEach((post) => { + if (renderedPostIds.has(post.id)) return; + const postEl = cards.createCard(post, { currentPubkey, autoWire: false, autoplayVideo: autoplayVideosEnabled }); + divFeed.appendChild(postEl); + renderedPostIds.add(post.id); + }); + + cards.wireInteractions(toShow.map(p => p.id), { currentPubkey }); + + btnSeeMore.style.display = posts.length > displayCount ? 'block' : 'none'; + divHint.textContent = followedPubkeys.length > 0 + ? `${posts.length} posts from ${followedPubkeys.length} follows` + : 'Follow users to populate your feed.'; + divFooterRight.textContent = `${posts.length} posts`; + } + + function ensureLiveFeedSubscription() { + const authors = Array.from(feedPubkeys).filter(Boolean); + if (authors.length === 0) return; + + const key = authors.slice().sort().join(','); + if (key === liveFeedSubscriptionKey) return; + + const newestKnown = posts.reduce((max, p) => Math.max(max, p?.created_at || 0), 0); + const now = Math.floor(Date.now() / 1000); + const since = newestKnown > 0 ? Math.max(0, newestKnown - 30) : (now - 3600); + + subscribe( + { kinds: [1], authors, since }, + { closeOnEose: false, cacheUsage: 'CACHE_FIRST' } + ); + + liveFeedSubscriptionKey = key; + console.log('[feed.html] Live feed subscription active', { authors: authors.length, since }); + } + + async function ingestContactList(evt) { + if (!evt || evt.kind !== 3 || evt.pubkey !== currentPubkey) return; + const pTags = evt.tags?.filter(tag => tag[0] === 'p' && tag[1]) || []; + followedPubkeys = pTags.map(tag => tag[1]); + + const newAuthors = followedPubkeys.filter((pk) => !feedPubkeys.has(pk)); + newAuthors.forEach((pk) => feedPubkeys.add(pk)); + + ensureLiveFeedSubscription(); + + if (newAuthors.length > 0) { + try { + const [cached, freshByRelay] = await Promise.all([ + queryCache({ kinds: [1], authors: Array.from(feedPubkeys), limit: 120 }).catch(() => []), + fetchEventsFromAllRelays({ kinds: [1], authors: newAuthors, limit: INITIAL_POSTS_LOAD }).catch(() => ({})) + ]); + + cached.forEach((evt1) => { + if (!evt1?.id || postIds.has(evt1.id) || !isOriginalPost(evt1)) return; + postIds.add(evt1.id); + posts.push(evt1); + }); + + Object.values(freshByRelay).flat().forEach((evt1) => { + if (!evt1?.id || postIds.has(evt1.id) || !isOriginalPost(evt1)) return; + postIds.add(evt1.id); + posts.push(evt1); + }); + + renderFeed(); + } catch (_) { + renderFeed(); + } + } else { + renderFeed(); + } + } + + async function logout() { + if (updateIntervalId) { + clearInterval(updateIntervalId); + updateIntervalId = null; + } + cards.destroy(); + disconnect(); + if (window.NOSTR_LOGIN_LITE?.logout) { + await window.NOSTR_LOGIN_LITE.logout(); + } + if (unsubscribeUserSettings) { + unsubscribeUserSettings(); + unsubscribeUserSettings = null; + } + localStorage.clear(); + sessionStorage.clear(); + location.reload(true); + } + + async function updateFooter() { + try { + await updateFooterRelayStatus(); + await updateSidenavRelaySection(); + await updateBlossomSection(); + } catch (_) {} + } + + (async function main() { + try { + const versionInfo = await getVersion(); + document.getElementById('versionDisplay').textContent = versionInfo.VERSION; + + initHamburgerMenu(); + document.getElementById('divSvgHam')?.addEventListener('click', toggleNav); + + document.getElementById('themeToggleButton')?.addEventListener('click', () => { + isDarkMode = !isDarkMode; + localStorage.setItem('theme', isDarkMode ? 'dark' : 'light'); + localStorage.setItem('sidenavWasOpen', isNavOpen ? 'true' : 'false'); + window.location.reload(); + }); + + document.getElementById('logoutButton')?.addEventListener('click', logout); + + document.getElementById('commentsToggleButton')?.addEventListener('click', () => { + const nextVisible = !cards.getCommentsVisible(); + cards.setCommentsVisible(nextVisible); + document.getElementById('commentsToggleButton')?.classList.toggle('dimmed', !nextVisible); + }); + + btnSeeMore.addEventListener('click', () => { + displayCount += SEE_MORE_INCREMENT; + renderFeed(); + }); + + chkAutoplayVideos?.addEventListener('change', async () => { + const nextEnabled = !!chkAutoplayVideos.checked; + applyVideoAutoplaySetting(nextEnabled); + await persistAutoplaySetting(nextEnabled); + }); + + await initNDKPage(); + currentPubkey = await getPubkey(); + await injectHeaderAvatar(currentPubkey); + + try { + const settings = await getUserSettings(); + applySettings(settings || {}); + } catch (error) { + console.warn('[feed.html] Failed to load user settings:', error?.message || error); + applySettings({}); + } + + if (!unsubscribeUserSettings) { + unsubscribeUserSettings = onUserSettings((settings) => { + applySettings(settings || {}); + }); + } + + initFooterRelayStatus(); + initSidenavRelaySection(); + await initBlossomSection(); + await updateFooter(); + updateIntervalId = setInterval(updateFooter, 1000); + + window.addEventListener('ndkRelayActivity', (e) => { + setRelayActivityState(e.detail.relayUrl, e.detail.activity); + }); + + window.addEventListener('ndkEvent', async (e) => { + const evt = e.detail; + if (!evt) return; + + if (evt.kind === 3 && evt.pubkey === currentPubkey) { + await ingestContactList(evt); + return; + } + + if (evt.kind === 1 && feedPubkeys.has(evt.pubkey) && isOriginalPost(evt) && !postIds.has(evt.id)) { + postIds.add(evt.id); + posts.push(evt); + renderFeed(); + } + }); + + subscribe({ kinds: [3], authors: [currentPubkey], limit: 1 }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' }); + + const cachedContactEvents = await queryCache({ kinds: [3], authors: [currentPubkey], limit: 1 }).catch(() => []); + if (cachedContactEvents.length > 0) { + const latest = cachedContactEvents.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0]; + await ingestContactList(latest); + } + + if (feedPubkeys.size === 0) { + divHint.textContent = 'Follow users to populate your feed.'; + } + + const sidenavWasOpen = localStorage.getItem('sidenavWasOpen'); + if (sidenavWasOpen === 'true') { + localStorage.removeItem('sidenavWasOpen'); + openNav(); + } + } catch (error) { + console.error('[feed.html] Initialization failed:', error); + divBody.innerHTML = `
`; + } + })(); + \ No newline at end of file diff --git a/build/.html-tv-inline-check.mjs b/build/.html-tv-inline-check.mjs new file mode 100644 index 0000000..59a1b33 --- /dev/null +++ b/build/.html-tv-inline-check.mjs @@ -0,0 +1,1948 @@ + + import { + initNDKPage, + getPubkey, injectHeaderAvatar, + disconnect, + getVersion, + updateVersionDisplay, + getUserSettings, + patchUserSettings, + onUserSettings, + subscribe, + publishEvent, + fetchCachedProfile + } from './js/init-ndk.mjs'; + import { HamburgerMorphing } from './hamburger_morphing/hamburger.mjs'; + import { + initFooterRelayStatus, + updateFooterRelayStatus, + initSidenavRelaySection, + updateSidenavRelaySection, + setRelayActivityState + } from './js/relay-ui.mjs'; + import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs'; + + import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs'; +import { uploadToServer, getDefaultBlossomServer } from './js/blossom-api.mjs'; + const AI_CONFIG_KEY = 'ai_chat_openai_config_v1'; + + let updateIntervalId = null; + let currentPubkey = null; + let hamburgerInstance = null; + let isNavOpen = false; + let logoutHamburger = null; + let themeToggleHamburger = null; + let isDarkMode = false; + + const DEFAULT_TV_SYSTEM_PROMPT = 'You are a web page generator. You must respond to ALL requests with a complete, valid HTML page. Your response must be ONLY the HTML — no markdown, no explanation, no code fences. The page should be self-contained with inline CSS and JavaScript as needed.'; + const DEFAULT_SKILL_TEMPLATE = `system: +${DEFAULT_TV_SYSTEM_PROMPT} + +user: +{{message}}`; + + let isSending = false; + let skills = []; + let skillFilterMode = 'all'; + let selectedSkillKey = null; + let skillSubscription = null; + let skillSubId = null; + let renderedPages = []; + let selectedRenderedPageId = null; + const authorProfiles = new Map(); + const profileLookupInFlight = new Set(); + let aiConfig = { + provider: 'ppq.ai', + api_key: '', + credit_id: '', + model: 'claude-haiku-4.5', + base_url: 'https://api.ppq.ai', + max_tokens: 512, + temperature: 0.7, + providers: [ + { name: 'ppq.ai', url: 'https://api.ppq.ai' }, + { name: 'Routstr', url: 'https://api.routstr.com' } + ], + favorites: [] + }; + + let fetchedModels = []; + let fetchModels = async () => {}; + let activeInvoicePollId = 0; + let previewPopoutWindow = null; + + const divSideNav = document.getElementById('divSideNav'); + const divSideNavBody = document.getElementById('divSideNavBody'); + const divFooterCenter = document.getElementById('divFooterCenter'); + const divFooterRight = document.getElementById('divFooterRight'); + const divFooterBalance = document.getElementById('divFooterBalance'); + + const divAiConversationsList = document.getElementById('divAiConversationsList'); + const divAiMessages = document.getElementById('divAiMessages'); + const divAiStatus = document.getElementById('divAiStatus'); + const divAiProviderBalanceHeader = document.getElementById('divAiProviderBalanceHeader'); + const iframeAiTv = document.getElementById('iframeAiTv'); + const divAiViewerLoading = document.getElementById('divAiViewerLoading'); + + const selAiProvider = document.getElementById('selAiProvider'); + const selAiModel = document.getElementById('selAiModel'); + const taAiPrompt = document.getElementById('taAiPrompt'); + const taAiSystemPrompt = document.getElementById('taAiSystemPrompt'); + const divAiInputArea = document.getElementById('divAiInputArea'); + const btnAiRun = document.getElementById('btnAiRun'); + const btnAiSaveSkill = document.getElementById('btnAiSaveSkill'); + const btnSkillFilterAll = document.getElementById('btnSkillFilterAll'); + const btnSkillFilterMy = document.getElementById('btnSkillFilterMy'); + const btnSkillNew = document.getElementById('btnSkillNew'); + const btnSkillDelete = document.getElementById('btnSkillDelete'); + const btnSkillRefresh = document.getElementById('btnSkillRefresh'); + const inpSkillSlug = document.getElementById('inpSkillSlug'); + const inpSkillDescription = document.getElementById('inpSkillDescription'); + const taSkillTemplate = document.getElementById('taSkillTemplate'); + const inpSkillTemperature = document.getElementById('inpSkillTemperature'); + const inpSkillMaxTokens = document.getElementById('inpSkillMaxTokens'); + const inpSkillSeed = document.getElementById('inpSkillSeed'); + const btnAiSaveRenderedPage = document.getElementById('btnAiSaveRenderedPage'); + const btnAiPopoutPreview = document.getElementById('btnAiPopoutPreviewOverlay'); + const lnkAiLaunchRenderedPage = document.getElementById('lnkAiLaunchRenderedPage'); + + const selCfgProvider = document.getElementById('selCfgProvider'); + const inpCfgBaseUrl = document.getElementById('inpCfgBaseUrl'); + const inpCfgApiKey = document.getElementById('inpCfgApiKey'); + const inpCfgCreditId = document.getElementById('inpCfgCreditId'); + const divPpqCreditIdGroup = document.getElementById('divPpqCreditIdGroup'); + const selCfgModel = document.getElementById('selCfgModel'); + const inpCfgModelFilter = document.getElementById('inpCfgModelFilter'); + const btnCfgFav = document.getElementById('btnCfgFav'); + const ddAiModel = document.getElementById('ddAiModel'); + const btnAiModelDropdown = document.getElementById('btnAiModelDropdown'); + const inpAiModelFilter = document.getElementById('inpAiModelFilter'); + const divAiModelOptions = document.getElementById('divAiModelOptions'); + const ddCfgModel = document.getElementById('ddCfgModel'); + const btnCfgModelDropdown = document.getElementById('btnCfgModelDropdown'); + const divCfgModelOptions = document.getElementById('divCfgModelOptions'); + const inpCfgMaxTokens = document.getElementById('inpCfgMaxTokens'); + const inpCfgTemperature = document.getElementById('inpCfgTemperature'); + const btnCfgSave = document.getElementById('btnCfgSave'); + const btnCfgCopyApiKey = document.getElementById('btnCfgCopyApiKey'); + + const btnRoutstrGetBalance = document.getElementById('btnRoutstrGetBalance'); + const divRoutstrBalanceState = document.getElementById('divRoutstrBalanceState'); + const inpRoutstrDepositSats = document.getElementById('inpRoutstrDepositSats'); + const btnRoutstrCreateInvoice = document.getElementById('btnRoutstrCreateInvoice'); + const inpRoutstrDepositCashu = document.getElementById('inpRoutstrDepositCashu'); + const btnRoutstrImportCashu = document.getElementById('btnRoutstrImportCashu'); + const inpRoutstrTopupSats = document.getElementById('inpRoutstrTopupSats'); + const btnRoutstrTopupInvoice = document.getElementById('btnRoutstrTopupInvoice'); + const inpRoutstrTopupCashu = document.getElementById('inpRoutstrTopupCashu'); + const btnRoutstrTopupCashu = document.getElementById('btnRoutstrTopupCashu'); + const btnRoutstrRefund = document.getElementById('btnRoutstrRefund'); + const taRoutstrRefundToken = document.getElementById('taRoutstrRefundToken'); + const divRoutstrDepositInvoiceMeta = document.getElementById('divRoutstrDepositInvoiceMeta'); + const divRoutstrDepositInvoiceStatus = document.getElementById('divRoutstrDepositInvoiceStatus'); + const divRoutstrDepositInvoiceQr = document.getElementById('divRoutstrDepositInvoiceQr'); + const taRoutstrDepositInvoiceBolt11 = document.getElementById('taRoutstrDepositInvoiceBolt11'); + const divRoutstrTopupInvoiceMeta = document.getElementById('divRoutstrTopupInvoiceMeta'); + const divRoutstrTopupInvoiceStatus = document.getElementById('divRoutstrTopupInvoiceStatus'); + const divRoutstrTopupInvoiceQr = document.getElementById('divRoutstrTopupInvoiceQr'); + const taRoutstrTopupInvoiceBolt11 = document.getElementById('taRoutstrTopupInvoiceBolt11'); + const divRoutstrOpsStatus = document.getElementById('divRoutstrOpsStatus'); + const divRoutstrOnlySections = document.getElementById('divRoutstrOnlySections'); + + const markedLib = window.marked; + if (markedLib && markedLib.setOptions) { + markedLib.setOptions({ + breaks: true, + gfm: true + }); + } + + function uid() { + return `${Date.now()}_${Math.random().toString(16).slice(2)}`; + } + + function nowTs() { + return Date.now(); + } + + function escapeHtml(value) { + const span = document.createElement('span'); + span.textContent = String(value || ''); + return span.innerHTML; + } + + function extractHtmlFromResponse(rawContent) { + let html = String(rawContent || '').trim(); + const fencePattern = /^```(?:html)?\s*\n?([\s\S]*?)\n?\s*```$/i; + const match = html.match(fencePattern); + if (match && match[1]) html = match[1].trim(); + + const lower = html.toLowerCase(); + if (!lower.includes('
'); + html = `