From 8313f0cf12186b349623297e50cd3cc35b3dfc2c Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 12 May 2026 14:15:42 +0300 Subject: [PATCH] feat(desktop): add wallet column and enhanced zapping UX Phase 1: Wallet deck column with NWC integration - Add DeckColumnType.Wallet with full deck system integration (AppDrawer, ColumnHeader, DeckState persistence, MenuBar) - WalletColumnScreen with 4 sub-screens: Home (balance + actions), Connect (NWC URI paste), Send (pay BOLT11), Receive (create invoice) - Uses existing NwcPaymentHandler for payment execution Phase 2: Zapping UX improvements - Upgrade ZapAmountDialog with zap type selection (PUBLIC/PRIVATE/ANONYMOUS via FilterChips) - Add custom amount input alongside preset chips - One-click zap: left-click sends default amount via NWC, right-click opens custom zap dialog - Configurable zap amounts parameter (no longer hardcoded) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-05-09-embedded-local-relay-plan.md | 475 ++++++++++++ .../2026-05-11-wallet-zapping-brainstorm.md | 329 +++++++++ ...2026-05-12-wallet-zapping-phase1-2-plan.md | 456 ++++++++++++ .../vitorpamplona/amethyst/desktop/Main.kt | 1 + .../amethyst/desktop/ui/NoteActions.kt | 159 +++- .../amethyst/desktop/ui/deck/AppDrawer.kt | 2 + .../amethyst/desktop/ui/deck/ColumnHeader.kt | 1 + .../desktop/ui/deck/DeckColumnContainer.kt | 11 + .../desktop/ui/deck/DeckColumnType.kt | 4 + .../amethyst/desktop/ui/deck/DeckState.kt | 1 + .../desktop/ui/wallet/WalletColumnScreen.kt | 696 ++++++++++++++++++ 11 files changed, 2112 insertions(+), 23 deletions(-) create mode 100644 desktopApp/plans/2026-05-09-embedded-local-relay-plan.md create mode 100644 desktopApp/plans/2026-05-11-wallet-zapping-brainstorm.md create mode 100644 desktopApp/plans/2026-05-12-wallet-zapping-phase1-2-plan.md create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt diff --git a/desktopApp/plans/2026-05-09-embedded-local-relay-plan.md b/desktopApp/plans/2026-05-09-embedded-local-relay-plan.md new file mode 100644 index 0000000000..6e7f84fd33 --- /dev/null +++ b/desktopApp/plans/2026-05-09-embedded-local-relay-plan.md @@ -0,0 +1,475 @@ +# Embedded Local Relay for Amethyst Desktop + +## Enhancement Summary + +**Deepened on:** 2026-05-09 +**Sections enhanced:** 5 phases + architecture +**Research agents used:** LocalRelayClient patterns, EventWriteBuffer, Hydration strategy, NIP-09/maintenance, Settings UI + Offline, Account lifecycle + +### Key Improvements +1. Discovered `BasicBundledInsert` already exists — reuse for write buffer instead of custom Channel +2. Full IRelayClient interface mapped — LocalRelayClient skeleton ready +3. Account lifecycle hook points identified (Main.kt lines 771-792, 830-866) +4. UI patterns catalogued — CollapsibleSection, DmBroadcastBanner, SearchSyncBanner all reusable +5. SQLite triggers already handle replaceables, deletions, expiration — no app-level logic needed + +## Overview +Add an in-process local relay to Amethyst Desktop using quartz's existing `NostrServer` + `SQLiteEventStore`. No WebSocket server — the relay lives in-process as a `LocalRelayClient` added to `RelayPool`. All remote relay events are persisted to SQLite. On startup, the local store hydrates `DesktopLocalCache` for instant feed rendering. + +## Architecture + +``` +App Startup + | + v +EventStore("~/.amethyst/accounts//events.db") + | + v +NostrServer(store, policyBuilder={VerifyPolicy}) + | + v +LocalRelayClient : IRelayClient (url = "local://amethyst") + | | + v v +RelayPool treats it like any relay DesktopLocalCache.consume() writes through +``` + +### Data Flow (Steady State) +``` +Remote Relay event arrives + -> DesktopRelaySubscriptionsCoordinator.consumeEvent() + -> localCache.consume(event, relay) [existing] + -> localRelayStore.enqueue(event) [NEW: write-through] + -> BasicBundledInsert batches (250ms) + -> store.transaction { batch.forEach { insert(it) } } +``` + +### Data Flow (Startup Hydration) +``` +Account login (pubKeyHex available) + -> LocalRelayStore.openForAccount(pubKeyHex) + -> LocalRelayStore.hydrate(localCache) + -> Phase 1: Query kind 3 (contact list) for own pubkey + -> Phase 2: Query kind 0 (metadata) for followed users + -> Phase 3: Query kinds 1,6,7,16,1111 since 7 days, limit 5000 + -> Each event -> localCache.consume(event, localRelayUrl) + -> relayManager.connect() [remote relays start after hydration] +``` + +## Phases + +### Phase 1: Core Infrastructure +**Goal**: Wire EventStore + write-through buffer + account lifecycle + +#### Files to Create + +**`desktopApp/.../relay/LocalRelayStore.kt`** — Manages EventStore lifecycle per account + +```kotlin +class LocalRelayStore( + private val scope: CoroutineScope, +) : AutoCloseable { + private var store: EventStore? = null + val localRelayUrl = "local://amethyst".normalizeRelayUrl() + + // Write buffer using existing BasicBundledInsert pattern + private val writeBundler = BasicBundledInsert( + delay = 250, // Same as desktop event bundler + dispatcher = Dispatchers.IO, + scope = scope, + ) + + fun openForAccount(pubKeyHex: String) { + close() + val dbDir = File(System.getProperty("user.home"), ".amethyst/accounts/${pubKeyHex.take(8)}") + dbDir.mkdirs() + store = EventStore( + dbName = File(dbDir, "events.db").absolutePath, + relay = localRelayUrl, + ) + } + + fun enqueue(event: Event) { + writeBundler.invalidateList(event) { batch -> + store?.transaction { + batch.forEach { insert(it) } + } + } + } + + override fun close() { + store?.close() + store = null + } +} +``` + +**Key decisions:** +- DB path: `~/.amethyst/accounts//events.db` (8-char hex prefix) +- `EventStore` wraps `SQLiteEventStore` with `BundledSQLiteDriver()` — no JNI needed +- `BasicBundledInsert` already battle-tested in `DesktopRelaySubscriptionsCoordinator` +- SQLite triggers handle replaceables, deletions, expiration automatically + +#### Files to Modify + +**`DesktopLocalCache.kt`** — Add write-through hook in `consume()` + +```kotlin +// After line ~208 (after route(event, relay)): +// Add: localRelayStore?.enqueue(event) +``` + +Interception point: `consume()` already verifies signatures. After `route()` succeeds, enqueue to local store. + +**`Main.kt`** — Account lifecycle integration + +Hook points identified: +- Line 697: Create `LocalRelayStore` alongside `localCache` +- Line 771-792 (`LaunchedEffect(accountState)`): + - `LoggedOut` → `localRelayStore.close()` + - `LoggedIn` with pubkey change → `localRelayStore.openForAccount(newPubKeyHex)` +- Line 859-866 (`DisposableEffect` cleanup): `localRelayStore.close()` + +```kotlin +// Line ~697: +val localRelayStore = remember { LocalRelayStore(scope) } + +// Line ~785 (after clear, before metadata): +localRelayStore.openForAccount(pubKeyHex) +``` + +#### No LocalRelayClient in Phase 1 + +Research revealed the write-through + hydration approach doesn't need a full `IRelayClient` implementation. The local store is a persistence layer, not a relay client. Events flow through the existing relay pool from remote relays and get persisted as a side effect. On startup, hydration reads from the store directly. + +Implementing `IRelayClient` would require handling JSON serialization/deserialization roundtrips through `NostrServer` which adds overhead for no benefit in Approach A. + +**Simplified architecture:** +- `LocalRelayStore` = `EventStore` + write buffer + hydration +- No `NostrServer` needed (saves serialization overhead) +- No `IRelayClient` needed (local store is not a relay peer) + +--- + +### Phase 2: Startup Hydration +**Goal**: On login, populate DesktopLocalCache from local store before remote relays connect + +#### Implementation in `LocalRelayStore` + +```kotlin +suspend fun hydrate(cache: DesktopLocalCache) { + val s = store ?: return + val relay = localRelayUrl + + // Phase 1: Own contact list (need follow list for feed filtering) + // Note: can't filter by own pubkey without it being passed in + val contactFilter = Filter(kinds = listOf(3), limit = 1) + s.query(contactFilter).forEach { event -> + cache.consume(event, relay, wasVerified = true) + } + + // Phase 2: Metadata for followed users + val followed = cache.followedUsers.value + if (followed.isNotEmpty()) { + // Batch metadata requests (max 500 authors per query for performance) + followed.chunked(500).forEach { chunk -> + val metaFilter = Filter( + kinds = listOf(0), + authors = chunk, + limit = chunk.size, + ) + s.query(metaFilter).forEach { event -> + cache.consume(event, relay, wasVerified = true) + } + } + } + + // Phase 3: Recent content events + val since = (System.currentTimeMillis() / 1000) - (7 * 24 * 3600) + val contentFilter = Filter( + kinds = listOf(1, 6, 7, 16, 1111, 9735), // notes, reposts, reactions, comments, zaps + since = since, + limit = 5000, + ) + s.query(contentFilter).forEach { event -> + cache.consume(event, relay, wasVerified = true) + } +} +``` + +#### Memory Impact +- 5000 events at ~600 bytes avg = ~3 MB (trivial for desktop JVM) +- Hydration time: SQLite query + cache insertion = <500ms for 5000 events +- `wasVerified = true` skips signature verification (already verified on first insert) + +#### Main.kt Integration + +```kotlin +// After localRelayStore.openForAccount(pubKeyHex), before relayManager.connect(): +scope.launch(Dispatchers.IO) { + localRelayStore.hydrate(localCache) + // Then connect remote relays (they'll fill gaps) +} +``` + +**Decision: Async hydration** — Show UI immediately, hydrate in background. Feed renders from local store data within ~200ms, then remote relay events fill in gaps. + +--- + +### Phase 3: Event Lifecycle +**Goal**: Handle deletions, pruning, VACUUM, disk monitoring + +#### File to Create + +**`desktopApp/.../relay/LocalRelayMaintenance.kt`** + +```kotlin +class LocalRelayMaintenance( + private val store: LocalRelayStore, + private val scope: CoroutineScope, +) { + private var maintenanceJob: Job? = null + private val _diskWarning = MutableStateFlow(false) + val diskWarning: StateFlow = _diskWarning + private val _lastError = MutableStateFlow(null) + val lastError: StateFlow = _lastError + + fun start(dbPath: String) { + maintenanceJob = scope.launch(Dispatchers.IO) { + // Startup maintenance + try { + store.deleteExpiredEvents() + maybeVacuum(dbPath) + } catch (e: Exception) { + _lastError.value = "Startup maintenance: ${e.message}" + } + + // Periodic maintenance (every 6 hours) + while (isActive) { + delay(6 * 60 * 60 * 1000L) + try { + store.deleteExpiredEvents() + store.pruneOldEvents(maxAgeDays = 30) + checkDiskSpace(dbPath) + } catch (e: Exception) { + _lastError.value = "Periodic maintenance: ${e.message}" + } + } + } + } + + private fun checkDiskSpace(dbPath: String) { + val usable = File(dbPath).parentFile?.usableSpace ?: return + _diskWarning.value = usable < 100 * 1024 * 1024 // < 100MB + if (_diskWarning.value) { + store.disableWrites() + } + } + + private fun maybeVacuum(dbPath: String) { + val prefs = Preferences.userRoot().node("amethyst/localrelay") + val lastVacuum = prefs.getLong("lastVacuum", 0) + val sevenDays = 7 * 24 * 60 * 60 * 1000L + if (System.currentTimeMillis() - lastVacuum > sevenDays) { + store.vacuum() + prefs.putLong("lastVacuum", System.currentTimeMillis()) + } + } + + fun stop() { + maintenanceJob?.cancel() + } +} +``` + +#### NIP-09 Deletion +SQLiteEventStore's `DeletionRequestModule` already handles this via BEFORE INSERT triggers: +- When kind 5 event inserted → trigger deletes referenced events +- Trigger also prevents re-insertion of deleted events + +**Desktop hook**: In `DesktopLocalCache.consume()`, when a `DeletionEvent` is consumed, the write-through to `LocalRelayStore.enqueue()` handles it — the store's trigger does the rest. + +#### Pruning Strategy +Add to `LocalRelayStore`: +```kotlin +suspend fun pruneOldEvents(maxAgeDays: Int) { + val cutoff = (System.currentTimeMillis() / 1000) - (maxAgeDays * 24 * 3600L) + val filter = Filter(until = cutoff) + store?.delete(filter) +} +``` + +#### DB Corruption Recovery +```kotlin +fun openForAccount(pubKeyHex: String) { + close() + try { + store = EventStore(dbName = dbPath, ...) + } catch (e: Exception) { + // Corrupt DB — delete and recreate + File(dbPath).delete() + listOf("-wal", "-shm", "-journal").forEach { + File(dbPath + it).delete() + } + store = EventStore(dbName = dbPath, ...) + _lastError.value = "Database recreated: ${e.message}" + } +} +``` + +--- + +### Phase 4: Settings UI +**Goal**: Local Relay Settings screen with stats, controls, error display + +#### File to Create + +**`desktopApp/.../ui/settings/LocalRelaySettingsScreen.kt`** + +Uses established patterns: +- `CollapsibleSection` from `RelayConfigTab.kt` +- `AnimatedVisibility` with `expandVertically()` + `fadeIn()/fadeOut()` +- Material3 colors: `surfaceContainerHigh`, `errorContainer`, `onSurfaceVariant` +- Layout: `ReadingColumn` wrapper, 12.dp horizontal padding, 16.dp section spacers +- Controls: `Switch` for toggles, `Button` for actions, `OutlinedTextField` for config + +#### Sections + +**1. Status Section** (always visible) +- Switch: enabled/disabled toggle +- Status indicator: green dot (active) / gray (disabled) +- DB path display (read-only) + +**2. Statistics** (CollapsibleSection, initially expanded) +- DB size on disk: `File(dbPath).length()` formatted as KB/MB/GB +- Total event count: `store.count(Filter())` +- Events by kind: breakdown table (kind 0, 1, 3, 7, etc.) +- Last write time + +**3. Storage Management** (CollapsibleSection) +- Current disk usage +- Prune button: "Delete events older than 30 days" → `pruneOldEvents(30)` +- Clear all button: "Delete all cached events" → delete DB + recreate +- VACUUM button: "Reclaim disk space" → `store.vacuum()` + +**4. Export/Import** (CollapsibleSection) +- Export button: JSONL file (one event JSON per line) + - Uses `DesktopFilePicker` for save dialog + - Streams events from store → write to file +- Import button: Read JSONL file → `store.transaction { events.forEach { insert(it) } }` + - Uses `DesktopFilePicker` for open dialog + +**5. Errors** (CollapsibleSection, collapsed by default) +- Recent errors from `LocalRelayMaintenance.lastError` +- Disk full warning from `LocalRelayMaintenance.diskWarning` +- Toggleable via `AnimatedVisibility` +- Error entries with timestamp + message +- "Clear errors" button + +**6. Disk Full Warning** (conditional banner) +- Pattern: `DmBroadcastBanner` style +- Colors: `MaterialTheme.colorScheme.errorContainer` +- Actions: "Prune Now" button, "Clear Cache" button + +#### Navigation Integration +- Add `LocalRelaySettings` to `DesktopScreen` sealed class +- Route from existing Settings screen (add a "Local Relay" row that navigates to it) +- Or add as tab in `RelayDashboardScreen` alongside Monitor/Configure + +--- + +### Phase 5: Offline Mode +**Goal**: Show offline indicator when no remote relays connected + +#### File to Create + +**`desktopApp/.../ui/components/OfflineBanner.kt`** + +```kotlin +@Composable +fun OfflineBanner( + connectedRelayCount: Int, + hasLocalData: Boolean, +) { + AnimatedVisibility( + visible = connectedRelayCount == 0, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Surface( + color = if (hasLocalData) + MaterialTheme.colorScheme.surfaceContainerHigh + else + MaterialTheme.colorScheme.errorContainer, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = if (hasLocalData) Icons.Default.CloudOff else Icons.Default.Warning, + contentDescription = null, + ) + Text( + text = if (hasLocalData) + "Offline — showing cached events" + else + "Offline — no cached events available", + style = MaterialTheme.typography.bodySmall, + ) + } + } + } +} +``` + +**Pattern**: Follows `DmBroadcastBanner` style (AnimatedVisibility + Surface + Row with icon/text). + +#### Integration +- Place in `DeckColumnContainer` root content, above feed content +- Observe `relayManager.connectedRelays` StateFlow +- `hasLocalData = localRelayStore.eventCount() > 0` + +--- + +## File Summary + +| File | Action | Phase | +|------|--------|-------| +| `desktopApp/.../relay/LocalRelayStore.kt` | Create | 1+2 | +| `desktopApp/.../relay/LocalRelayMaintenance.kt` | Create | 3 | +| `desktopApp/.../ui/settings/LocalRelaySettingsScreen.kt` | Create | 4 | +| `desktopApp/.../ui/components/OfflineBanner.kt` | Create | 5 | +| `desktopApp/.../cache/DesktopLocalCache.kt` | Modify (write-through hook) | 1 | +| `desktopApp/.../Main.kt` | Modify (lifecycle + hydration) | 1+2 | +| `desktopApp/.../ui/deck/DeckColumnContainer.kt` | Modify (offline banner) | 5 | +| `desktopApp/.../Main.kt` (DesktopScreen) | Modify (add LocalRelaySettings) | 4 | + +## Non-Goals (Punt) +- WebSocket server for external apps (Approach B — future) +- Sync between devices +- Full-text search UI against local store +- NostrServer / IRelayClient implementation (unnecessary for Approach A) + +## Risk Mitigation + +| Risk | Mitigation | +|------|------------| +| Slow startup hydration | Cap at 5000 events, async (don't block UI) | +| Write buffer data loss on crash | Accept — events exist on remote relays | +| DB corruption | Detect on open, delete + recreate, log to settings errors | +| Disk full | Monitor `File.usableSpace`, disable writes at <100MB, surface in UI | +| Tag indexing overhead (80% of insert) | BasicBundledInsert batches amortize transaction cost | +| Hydration before follow list available | Query kind 3 first, then use result for metadata + content filters | + +## Testing Strategy +- Unit tests for write buffer batching behavior +- Unit tests for LocalRelayStore (open/close/account-switch lifecycle) +- Integration test: write events -> close -> reopen -> hydrate -> verify cache populated +- Integration test: NIP-09 deletion -> verify event removed from store +- Manual: launch app, browse feeds, restart, verify instant load from cache +- Manual: export/import round-trip +- Manual: settings screen — prune, vacuum, clear, toggle +- Manual: disconnect network, verify offline banner + cached events display diff --git a/desktopApp/plans/2026-05-11-wallet-zapping-brainstorm.md b/desktopApp/plans/2026-05-11-wallet-zapping-brainstorm.md new file mode 100644 index 0000000000..396ecbacc4 --- /dev/null +++ b/desktopApp/plans/2026-05-11-wallet-zapping-brainstorm.md @@ -0,0 +1,329 @@ +# Desktop Wallet & Zapping Experience - Brainstorm Map + +**Date:** 2026-05-12 +**Status:** Brainstorm v2 — phase map with research findings + decisions + +--- + +## Decisions Made + +| # | Decision | Resolution | +|---|----------|------------| +| 1 | Cashu lib: build now? | Not building yet, but may do it anyway. Research first. | +| 2 | NIP-60/61 worth investment? | Worth investigating — adoption growing | +| 3 | Key storage | `java.security.KeyStore` — platform-agnostic | +| 4 | WalletViewModel extraction | Clean — no deep Android ties | +| 5 | Hot wallet opt-in/out | **Opt-in** — user chooses to setup desktop wallet OR connect existing (e.g. mobile) | +| 6 | NWC QR on desktop | Clipboard-only paste | +| 7 | Phase priority | **NWC parity first**, then layer Cashu/hot wallet | +| 8 | Mint trust | Needs deeper trade-off discussion | +| 9 | Balance limits | Soft warning at threshold, no hard cap (see research below) | + +--- + +## Current State + +### What Desktop Has Today +- Basic zap button in `NoteActions.kt` with preset amounts (21, 100, 500, 1k, 5k, 10k) +- `NwcPaymentHandler.kt` — NIP-47 payment flow (pay invoice, wait for response) +- `ZapReceiptsDialog` — top 10 zap receipts on a note +- Opens `lightning:` URI for external wallet fallback +- No wallet management UI, no zap type selection, no transaction history + +### What Android Has (Full Feature Set) +- 6 wallet screens: Dashboard, Add, Detail, Send, Receive, Transactions +- `WalletViewModel` with full NIP-47 RPC (balance, info, transactions, invoice, pay) +- `ZapCustomDialog` with PUBLIC/PRIVATE/ANONYMOUS/NONZAP type selection +- `ReusableZapButton` with progress state +- Zap splits, zap polls, zap fundraisers +- Cashu token parsing + redemption (`CashuParser`, `MeltProcessor`) +- `ZapPaymentHandler` orchestrating splits + NWC routing +- Biometric auth for sensitive operations + +### Shared Infrastructure (Already in quartz/commons) +- **NIP-57:** `LnZapEvent`, `LnZapRequestEvent`, `LnZapPrivateEvent`, private zap encryption +- **NIP-47:** `Nip47Client`, request/response events, full RPC method set +- **NIP-60:** `CashuWalletEvent`, `CashuTokenEvent`, `CashuSpendingHistoryEvent` (skeleton) +- **NIP-61:** `NutzapEvent`, `NutzapInfoEvent` (skeleton) +- **NIP-87:** `CashuMintEvent`, `FedimintEvent`, `MintRecommendationEvent` +- **LNURL:** `LightningAddressResolver` (in commons jvmAndroid) +- **ZapAction:** Shared zap invoice fetching (commons jvmAndroid) +- **Cashu parsing:** V3+V4 token parsers, `MeltProcessor` (Android only, extractable) +- **Icons:** `Zap.kt`, `ZapSplit.kt` in commons + +--- + +## Competitor Landscape + +| Client | Platform | Wallet Type | NWC | Cashu | Hot Wallet | Standout Feature | +|--------|----------|-------------|-----|-------|------------|------------------| +| **Primal** | Web+Mobile | Custodial (Strike), migrating to Spark | Yes | No | Yes | Zero-friction, 1M sat limit | +| **Damus** | iOS | External (NWC) | Yes | No | No | Clean wallet view, high-balance warning | +| **Nostura** | iOS/macOS | External (NWC) | Yes | No | No | Balance in zap sheet | +| **Vega** | Desktop | External (NWC) | Yes | No | No | Guided wizard, zap history tabs | +| **YakiHonne** | Mobile | Built-in Cashu + NWC | Yes | Yes | Yes | Zero-config via Cashu | +| **0xchat** | Mobile | Built-in Cashu | ? | Yes | Yes | Ecash-native messaging payments | +| **Coracle** | Web | External (NWC) | Yes | ? | No | WoT-focused | +| **Snort** | Web | External (NWC) | Yes | No | No | Performance-focused | + +### Key Insights +- NWC is table stakes — every client supports it +- Cashu is the frontier — YakiHonne, 0xchat leading; NIP-60/61 merged but early +- **No desktop client has a hot wallet** — differentiation opportunity +- Vega is the best desktop reference (wizard, history, keyboard shortcuts) +- Primal is **migrating from custodial (Strike) to self-custodial (Spark)** — signals industry direction +- Zero-config onboarding (Cashu) is the killer UX pattern + +--- + +## Hot Wallet Technology Options + +### Comparison Matrix + +| Approach | Sovereignty | Complexity | UX | JVM/Kotlin | Maturity | Notes | +|----------|-------------|------------|-----|------------|----------|-------| +| **NWC (external wallet)** | High (user's wallet) | Very Low | Good | Just Nostr events | High | Phase 1 — proven, zero backend | +| **Cashu (ecash)** | Low (trust mint) | Medium | Excellent (instant) | cdk-kotlin Android-only; pure Kotlin feasible | Medium | Phase 3 candidate | +| **Breez SDK Nodeless** | Medium (Liquid federation) | Medium | Good (no channels) | Kotlin bindings exist | Medium-High | Self-custodial LN alternative | +| **LDK Node** | High (self-custody) | High | Fair (channel mgmt) | `ldk-node-jvm` on Maven | Medium | Full LN node as library | +| **lightning-kmp (ACINQ)** | High (self-custody) | Very High | Fair | Native KMP | High (Phoenix) | Best KMP fit, not designed for embedding | +| **phoenixd (sidecar)** | High (self-custody) | Medium | Good (auto liquidity) | HTTP API | High | Separate daemon | +| **Spark (Lightspark)** | High (self-custody) | Low | Excellent | **No JVM SDK yet** | Low (beta) | Watch — Primal + WoS adopting | +| **Custodial API (Strike)** | None | Low | Excellent | HTTP API | High | Requires business agreement + KYC | +| **LNbits** | Medium (your server) | Medium | Good | HTTP API | High | Requires separate server | + +### Recommendation Path +1. **Now:** NWC parity (Phase 1+2) — let users bring their own wallet +2. **Next:** Cashu hot wallet (Phase 3+4) — zero-config spending wallet +3. **Watch:** Spark Kotlin SDK — when it ships, could be the best self-custodial option +4. **Consider:** Breez SDK Nodeless as a self-custodial alternative to Cashu + +--- + +## Cashu Library Situation + +### Existing Kotlin/JVM Options + +| Library | Status | Desktop JVM? | Notes | +|---------|--------|-------------|-------| +| **cdk-kotlin** (cashubtc) | Active, v0.16.0 | **No** — Android AAR only | UniFFI wrapping Rust CDK; ARM/x86 Android ABIs only | +| **cashu-client** (thunderbiscuit) | Abandoned (2y stale) | KMP intended | Never completed | +| **cashu-bdhke-kmp** (gandlafbtc) | Dead (3y stale) | KMP | BDHKE only, usable as reference | + +### Options to Get Cashu on JVM Desktop + +| Option | Effort | Risk | Notes | +|--------|--------|------|-------| +| **A. Fork cdk-kotlin, add JVM targets** | 2-3 weeks | Medium | Build CDK Rust for desktop targets + swap AAR JNA for standard JNA | +| **B. Pure Kotlin implementation** | 4-6 weeks | Low | BDHKE ~400 lines using existing secp256k1-kmp; full mint client in Kotlin | +| **C. Hybrid (BDHKE in Kotlin + HTTP)** | 3-4 weeks | Medium | Pragmatic middle ground | + +### What Amethyst Already Has (reusable for any option) +- NIP-60/61/87 event types in quartz (commonMain, KMP-ready) +- Cashu V3+V4 token parsing (Android, extractable to commons) +- `MeltProcessor` — HTTP calls to `/melt` and `/checkfees` (extractable) +- `secp256k1-kmp` — curve operations for BDHKE foundation +- NIP-44 encryption for wallet content + +### What's Missing (regardless of library choice) +- BDHKE (blind signatures): hash-to-curve, blinding, unblinding +- Full mint API client (mint, swap, check state) +- P2PK token locking (NUT-11, needed for NIP-61 nutzaps) +- Proof management (coin selection, consolidation) +- Deterministic secret derivation (NUT-13) + +--- + +## Balance Limits Research + +| App | Type | Hard Limit | Warning | Pattern | +|-----|------|-----------|---------|---------| +| **Primal** | Custodial | 1M sats | Yes | Server-enforced, "use hardware wallet for more" | +| **Damus** | NWC | None | Dismissable high-balance reminder | Soft warning | +| **Cashu.me** | Cashu browser | None | "Small spending amounts only" | Onboarding copy | +| **Minibits** | Cashu mobile | None | Beta warning | General disclaimer | +| **Phoenix** | Self-custodial LN | None | None | No artificial limits | +| **WoS** | Custodial | 5 BTC | Implied | Server-enforced | + +### Our Approach (for Cashu hot wallet) +- **No hard cap** — self-custodial ecash, user's choice +- **Soft dismissable warning** at configurable threshold (default ~500K sats) +- **"Spending wallet" framing** in onboarding — clear this isn't savings +- **"Move to cold storage" CTA** when warning triggers +- **User-configurable threshold** — let power users set their own comfort level + +--- + +## NIP Infrastructure + +| NIP | Purpose | Status | Amethyst Support | +|-----|---------|--------|------------------| +| **47** | Nostr Wallet Connect | Merged, mature | Full in quartz, partial desktop UI | +| **57** | Lightning Zaps | Merged, mature | Full in quartz + Android, basic desktop | +| **60** | Cashu Wallet (relay-stored) | Merged (draft) | Event types in quartz, parsing in Android | +| **61** | Nutzaps (ecash zaps) | Merged | Event types in quartz only | +| **87** | Mint Discoverability | Merged (draft) | Event types in quartz only | + +--- + +## Phase Map + +### Phase 1: NWC Wallet Parity (Foundation) -- START HERE +**Goal:** Desktop matches Android's NWC wallet experience +**Priority:** Highest — this is the foundation everything else builds on + +| Work Item | Source | Action | +|-----------|--------|--------| +| Extract `WalletViewModel` | Android `WalletViewModel` | Move to `commons/commonMain/viewmodels/` | +| Wallet connection setup | Android `AddWalletScreen` | Desktop layout: paste `nostr+walletconnect://` URI | +| Wallet dashboard | Android `WalletScreen` | Desktop sidebar panel with balance + quick actions | +| Balance display | Nostura/Vega pattern | Persistent balance in sidebar | +| Send screen | Android `WalletSendScreen` | Desktop layout with paste-friendly invoice input | +| Receive screen | Android `WalletReceiveScreen` | Desktop layout with QR code + copy button | +| Transaction history | Android `WalletTransactionsScreen` | Desktop layout with search/filter/tabs | +| Multi-wallet switcher | Android `NwcSignerState` | Dropdown in wallet panel header | +| Extract NWC payment logic | Android `ZapPaymentHandler` | Shared NWC routing to commons (minus Android intents) | + +**Layout decision needed:** Sidebar wallet panel vs deck column vs both? + +--- + +### Phase 2: Zapping UX Upgrade +**Goal:** Rich zapping with desktop-first interactions +**Dependency:** Phase 1 wallet connection + +| Work Item | Source | Action | +|-----------|--------|--------| +| Zap type selection | Android `ZapCustomDialog` | Add PUBLIC/PRIVATE/ANONYMOUS toggle | +| Zap splits display | Android `DisplayZapSplits` | Extract to commons | +| Configurable presets | Account settings | User-defined amounts (not hardcoded) | +| Zap progress feedback | Android `ReusableZapButton` | Extract progress component | +| One-click zap | New | Single click = default amount; right-click = dialog | +| Keyboard shortcut zap | New (no client does this!) | `Z` = zap focused note, `Shift+Z` = custom amount | +| Zap animations | New | Subtle lightning flash on success | +| Zap receipts panel | Vega pattern | Sent/received tabs with note previews | +| Zap polls | Android `ZapPollNote` | Extract + desktop layout | +| Zap fundraisers | Android zapraiser | Extract + desktop layout | + +--- + +### Phase 3: Cashu Hot Wallet +**Goal:** Built-in ecash spending wallet — opt-in, zero-config zapping once funded +**Dependency:** Cashu crypto library (build or fork) + +| Work Item | Source | Action | +|-----------|--------|--------| +| Cashu crypto (BDHKE) | Pure Kotlin or fork cdk-kotlin | Core blind signature operations | +| Mint HTTP client | Cashu NUT spec | mint, melt, swap, check, keysets | +| NIP-60 wallet manager | Quartz skeleton | Full read/write/encrypt of wallet state on relays | +| Token lifecycle | NIP-60 | Create, spend, delete kind 7375 events | +| Extract `CashuParser` | Android `service/cashu/` | Move to commons | +| Extract `MeltProcessor` | Android `service/cashu/` | Move to commons | +| P2PK token locking | NUT-11 | Lock tokens to pubkey (needed for Phase 4) | +| Wallet key derivation | NIP-60 | Dedicated key per wallet via `java.security.KeyStore` | +| Deposit flow | Cashu mint API | LN invoice → pay → receive proofs | +| Withdraw flow | Cashu mint API | Send proofs → receive LN payment | +| Balance display | Local | Sum unspent proofs across mints | +| Wallet setup wizard | New | Opt-in: "Create spending wallet" or "Connect existing wallet" | +| Soft balance warning | Damus pattern | Dismissable warning at threshold (default 500K sats) | +| "Spending wallet" framing | Primal/Cashu.me | Clear onboarding copy | + +--- + +### Phase 4: Nutzaps (Ecash Zaps) +**Goal:** Trustless zapping via Cashu tokens (NIP-61) +**Dependency:** Phase 3 Cashu wallet + P2PK + +| Work Item | Source | Action | +|-----------|--------|--------| +| Kind 10019 publish | NIP-61 | Publish trusted mints + P2PK pubkey | +| Kind 10019 parse | NIP-61 | Read recipient's nutzap preferences | +| Kind 9321 create | NIP-61 | P2PK-lock tokens to recipient | +| Kind 9321 redeem | NIP-61 | Detect incoming nutzaps, swap into wallet | +| Nutzap display | New | Show alongside LN zaps in UI | +| Auto-redeem | New | Background coroutine to claim incoming nutzaps | +| Mint matching | NIP-61 | Find common mint between sender + recipient | +| Smart routing | New | Nutzap if possible, LN zap fallback | +| Fallback UX | New | "No common mint — send LN zap instead?" | + +--- + +### Phase 5: Advanced Wallet Features +**Goal:** Power-user and sovereignty features + +| Work Item | Description | Priority | +|-----------|-------------|----------| +| **NIP-87 mint discovery** | Browse mints recommended by follows | High | +| **Multi-mint management** | Add/remove mints, per-mint balances | High | +| **Smart payment routing** | Cashu > NWC > external, configurable | High | +| **Proof management** | Swap, consolidate, check validity | Medium | +| **Auto-swap** | Background consolidation of small proofs | Medium | +| **Wallet backup export** | Encrypted state for offline backup | Medium | +| **Budget controls** | Daily/weekly spend limits, per-zap max | Medium | +| **Zap scheduler** | Recurring zaps to favorite creators | Low | +| **Paywall support** | Pay-to-unlock via Cashu or LN | Low | +| **P2P ecash sends** | Send ecash directly to npub | Low | +| **Fedimint support** | NIP-87 kind 38173 | Low | + +### Phase 5b: Watch List (Not Building Yet) +| Technology | When to Revisit | Why | +|------------|----------------|-----| +| **Spark (Lightspark)** | When Kotlin/JVM SDK ships | Self-custodial, no channels, LN-compatible | +| **Breez SDK Nodeless** | If users want self-custodial LN | Kotlin bindings exist, Liquid-based | +| **lightning-kmp** | If ACINQ opens it for embedding | Native KMP, best language fit | + +--- + +## Extraction Inventory (Android -> Commons) + +| Component | Android Location | Extractable? | Phase | +|-----------|-----------------|-------------|-------| +| `WalletViewModel` | `amethyst/ui/screen/loggedIn/wallet/` | Yes (clean) | 1 | +| `ZapPaymentHandler` (NWC part) | `amethyst/service/` | Yes | 1 | +| `NwcSignerState` | `amethyst/model/nip47WalletConnect/` | Partially | 1 | +| `ZapCustomDialog` (state logic) | `amethyst/ui/note/` | Yes | 2 | +| `DisplayZapSplits` | `amethyst/ui/note/creators/zapsplits/` | Yes | 2 | +| `ReusableZapButton` (progress) | `amethyst/ui/components/` | Yes | 2 | +| `CashuParser` (V3+V4) | `amethyst/service/cashu/` | Yes | 3 | +| `CashuToken` / `Proof` | `amethyst/service/cashu/` | Yes | 3 | +| `MeltProcessor` | `amethyst/service/cashu/` | Yes | 3 | + +--- + +## Differentiation Opportunities + +1. **First desktop Nostr client with a hot wallet** — nobody does this +2. **Keyboard-driven zapping** — `Z` to zap, no client has this +3. **Opt-in wallet choice** — create desktop wallet OR connect mobile wallet +4. **Smart payment routing** — Cashu when possible, NWC fallback +5. **Zap history as deck column** — persistent visibility +6. **Drag-and-drop invoice payment** — drop BOLT11/cashu token +7. **Multi-mint visualization** — balance distribution across mints + +--- + +## Brainstorm Sessions Needed + +| Session | Phase | Key Questions | +|---------|-------|---------------| +| **Wallet panel layout** | 1 | Sidebar panel vs deck column? Persistent balance placement? | +| **NWC extraction** | 1 | WalletViewModel extraction plan, what stays Android-specific? | +| **Zap UX design** | 2 | Keyboard shortcuts, animation style, one-click vs dialog | +| **Cashu library strategy** | 3 | Fork cdk-kotlin vs pure Kotlin BDHKE? secp256k1-kmp capabilities? | +| **Mint trust model** | 3 | Curated list vs NIP-87 social discovery vs user-only? | +| **Nutzap routing** | 4 | Unified zap button with smart routing vs separate buttons? | + +--- + +## Unanswered Questions + +1. Does `secp256k1-kmp` (already in quartz) expose the low-level point arithmetic needed for BDHKE, or only sign/verify? +2. What is Vitor's appetite for adding native/Rust build deps to CI? (affects cdk-kotlin fork option) +3. Mint trust for new users with no social graph — curated default list? Who curates? +4. Cashu adoption curve — are enough people publishing kind 10019 (nutzap info) to make Phase 4 worthwhile near-term? +5. Should wallet panel be a permanent sidebar section or a toggleable deck column? +6. How does the "connect existing wallet" flow work cross-device? NWC URI shared how? +7. Will Spark ship a Kotlin/JVM SDK in 2026? Timeline unknown. +8. Is `cashu-bdhke-kmp` (3y stale) usable as reference code for pure Kotlin BDHKE? +9. Breez SDK Nodeless fees — are submarine swap costs acceptable for a spending wallet? +10. Should we contribute desktop JVM targets back to cdk-kotlin upstream rather than maintaining a fork? diff --git a/desktopApp/plans/2026-05-12-wallet-zapping-phase1-2-plan.md b/desktopApp/plans/2026-05-12-wallet-zapping-phase1-2-plan.md new file mode 100644 index 0000000000..e63ae5adc9 --- /dev/null +++ b/desktopApp/plans/2026-05-12-wallet-zapping-phase1-2-plan.md @@ -0,0 +1,456 @@ +# Phase 1-2: NWC Wallet Parity + Zapping UX — Implementation Plan + +**Date:** 2026-05-12 +**Branch:** `feat/desktop-wallet-zapping` +**Status:** Plan — deepened, ready for work + +## Deepening Corrections (from code verification) + +1. **QR code**: ZXing already works on desktop (`QrCodeCanvas.kt`). No new lib needed. Step 1.7 is just reuse. +2. **Clipboard**: Both platforms have `ClipboardExt.kt` already. Step 1.3 wraps existing code in expect/actual. +3. **UserAvatar**: Already in `commons/` — transaction list can use it directly. +4. **WalletViewModel blocker**: `launchSigner` depends on `AccountViewModel.viewModelScope` + `toastManager`. Fix: inject `onSignerError: (String) -> Unit` callback instead of requiring AccountViewModel. +5. **No note focus tracking**: Desktop has no "focused note" concept. Keyboard shortcut zapping (Step 2.5) deferred to Phase 2b. One-click zap (Step 2.4) still works since note is passed explicitly to NoteActionsRow. +6. **NwcSignerState**: Zero Android deps confirmed. Can move to quartz or stay in commons. +7. **WalletViewModel constructor**: Takes NO params — initialized via `init(accountViewModel)` method. Extraction: make `init(account, scope, onError)` instead. + +--- + +## Phase 1: NWC Wallet Parity + +### Goal +Desktop gets a full wallet experience: connect NWC wallet, see balance, send/receive, view transactions — as a deck column. + +### Architecture Decision: Wallet as Deck Column +- Fits the existing pattern (Settings, Relays, Chess are all column types) +- User adds via AppDrawer or MenuBar "Add Column > Wallet" +- In-column drill-down for sub-screens (Send, Receive, Detail, Transactions) +- Sidebar wallet icon with balance badge (optional, Phase 1b) + +### Step 1.1: Extract Pure Types to Commons +**Target:** `commons/src/commonMain/kotlin/.../commons/viewmodels/wallet/` + +| Type | Source | Notes | +|------|--------|-------| +| `WalletSendState` (sealed) | WalletViewModel.kt `SendState` | Idle, Sending, Success(preimage), Error(msg) | +| `WalletReceiveState` (sealed) | WalletViewModel.kt `ReceiveState` | Idle, Creating, Created(invoice, amount), Error(msg) | +| `TransactionFilter` (enum) | WalletViewModel.kt | ALL, ZAPS, NON_ZAPS | +| `WalletInfo` (data class) | WalletViewModel.kt | walletId, name, alias, balanceSats, isDefault, isLoading, error | + +**Effort:** ~1 hour +**Files created:** `commons/src/commonMain/.../viewmodels/wallet/WalletTypes.kt` + +### Step 1.2: Extract SharedWalletViewModel to Commons +**Target:** `commons/src/commonMain/kotlin/.../commons/viewmodels/wallet/SharedWalletViewModel.kt` + +**Key refactoring:** +- Remove `ViewModel` base class → plain class +- Replace `viewModelScope` → constructor-injected `CoroutineScope` +- Keep all NIP-47 RPC logic (getBalance, getInfo, listTransactions, makeInvoice, payInvoice) +- Keep all state management (StateFlow, StateFlow, etc.) +- `Account` dependency is already platform-agnostic + +```kotlin +class SharedWalletViewModel( + val account: Account, + val scope: CoroutineScope, +) { + val walletInfoList: StateFlow> + val sendState: StateFlow + val receiveState: StateFlow + val transactions: StateFlow> + val selectedFilter: StateFlow + + fun selectWallet(id: String) { ... } + fun setDefault(id: String) { ... } + fun removeWallet(id: String) { ... } + fun renameWallet(id: String, name: String) { ... } + fun payInvoice(invoice: String) { ... } + fun makeInvoice(amountSats: Long, description: String) { ... } + fun refreshBalance() { ... } + fun loadTransactions() { ... } + fun filterTransactions(filter: TransactionFilter) { ... } +} +``` + +**Android wrapper:** +```kotlin +// amethyst/ +class WalletViewModel(account: Account) : ViewModel() { + val shared = SharedWalletViewModel(account, viewModelScope) + // Delegate all state/methods to shared +} +``` + +**Desktop usage:** +```kotlin +// desktopApp/ +val scope = rememberCoroutineScope() +val walletVM = remember(account) { SharedWalletViewModel(account, scope) } +``` + +**Effort:** ~3 hours +**Files created:** `commons/.../viewmodels/wallet/SharedWalletViewModel.kt` +**Files modified:** `amethyst/.../wallet/WalletViewModel.kt` (delegate to shared) + +### Step 1.3: Platform Utilities (expect/actual) +**Target:** `commons/src/commonMain/.../commons/platform/` + +| Utility | commonMain (expect) | androidMain (actual) | jvmMain (actual) | +|---------|--------------------|--------------------|-----------------| +| `getClipboardText()` | `expect suspend fun` | `ClipboardManager` | `Toolkit.getDefaultToolkit().systemClipboard` | +| `setClipboardText(text)` | `expect fun` | `ClipboardManager` | `StringSelection` + `Toolkit` | + +**Effort:** ~1 hour +**Files created:** 3 files (expect + 2 actual) + +### Step 1.4: Add DeckColumnType.Wallet +**Files to modify:** + +| File | Change | +|------|--------| +| `DeckColumnType.kt` | Add `object Wallet : DeckColumnType()` + title/typeKey | +| `AppDrawer.kt` | Add to `LAUNCHABLE_SCREENS`, category = `IDENTITY` | +| `DeckColumnContainer.kt` | Add case in `RootContent()` → render `WalletColumnScreen()` | +| `Main.kt` | Add to MenuBar "Add Column..." menu | + +**Effort:** ~1 hour + +### Step 1.5: Desktop Wallet Column Screen +**Target:** `desktopApp/src/jvmMain/.../desktop/ui/wallet/` + +**Sub-screens (in-column navigation via navStack):** + +#### 1.5a: WalletHomeScreen (default view) +``` +┌─────────────────────────┐ +│ Wallet [+ Add] │ +├─────────────────────────┤ +│ ┌─────────────────────┐ │ +│ │ Alby Hub ★ def │ │ +│ │ 125,432 sats │ │ +│ │ [Send] [Receive] │ │ +│ └─────────────────────┘ │ +│ ┌─────────────────────┐ │ +│ │ Phoenix ☆ │ │ +│ │ 50,000 sats │ │ +│ │ [Send] [Receive] │ │ +│ └─────────────────────┘ │ +│ │ +│ Recent Transactions │ +│ ⚡ Sent 1,000 sats 2m │ +│ ⚡ Recv 5,000 sats 15m │ +│ ⚡ Zap 500 sats 1h │ +└─────────────────────────┘ +``` + +#### 1.5b: AddWalletScreen +``` +┌─────────────────────────┐ +│ ← Connect Wallet │ +├─────────────────────────┤ +│ Paste NWC URI: │ +│ ┌─────────────────────┐ │ +│ │ nostr+walletconnect… │ │ +│ └─────────────────────┘ │ +│ [Paste from clipboard] │ +│ │ +│ Wallet Name: │ +│ ┌─────────────────────┐ │ +│ │ My Alby Hub │ │ +│ └─────────────────────┘ │ +│ │ +│ [Connect] │ +│ │ +│ ──────────────────── │ +│ Supported wallets: │ +│ Alby Hub, Phoenix, │ +│ Coinos, LNbits, Zeus │ +└─────────────────────────┘ +``` + +#### 1.5c: WalletSendScreen +``` +┌─────────────────────────┐ +│ ← Send │ +├─────────────────────────┤ +│ Invoice or LN Address: │ +│ ┌─────────────────────┐ │ +│ │ lnbc... │ │ +│ └─────────────────────┘ │ +│ [Paste] │ +│ │ +│ Amount: 1,000 sats │ +│ │ +│ [Pay Invoice] │ +│ │ +│ Status: Sending... │ +│ ████████░░ 80% │ +└─────────────────────────┘ +``` + +#### 1.5d: WalletReceiveScreen +``` +┌─────────────────────────┐ +│ ← Receive │ +├─────────────────────────┤ +│ Amount (sats): │ +│ ┌─────────────────────┐ │ +│ │ 10000 │ │ +│ └─────────────────────┘ │ +│ Description: │ +│ ┌─────────────────────┐ │ +│ │ Coffee fund │ │ +│ └─────────────────────┘ │ +│ │ +│ [Create Invoice] │ +│ │ +│ ┌───────────────┐ │ +│ │ QR CODE │ │ +│ │ (invoice) │ │ +│ └───────────────┘ │ +│ lnbc10u1pj... [Copy] │ +└─────────────────────────┘ +``` + +#### 1.5e: WalletTransactionsScreen +``` +┌─────────────────────────┐ +│ ← Transactions │ +├─────────────────────────┤ +│ [All] [Zaps] [Non-Zaps] │ +├─────────────────────────┤ +│ Today │ +│ ⚡↑ 1,000 sats @alice │ +│ ⚡↓ 5,000 sats @bob │ +│ Yesterday │ +│ ⚡↑ 500 sats @carol │ +│ ... │ +└─────────────────────────┘ +``` + +**Effort:** ~6 hours (all sub-screens) +**Files created:** +- `desktopApp/.../ui/wallet/WalletColumnScreen.kt` +- `desktopApp/.../ui/wallet/WalletHomeContent.kt` +- `desktopApp/.../ui/wallet/AddWalletContent.kt` +- `desktopApp/.../ui/wallet/WalletSendContent.kt` +- `desktopApp/.../ui/wallet/WalletReceiveContent.kt` +- `desktopApp/.../ui/wallet/WalletTransactionsContent.kt` + +### Step 1.6: Migrate NWC Config from RelaySettings to Wallet Column +- Remove NWC section from `RelaySettingsScreen` +- Add "Manage in Wallet column" link if wallet column exists +- NWC connection management now lives in AddWalletScreen + +**Effort:** ~1 hour + +### Step 1.7: QR Code Generation (Desktop) +- Need QR code composable for WalletReceiveScreen +- Options: `io.github.alexzhirkevich:qrose` (KMP QR library) or ZXing +- Already used on Android? Check and reuse if possible + +**Effort:** ~2 hours (evaluate + integrate) + +--- + +## Phase 2: Zapping UX Upgrade + +### Goal +Rich zapping with zap types, configurable presets, one-click zaps, keyboard shortcuts, and progress feedback. + +### Step 2.1: Extract Zap ViewModels to Commons +**Target:** `commons/src/commonMain/.../viewmodels/zap/` + +| Component | Source | Notes | +|-----------|--------|-------| +| `ZapOptionViewModel` | ZapCustomDialog.kt:96-113 | customAmount + customMessage state | +| `UpdateZapAmountViewModel` | UpdateZapAmountViewModel.kt | zapAmounts, selectedZapType, NWC config | + +**Effort:** ~1 hour +**Files created:** `commons/.../viewmodels/zap/ZapOptionViewModel.kt`, `ZapSettingsViewModel.kt` + +### Step 2.2: Extract Zap UI Components to Commons +**Target:** `commons/src/commonMain/.../ui/components/zap/` + +| Component | Source | Platform deps | Action | +|-----------|--------|--------------|--------| +| `ZapCustomDialog` | ZapCustomDialog.kt:117-332 | None | Move as-is | +| `ZapAmountChoicePopup` | ReactionsRow.kt:1778-1897 | None | Extract to own file | +| `ZapTypeSelector` | ZapCustomDialog.kt (type chips) | None | Extract as standalone | +| `UpdateZapAmountContent` | UpdateZapAmountDialog.kt:138-649 | BiometricPrompt (skip on desktop) | Extract, make auth optional | + +**Platform-specific (expect/actual):** +| Function | commonMain | androidMain | jvmMain | +|----------|-----------|-------------|---------| +| `payInvoice(invoice)` | expect | Intent ACTION_VIEW | `Desktop.browse(URI("lightning:$invoice"))` | + +**Effort:** ~3 hours +**Files created:** ~4 files in `commons/.../ui/components/zap/` + +### Step 2.3: Upgrade Desktop ZapAmountDialog +Replace current basic `ZapAmountDialog` in `NoteActions.kt` with extracted `ZapCustomDialog`. + +**New features:** +- Zap type selection (PUBLIC/PRIVATE/ANONYMOUS) +- Configurable preset amounts from account settings +- Custom amount + message input +- Progress feedback during payment + +**Effort:** ~2 hours +**Files modified:** `desktopApp/.../ui/NoteActions.kt` + +### Step 2.4: One-Click Zap +**Behavior:** +- Single left-click on zap icon → send default amount as default zap type (no dialog) +- Right-click on zap icon → open ZapCustomDialog +- Visual feedback: brief flash/highlight on successful zap + +**Implementation:** +- Read `account.settings.syncedSettings.zaps.zapAmountChoices[0]` as default +- Read `account.settings.syncedSettings.zaps.defaultZapType` as default type +- Call `ZapAction.fetchZapInvoice()` → `NwcPaymentHandler.payInvoice()` inline +- Show success/error via snackbar + +**Effort:** ~2 hours +**Files modified:** `desktopApp/.../ui/NoteActions.kt` + +### Step 2.5: Keyboard Shortcut Zapping +**Design:** +- `Z` — zap focused/hovered note with default amount (same as one-click) +- `Shift+Z` — open ZapCustomDialog for focused/hovered note + +**Implementation:** +- Desktop already has keyboard shortcuts in MenuBar (Main.kt) +- Need "focused note" concept — track which note the mouse is hovering over or keyboard-navigated to +- Add to existing `KeyShortcut` system + +**Note:** This requires a "focused note" tracking system. If note focus doesn't exist yet, this becomes more complex. May need to defer to Phase 2b or implement basic hover-tracking first. + +**Effort:** ~3 hours (if focus system exists) / ~6 hours (if building focus tracking) +**Risk:** Medium — depends on existing focus/hover infrastructure + +### Step 2.6: Zap Progress & Animations +**Behavior:** +- During payment: zap icon pulses or shows mini spinner +- On success: brief lightning flash effect +- On error: red shake + snackbar + +**Implementation:** +- Extract `ObserveZapIcon` pattern from Android +- Use `Animatable` for pulse/flash +- Integrate with `NwcPaymentHandler` response callback + +**Effort:** ~2 hours + +### Step 2.7: Zap Settings in Wallet Column +Add "Zap Settings" section to WalletHomeScreen: +- Configure preset amounts (drag-to-reorder) +- Set default zap type +- Set default zap amount for one-click + +**Effort:** ~2 hours + +--- + +## Implementation Order + +``` +Phase 1 Phase 2 +──────── ──────── +1.1 Extract types (1h) ──→ 2.1 Extract zap VMs (1h) +1.2 SharedWalletViewModel (3h) ──→ 2.2 Extract zap UI (3h) +1.3 Platform utils (1h) 2.3 Upgrade zap dialog (2h) +1.4 DeckColumnType.Wallet (1h) 2.4 One-click zap (2h) +1.5 Desktop wallet screens (6h) 2.5 Keyboard shortcuts (3-6h) +1.6 Migrate NWC config (1h) 2.6 Zap animations (2h) +1.7 QR code generation (2h) 2.7 Zap settings (2h) +──── ──── +~15h total ~13-16h total +``` + +**Critical path:** 1.1 → 1.2 → 1.4 → 1.5 (wallet column must exist before zap settings in 2.7) +**Parallelizable:** 1.3 + 1.4 can happen alongside 1.2; 2.1 + 2.2 can happen alongside 1.5 + +--- + +## Testing Strategy + +### Phase 1 Testing +- [ ] Connect NWC wallet via clipboard paste (Alby Hub, Phoenix) +- [ ] Balance refreshes and displays correctly +- [ ] Send payment to BOLT11 invoice +- [ ] Create receive invoice + display QR +- [ ] Transaction list loads with filter tabs +- [ ] Multi-wallet: add second wallet, switch default +- [ ] Wallet column persists across app restart +- [ ] Remove wallet + re-add + +### Phase 2 Testing +- [ ] Zap dialog shows type selection (PUBLIC/PRIVATE/ANONYMOUS) +- [ ] Custom amount + message work +- [ ] One-click zap sends default amount without dialog +- [ ] Right-click opens custom zap dialog +- [ ] Keyboard Z zaps focused note +- [ ] Keyboard Shift+Z opens dialog +- [ ] Zap progress animation shows during payment +- [ ] Success/error feedback via snackbar +- [ ] Zap settings persist (amounts, type, default) + +--- + +## Risk Assessment + +| Risk | Severity | Mitigation | +|------|----------|------------| +| WalletViewModel has hidden Android deps | Low | Agent analysis shows clean extraction | +| QR generation library compatibility | Low | Multiple KMP options exist | +| Keyboard shortcut needs focus tracking | Medium | Can defer to Phase 2b; one-click works without it | +| NWC wallet response timeouts | Low | Already handled in NwcPaymentHandler (30s timeout) | +| Account settings sync between platforms | Medium | Using existing AccountSettings infrastructure | + +--- + +## Files Summary + +### New Files (~15) +``` +commons/src/commonMain/.../viewmodels/wallet/WalletTypes.kt +commons/src/commonMain/.../viewmodels/wallet/SharedWalletViewModel.kt +commons/src/commonMain/.../viewmodels/zap/ZapOptionViewModel.kt +commons/src/commonMain/.../viewmodels/zap/ZapSettingsViewModel.kt +commons/src/commonMain/.../ui/components/zap/ZapCustomDialog.kt +commons/src/commonMain/.../ui/components/zap/ZapAmountChoicePopup.kt +commons/src/commonMain/.../ui/components/zap/ZapTypeSelector.kt +commons/src/commonMain/.../platform/ClipboardUtils.kt (expect) +commons/src/androidMain/.../platform/ClipboardUtils.kt (actual) +commons/src/jvmMain/.../platform/ClipboardUtils.kt (actual) +desktopApp/.../ui/wallet/WalletColumnScreen.kt +desktopApp/.../ui/wallet/WalletHomeContent.kt +desktopApp/.../ui/wallet/AddWalletContent.kt +desktopApp/.../ui/wallet/WalletSendContent.kt +desktopApp/.../ui/wallet/WalletReceiveContent.kt +desktopApp/.../ui/wallet/WalletTransactionsContent.kt +``` + +### Modified Files (~8) +``` +amethyst/.../wallet/WalletViewModel.kt (delegate to shared) +desktopApp/.../deck/DeckColumnType.kt (add Wallet) +desktopApp/.../deck/DeckColumnContainer.kt (add RootContent case) +desktopApp/.../deck/AppDrawer.kt (add to LAUNCHABLE_SCREENS) +desktopApp/.../Main.kt (MenuBar + migrate NWC) +desktopApp/.../ui/NoteActions.kt (upgrade zap dialog, one-click, keyboard) +desktopApp/.../RelaySettingsScreen (remove NWC section) +``` + +--- + +## Unanswered Questions + +1. Does desktop have a "focused/hovered note" concept for keyboard shortcuts? If not, how much work to add? +2. QR code library: is `qrose` already a dependency, or do we need to add it? What does Android use? +3. Should wallet balance show in sidebar icon (badge) or only in wallet column? +4. UserPicture/UsernameDisplay for transaction list — are these already in commons or need extraction? +5. How should wallet column handle being opened when no wallet is connected? (show AddWallet immediately?) +6. Should the wallet column auto-refresh balance on a timer, or only on user action? diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 5b92c6c1e5..3aa67d8601 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -590,6 +590,7 @@ fun main() { Item("Profile", onClick = { deckState.addColumn(DeckColumnType.MyProfile) }) Item("Chess", onClick = { deckState.addColumn(DeckColumnType.Chess) }) Item("Relays", onClick = { deckState.addColumn(DeckColumnType.Relays) }) + Item("Wallet", onClick = { deckState.addColumn(DeckColumnType.Wallet) }) } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt index 490125e088..5698a8679b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt @@ -49,7 +49,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.isSecondaryPressed +import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.Bookmark import com.vitorpamplona.amethyst.commons.icons.BookmarkFilled @@ -87,7 +91,19 @@ import java.awt.Toolkit import java.awt.datatransfer.StringSelection import kotlin.coroutines.resume -private val ZAP_AMOUNTS = listOf(21L, 100L, 500L, 1000L, 5000L, 10000L) +private val DEFAULT_ZAP_AMOUNTS = listOf(21L, 100L, 500L, 1000L, 5000L, 10000L) + +/** + * Zap type for the zap dialog. + */ +enum class ZapType( + val label: String, + val description: String, +) { + PUBLIC("Public", "Everyone sees your zap"), + PRIVATE("Private", "Only recipient sees your identity"), + ANONYMOUS("Anonymous", "No identity attached"), +} /** * Feedback from a zap operation for UI display. @@ -150,58 +166,120 @@ fun getDisplayName( } /** - * Dialog for selecting zap amount and optional message. + * Dialog for selecting zap amount, type, and optional message. */ @Composable fun ZapAmountDialog( onDismiss: () -> Unit, - onZap: (Long, String) -> Unit, + onZap: (Long, String, ZapType) -> Unit, + zapAmounts: List = DEFAULT_ZAP_AMOUNTS, + defaultZapType: ZapType = ZapType.PUBLIC, ) { - var selectedAmount by remember { mutableStateOf(21L) } + var selectedAmount by remember { mutableStateOf(zapAmounts.firstOrNull() ?: 21L) } + var customAmount by remember { mutableStateOf("") } + var useCustom by remember { mutableStateOf(false) } var message by remember { mutableStateOf("") } + var selectedType by remember { mutableStateOf(defaultZapType) } + + val effectiveAmount = if (useCustom) customAmount.toLongOrNull() ?: 0L else selectedAmount AlertDialog( onDismissRequest = onDismiss, title = { Text("Zap") }, text = { Column { + // Amount selection Text( - "Select amount in sats", - style = MaterialTheme.typography.bodyMedium, + "Amount (sats)", + style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Spacer(Modifier.height(12.dp)) + Spacer(Modifier.height(8.dp)) Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), ) { - ZAP_AMOUNTS.take(3).forEach { amount -> + zapAmounts.take(3).forEach { amount -> FilterChip( - selected = selectedAmount == amount, - onClick = { selectedAmount = amount }, + selected = !useCustom && selectedAmount == amount, + onClick = { + selectedAmount = amount + useCustom = false + }, label = { Text("$amount") }, ) } } - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(4.dp)) Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), ) { - ZAP_AMOUNTS.drop(3).forEach { amount -> + zapAmounts.drop(3).forEach { amount -> FilterChip( - selected = selectedAmount == amount, - onClick = { selectedAmount = amount }, + selected = !useCustom && selectedAmount == amount, + onClick = { + selectedAmount = amount + useCustom = false + }, label = { Text(formatSats(amount)) }, ) } + FilterChip( + selected = useCustom, + onClick = { useCustom = true }, + label = { Text("Custom") }, + ) } + + if (useCustom) { + Spacer(Modifier.height(8.dp)) + androidx.compose.material3.OutlinedTextField( + value = customAmount, + onValueChange = { new -> if (new.all { it.isDigit() }) customAmount = new }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Custom amount") }, + placeholder = { Text("Enter sats...") }, + singleLine = true, + ) + } + Spacer(Modifier.height(16.dp)) + + // Zap type selection + Text( + "Zap Type", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + ZapType.entries.forEach { type -> + FilterChip( + selected = selectedType == type, + onClick = { selectedType = type }, + label = { Text(type.label) }, + ) + } + } + + Spacer(Modifier.height(16.dp)) + + // Message + val messageLabel = + when (selectedType) { + ZapType.PRIVATE -> "Private message (only recipient sees)" + ZapType.ANONYMOUS -> "Message (optional)" + ZapType.PUBLIC -> "Message (optional)" + } androidx.compose.material3.OutlinedTextField( value = message, onValueChange = { message = it }, modifier = Modifier.fillMaxWidth(), - label = { Text("Message (optional)") }, + label = { Text(messageLabel) }, placeholder = { Text("Add a comment...") }, singleLine = false, maxLines = 3, @@ -209,8 +287,11 @@ fun ZapAmountDialog( } }, confirmButton = { - Button(onClick = { onZap(selectedAmount, message) }) { - Text("Zap ${formatSats(selectedAmount)} sats") + Button( + onClick = { onZap(effectiveAmount, message, selectedType) }, + enabled = effectiveAmount > 0, + ) { + Text("Zap ${formatSats(effectiveAmount)} sats") } }, dismissButton = { @@ -610,7 +691,7 @@ fun NoteActionsRow( } } - // Zap button with amount (clickable to show receipts) + // Zap button: left-click = quick zap (default amount), right-click = custom dialog Row(verticalAlignment = Alignment.CenterVertically) { Box(modifier = Modifier.size(32.dp), contentAlignment = Alignment.Center) { if (isZapping) { @@ -620,9 +701,40 @@ fun NoteActionsRow( color = MaterialTheme.colorScheme.primary, ) } else { + @OptIn(ExperimentalComposeUiApi::class) IconButton( - onClick = { showZapDialog = true }, - modifier = Modifier.size(32.dp), + onClick = { + // Quick zap with default amount (first preset) + if (nwcConnection != null) { + val defaultAmount = DEFAULT_ZAP_AMOUNTS.first() + scope.launch { + isZapping = true + val feedback = + zapNote( + event = event, + account = account, + relayManager = relayManager, + localCache = localCache, + amountSats = defaultAmount, + message = "", + nwcConnection = nwcConnection, + ) + isZapping = false + onZapFeedback(feedback) + } + } else { + // No wallet connected — open dialog for external wallet fallback + showZapDialog = true + } + }, + modifier = + Modifier + .size(32.dp) + .onPointerEvent(PointerEventType.Press) { pointerEvent -> + if (pointerEvent.buttons.isSecondaryPressed) { + showZapDialog = true + } + }, ) { Icon( Zap, @@ -771,7 +883,7 @@ fun NoteActionsRow( if (showZapDialog) { ZapAmountDialog( onDismiss = { showZapDialog = false }, - onZap = { amountSats, message -> + onZap = { amountSats, message, zapType -> showZapDialog = false scope.launch { isZapping = true @@ -784,6 +896,7 @@ fun NoteActionsRow( amountSats = amountSats, message = message, nwcConnection = nwcConnection, + // TODO: pass zapType to ZapAction for PRIVATE/ANONYMOUS zap support ) isZapping = false onZapFeedback(feedback) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt index 27b578dfc7..aa7139cdbe 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt @@ -128,6 +128,7 @@ fun DeckColumnType.category(): ScreenCategory = DeckColumnType.MyProfile, DeckColumnType.Settings, + DeckColumnType.Wallet, -> ScreenCategory.IDENTITY DeckColumnType.Chess -> ScreenCategory.PLAY @@ -176,6 +177,7 @@ val LAUNCHABLE_SCREENS: List = DeckColumnType.Settings, DeckColumnType.Relays, DeckColumnType.Chess, + DeckColumnType.Wallet, ) // -- Tabs -- diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt index e2dbe9136a..d1e8cf9a74 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt @@ -121,6 +121,7 @@ fun DeckColumnType.icon(): MaterialSymbol = DeckColumnType.Chess -> MaterialSymbols.Extension DeckColumnType.Settings -> MaterialSymbols.Settings DeckColumnType.Relays -> MaterialSymbols.Dns + DeckColumnType.Wallet -> MaterialSymbols.AccountBalanceWallet is DeckColumnType.Article -> MaterialSymbols.AutoMirrored.Article is DeckColumnType.Editor -> MaterialSymbols.AutoMirrored.Article DeckColumnType.Drafts -> MaterialSymbols.AutoMirrored.Article diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 3dae8973b4..08678668c4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -356,6 +356,17 @@ internal fun RootContent( ) } + DeckColumnType.Wallet -> { + com.vitorpamplona.amethyst.desktop.ui.wallet.WalletColumnScreen( + account = account, + relayManager = relayManager, + localCache = localCache, + nwcConnection = nwcConnection, + appScope = appScope, + onZapFeedback = onZapFeedback, + ) + } + DeckColumnType.Relays -> { val accountRelays = com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays.current RelayDashboardScreen( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt index fd76311ba1..071ad2a56a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt @@ -45,6 +45,8 @@ sealed class DeckColumnType { object Relays : DeckColumnType() + object Wallet : DeckColumnType() + data class Profile( val pubKeyHex: String, ) : DeckColumnType() @@ -88,6 +90,7 @@ sealed class DeckColumnType { Chess -> "Chess" Settings -> "Settings" Relays -> "Relays" + Wallet -> "Wallet" is Article -> "Article" is Editor -> "New Article" Drafts -> "Drafts" @@ -111,6 +114,7 @@ sealed class DeckColumnType { Chess -> "chess" Settings -> "settings" Relays -> "relays" + Wallet -> "wallet" is Article -> "article" is Editor -> "editor" Drafts -> "drafts" diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt index 8ff785dffc..1fd4284345 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt @@ -299,6 +299,7 @@ class DeckState( "chess" -> DeckColumnType.Chess "settings" -> DeckColumnType.Settings "relays" -> DeckColumnType.Relays + "wallet" -> DeckColumnType.Wallet "drafts" -> DeckColumnType.Drafts "highlights" -> DeckColumnType.MyHighlights "editor" -> DeckColumnType.Editor(param) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt new file mode 100644 index 0000000000..d3ab7f0f9c --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt @@ -0,0 +1,696 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.wallet + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.nwc.NwcPaymentHandler +import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import java.awt.Toolkit +import java.awt.datatransfer.DataFlavor +import java.awt.datatransfer.StringSelection +import java.text.NumberFormat +import java.util.Locale + +enum class WalletScreen { + HOME, + CONNECT, + SEND, + RECEIVE, +} + +@Composable +fun WalletColumnScreen( + account: AccountState.LoggedIn, + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + nwcConnection: Nip47URINorm?, + appScope: CoroutineScope, + onZapFeedback: (ZapFeedback) -> Unit, +) { + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + var currentScreen by remember { mutableStateOf(WalletScreen.HOME) } + + // NWC connection state + var nwcUri by remember { mutableStateOf("") } + var isConnecting by remember { mutableStateOf(false) } + var connectionError by remember { mutableStateOf(null) } + + // Balance state + var balanceSats by remember { mutableStateOf(null) } + var isLoadingBalance by remember { mutableStateOf(false) } + + // Send state + var sendInvoice by remember { mutableStateOf("") } + var isSending by remember { mutableStateOf(false) } + var sendResult by remember { mutableStateOf(null) } + + // Receive state + var receiveAmount by remember { mutableStateOf("") } + var receiveDescription by remember { mutableStateOf("") } + var generatedInvoice by remember { mutableStateOf(null) } + var isGenerating by remember { mutableStateOf(false) } + + val paymentHandler = + remember(relayManager, localCache) { + NwcPaymentHandler(relayManager, localCache) + } + + Column(modifier = Modifier.fillMaxSize()) { + when (currentScreen) { + WalletScreen.HOME -> { + WalletHomeContent( + nwcConnection = nwcConnection, + balanceSats = balanceSats, + isLoadingBalance = isLoadingBalance, + onConnect = { currentScreen = WalletScreen.CONNECT }, + onSend = { currentScreen = WalletScreen.SEND }, + onReceive = { currentScreen = WalletScreen.RECEIVE }, + onRefreshBalance = { + if (nwcConnection != null) { + isLoadingBalance = true + scope.launch { + // TODO: implement NWC get_balance RPC + isLoadingBalance = false + } + } + }, + onDisconnect = { + // TODO: clear NWC from account settings + scope.launch { + snackbarHostState.showSnackbar("Disconnect from account settings") + } + }, + ) + } + + WalletScreen.CONNECT -> { + ConnectWalletContent( + nwcUri = nwcUri, + isConnecting = isConnecting, + error = connectionError, + onUriChanged = { + nwcUri = it + connectionError = null + }, + onPasteFromClipboard = { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + val text = + try { + clipboard.getData(DataFlavor.stringFlavor) as? String + } catch (_: Exception) { + null + } + if (text != null) { + nwcUri = text + } + }, + onConnect = { + val parsed = Nip47WalletConnect.parse(nwcUri) + if (parsed != null) { + // TODO: save to account settings + isConnecting = true + scope.launch { + snackbarHostState.showSnackbar("Wallet connected! Restart to apply.") + isConnecting = false + currentScreen = WalletScreen.HOME + } + } else { + connectionError = "Invalid NWC URI. Expected: nostr+walletconnect://..." + } + }, + onBack = { currentScreen = WalletScreen.HOME }, + ) + } + + WalletScreen.SEND -> { + SendContent( + invoice = sendInvoice, + isSending = isSending, + result = sendResult, + onInvoiceChanged = { + sendInvoice = it + sendResult = null + }, + onPaste = { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + val text = + try { + clipboard.getData(DataFlavor.stringFlavor) as? String + } catch (_: Exception) { + null + } + if (text != null) sendInvoice = text + }, + onSend = { + if (nwcConnection != null && sendInvoice.isNotBlank()) { + isSending = true + sendResult = null + scope.launch { + val result = + paymentHandler.payInvoice( + bolt11 = sendInvoice, + nwcConnection = nwcConnection, + ) + when (result) { + is NwcPaymentHandler.PaymentResult.Success -> { + sendResult = "Payment successful!" + sendInvoice = "" + } + + is NwcPaymentHandler.PaymentResult.Error -> { + sendResult = "Error: ${result.message}" + } + + is NwcPaymentHandler.PaymentResult.Timeout -> { + sendResult = "Payment timed out" + } + } + isSending = false + } + } + }, + hasWallet = nwcConnection != null, + onBack = { currentScreen = WalletScreen.HOME }, + ) + } + + WalletScreen.RECEIVE -> { + ReceiveContent( + amount = receiveAmount, + description = receiveDescription, + generatedInvoice = generatedInvoice, + isGenerating = isGenerating, + onAmountChanged = { receiveAmount = it }, + onDescriptionChanged = { receiveDescription = it }, + onGenerate = { + // TODO: implement NWC make_invoice RPC + scope.launch { + isGenerating = true + snackbarHostState.showSnackbar("make_invoice not yet implemented") + isGenerating = false + } + }, + onCopyInvoice = { invoice -> + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(StringSelection(invoice), null) + scope.launch { + snackbarHostState.showSnackbar("Invoice copied to clipboard") + } + }, + hasWallet = nwcConnection != null, + onBack = { currentScreen = WalletScreen.HOME }, + ) + } + } + + Spacer(modifier = Modifier.weight(1f)) + SnackbarHost(hostState = snackbarHostState) + } +} + +@Composable +private fun WalletHomeContent( + nwcConnection: Nip47URINorm?, + balanceSats: Long?, + isLoadingBalance: Boolean, + onConnect: () -> Unit, + onSend: () -> Unit, + onReceive: () -> Unit, + onRefreshBalance: () -> Unit, + onDisconnect: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (nwcConnection == null) { + // No wallet connected + NoWalletContent(onConnect = onConnect) + } else { + // Connected wallet + WalletBalanceCard( + balanceSats = balanceSats, + isLoading = isLoadingBalance, + walletRelay = nwcConnection.relayUri.toString(), + onRefresh = onRefreshBalance, + ) + + // Quick actions + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = onSend, + modifier = Modifier.weight(1f), + ) { + Icon(symbol = MaterialSymbols.ArrowUpward, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Send") + } + OutlinedButton( + onClick = onReceive, + modifier = Modifier.weight(1f), + ) { + Icon(symbol = MaterialSymbols.ArrowDownward, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Receive") + } + } + + HorizontalDivider() + + // Connection info + Text( + text = "Connected Wallet", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "Relay: ${nwcConnection.relayUri}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "Wallet: ${nwcConnection.pubKeyHex.take(8)}...${nwcConnection.pubKeyHex.takeLast(8)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + TextButton(onClick = onDisconnect) { + Text("Disconnect", color = MaterialTheme.colorScheme.error) + } + } + } +} + +@Composable +private fun NoWalletContent(onConnect: () -> Unit) { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + symbol = MaterialSymbols.AccountBalanceWallet, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "No Wallet Connected", + style = MaterialTheme.typography.titleLarge, + ) + Text( + text = "Connect a Lightning wallet via\nNostr Wallet Connect (NWC)\nto send and receive sats.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = onConnect) { + Text("Connect Wallet") + } + } +} + +@Composable +private fun WalletBalanceCard( + balanceSats: Long?, + isLoading: Boolean, + walletRelay: String, + onRefresh: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Balance", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + TextButton(onClick = onRefresh, enabled = !isLoading) { + if (isLoading) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + } else { + Text("Refresh", style = MaterialTheme.typography.labelSmall) + } + } + } + if (balanceSats != null) { + Text( + text = "${formatSats(balanceSats)} sats", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } else { + Text( + text = "-- sats", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.5f), + ) + } + } + } +} + +@Composable +private fun ConnectWalletContent( + nwcUri: String, + isConnecting: Boolean, + error: String?, + onUriChanged: (String) -> Unit, + onPasteFromClipboard: () -> Unit, + onConnect: () -> Unit, + onBack: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + TextButton(onClick = onBack) { + Icon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Back") + } + + Text( + text = "Connect Wallet", + style = MaterialTheme.typography.titleLarge, + ) + + Text( + text = "Paste your Nostr Wallet Connect URI to connect a Lightning wallet.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + OutlinedTextField( + value = nwcUri, + onValueChange = onUriChanged, + label = { Text("NWC URI") }, + placeholder = { Text("nostr+walletconnect://...") }, + modifier = Modifier.fillMaxWidth(), + singleLine = false, + maxLines = 4, + isError = error != null, + supportingText = error?.let { { Text(it) } }, + ) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = onPasteFromClipboard) { + Text("Paste from Clipboard") + } + } + + Button( + onClick = onConnect, + enabled = nwcUri.isNotBlank() && !isConnecting, + modifier = Modifier.fillMaxWidth(), + ) { + if (isConnecting) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + Spacer(modifier = Modifier.width(8.dp)) + } + Text("Connect") + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + Text( + text = "Supported wallets:", + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = "Alby Hub, Phoenix, Coinos, LNbits, Zeus, Mutiny, Strike", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "Get an NWC connection URI from your wallet's settings.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun SendContent( + invoice: String, + isSending: Boolean, + result: String?, + onInvoiceChanged: (String) -> Unit, + onPaste: () -> Unit, + onSend: () -> Unit, + hasWallet: Boolean, + onBack: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + TextButton(onClick = onBack) { + Icon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Back") + } + + Text( + text = "Send Payment", + style = MaterialTheme.typography.titleLarge, + ) + + if (!hasWallet) { + Text( + text = "Connect a wallet first to send payments.", + color = MaterialTheme.colorScheme.error, + ) + return + } + + OutlinedTextField( + value = invoice, + onValueChange = onInvoiceChanged, + label = { Text("BOLT11 Invoice") }, + placeholder = { Text("lnbc...") }, + modifier = Modifier.fillMaxWidth(), + singleLine = false, + maxLines = 6, + ) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = onPaste) { + Text("Paste") + } + } + + Button( + onClick = onSend, + enabled = invoice.isNotBlank() && !isSending, + modifier = Modifier.fillMaxWidth(), + ) { + if (isSending) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary) + Spacer(modifier = Modifier.width(8.dp)) + Text("Sending...") + } else { + Text("Pay Invoice") + } + } + + if (result != null) { + val isError = result.startsWith("Error") || result.contains("timed out") + Text( + text = result, + color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + } + } +} + +@Composable +private fun ReceiveContent( + amount: String, + description: String, + generatedInvoice: String?, + isGenerating: Boolean, + onAmountChanged: (String) -> Unit, + onDescriptionChanged: (String) -> Unit, + onGenerate: () -> Unit, + onCopyInvoice: (String) -> Unit, + hasWallet: Boolean, + onBack: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + TextButton(onClick = onBack) { + Icon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Back") + } + + Text( + text = "Receive Payment", + style = MaterialTheme.typography.titleLarge, + ) + + if (!hasWallet) { + Text( + text = "Connect a wallet first to receive payments.", + color = MaterialTheme.colorScheme.error, + ) + return + } + + OutlinedTextField( + value = amount, + onValueChange = { new -> if (new.all { it.isDigit() }) onAmountChanged(new) }, + label = { Text("Amount (sats)") }, + placeholder = { Text("1000") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + OutlinedTextField( + value = description, + onValueChange = onDescriptionChanged, + label = { Text("Description (optional)") }, + placeholder = { Text("What's this for?") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Button( + onClick = onGenerate, + enabled = amount.isNotBlank() && !isGenerating, + modifier = Modifier.fillMaxWidth(), + ) { + if (isGenerating) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary) + Spacer(modifier = Modifier.width(8.dp)) + } + Text("Create Invoice") + } + + if (generatedInvoice != null) { + HorizontalDivider() + Text( + text = "Invoice Created", + style = MaterialTheme.typography.titleSmall, + ) + Card(modifier = Modifier.fillMaxWidth()) { + Text( + text = generatedInvoice, + modifier = Modifier.padding(12.dp), + style = MaterialTheme.typography.bodySmall, + maxLines = 4, + ) + } + Button( + onClick = { onCopyInvoice(generatedInvoice) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Copy Invoice") + } + } + } +} + +private fun formatSats(sats: Long): String = NumberFormat.getNumberInstance(Locale.getDefault()).format(sats)