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) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-05-21 09:31:38 +03:00
co-authored by Claude Opus 4.6
parent 6cd4de51aa
commit 8313f0cf12
11 changed files with 2112 additions and 23 deletions
@@ -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/<pubkey8>/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<Event>(
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/<pubkey8>/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<ContactListEvent>(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<MetadataEvent>(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<Event>(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<Boolean> = _diskWarning
private val _lastError = MutableStateFlow<String?>(null)
val lastError: StateFlow<String?> = _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
@@ -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?
@@ -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<WalletInfo>, StateFlow<SendState>, etc.)
- `Account` dependency is already platform-agnostic
```kotlin
class SharedWalletViewModel(
val account: Account,
val scope: CoroutineScope,
) {
val walletInfoList: StateFlow<List<WalletInfo>>
val sendState: StateFlow<WalletSendState>
val receiveState: StateFlow<WalletReceiveState>
val transactions: StateFlow<List<Transaction>>
val selectedFilter: StateFlow<TransactionFilter>
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?
@@ -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) })
}
}
}
@@ -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<Long> = 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)
@@ -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> =
DeckColumnType.Settings,
DeckColumnType.Relays,
DeckColumnType.Chess,
DeckColumnType.Wallet,
)
// -- Tabs --
@@ -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
@@ -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(
@@ -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"
@@ -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)
@@ -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<String?>(null) }
// Balance state
var balanceSats by remember { mutableStateOf<Long?>(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<String?>(null) }
// Receive state
var receiveAmount by remember { mutableStateOf("") }
var receiveDescription by remember { mutableStateOf("") }
var generatedInvoice by remember { mutableStateOf<String?>(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)