mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
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:
co-authored by
Claude Opus 4.6
parent
6cd4de51aa
commit
8313f0cf12
@@ -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) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+136
-23
@@ -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 --
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+11
@@ -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(
|
||||
|
||||
+4
@@ -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)
|
||||
|
||||
+696
@@ -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)
|
||||
Reference in New Issue
Block a user