From bb86df246ad3b511abc5c4bdacf4f628283b75c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 20:18:45 +0000 Subject: [PATCH] fix(cashu): show "discovering" state instead of empty-create CTA on first launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NIP-60 wallets are portable — kind:17375 + 7375 + 7376 + 10019 are stored on relays, so a wallet created in another client (cashu.me, Boardwalk, etc.) should appear in Amethyst when the user signs in with the same Nostr key. The plumbing already handles this: our CashuWalletFilterAssembler subscribes to kinds=[17375, 7375, 7376, 7374, 10019] authored by us, and CashuWalletState.applyEvents() indexes incoming events regardless of which client published them. The UX bug: the wallet screen had two states (wallet event present / absent). On first launch, before relays delivered the existing wallet event, we rendered the "No Cashu wallet — Create" state. Tapping Create there published a fresh kind:17375 which (being replaceable) clobbered the remote wallet — destroying the P2PK key and orphaning any inbound nutzaps locked to it. Fix: * CashuWalletState gets a `discovering: StateFlow` set to true at start() until either a wallet event arrives (cleared from applyEvents) or DISCOVERY_TIMEOUT_MS (8 s) elapses, whichever first. * CashuWalletScreen renders a "Looking for your wallet…" pane with a spinner + explainer while discovering is true. Empty-create CTA only fires after timeout for genuinely wallet-less users. playDebug + fdroidDebug compile clean; 24/24 jvm tests still pass. https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP --- .../model/nip60Cashu/CashuWalletState.kt | 46 ++++++++++++ .../loggedIn/wallet/CashuWalletScreen.kt | 72 ++++++++++++++----- .../loggedIn/wallet/CashuWalletViewModel.kt | 1 + amethyst/src/main/res/values/strings.xml | 2 + 4 files changed, 104 insertions(+), 17 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt index 02d5790204..65dda7d923 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -133,6 +133,24 @@ class CashuWalletState( private val _pendingQuotes = MutableStateFlow>(emptyList()) val pendingQuotes: StateFlow> = _pendingQuotes.asStateFlow() + /** + * True while we're still waiting for relays to deliver an existing + * wallet event for this account. + * + * NIP-60 wallets are portable across clients — if the user previously + * created one in (say) Boardwalk or cashu.me, our subscription should + * pull the kind:17375 in within a few seconds of sign-in. The UI uses + * this flag to show a "Looking for your wallet…" state during that + * window instead of jumping straight to the "Create" CTA, which would + * overwrite the remote wallet (kind:17375 is replaceable). + * + * Cleared when: + * - a wallet event arrives via cache backfill or live update, OR + * - the discovery timeout elapses (~8 seconds), whichever first. + */ + private val _discovering = MutableStateFlow(false) + val discovering: StateFlow = _discovering.asStateFlow() + fun hasWallet(): Boolean = _walletEvent.value != null /** Read the wallet's P2PK pubkey (33-byte compressed hex). */ @@ -181,6 +199,21 @@ class CashuWalletState( started = true this.publish = publish + // Show the "discovering" state until either a wallet event lands + // (set inside applyEvents()) or the timeout below fires. Without + // this the UI would render the empty-wallet CTA the moment the + // screen opens — even for users whose wallet exists on relays but + // hasn't yet been delivered — and tapping Create there would + // overwrite the remote kind:17375. + if (_walletEvent.value == null) { + _discovering.value = true + jobs += + scope.launch(Dispatchers.Default) { + kotlinx.coroutines.delay(DISCOVERY_TIMEOUT_MS) + _discovering.value = false + } + } + // Backfill from cache once. scope.launch(Dispatchers.Default) { val initial = scanCacheForOwnEvents() @@ -294,6 +327,9 @@ class CashuWalletState( if (dirtyWallet) { _walletEvent.value = walletEventInternal + // Any wallet event resolves the "discovering" state — whether it + // came from cache backfill or a fresh relay delivery. + _discovering.value = false walletEventInternal?.let { evt -> _mints.value = runCatching { evt.mints(signer) } @@ -524,6 +560,16 @@ class CashuWalletState( private suspend fun publishEvent(event: Event) { publish(event) } + + private companion object { + /** + * How long to remain in the "discovering" state before falling back + * to the empty-wallet CTA. Long enough that a typical relay round- + * trip on a cold start completes; short enough that genuinely + * wallet-less users don't stare at a spinner. + */ + const val DISCOVERY_TIMEOUT_MS = 8_000L + } } /** Mint + recipient pubkey resolved from a kind:10019. */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletScreen.kt index 4d95a4961c..c0fc8c4422 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletScreen.kt @@ -96,6 +96,7 @@ fun CashuWalletScreen( viewModel.init(accountViewModel) val walletEvent by viewModel.walletEvent.collectAsState() + val discovering by viewModel.discovering.collectAsState() val mints by viewModel.mints.collectAsState() val balanceSats by viewModel.balanceSats.collectAsState() val history by viewModel.history.collectAsState() @@ -139,23 +140,33 @@ fun CashuWalletScreen( ) }, ) { padding -> - if (walletEvent == null) { - EmptyCashuWallet( - modifier = Modifier.padding(padding), - onCreate = { nav.nav(Route.WalletAddCashu) }, - ) - } else { - CashuWalletContent( - modifier = Modifier.padding(padding), - balanceSats = balanceSats, - mints = mints, - history = history, - onReceive = { receiveOpen = true }, - onSendLn = { sendLnOpen = true }, - onSendToken = { sendTokenOpen = true }, - onRedeem = { redeemOpen = true }, - onRecommendMint = { viewModel.recommendMint(it) }, - ) + when { + walletEvent != null -> + CashuWalletContent( + modifier = Modifier.padding(padding), + balanceSats = balanceSats, + mints = mints, + history = history, + onReceive = { receiveOpen = true }, + onSendLn = { sendLnOpen = true }, + onSendToken = { sendTokenOpen = true }, + onRedeem = { redeemOpen = true }, + onRecommendMint = { viewModel.recommendMint(it) }, + ) + + // NIP-60 wallets are portable across clients — show a "looking + // for your wallet" state until the relay subscription returns + // anything (or times out). Without this, a user who created + // their wallet in another app would see the Create CTA on + // first launch and would clobber the remote kind:17375. + discovering -> + DiscoveringCashuWallet(modifier = Modifier.padding(padding)) + + else -> + EmptyCashuWallet( + modifier = Modifier.padding(padding), + onCreate = { nav.nav(Route.WalletAddCashu) }, + ) } } @@ -200,6 +211,33 @@ fun CashuWalletScreen( } } +@Composable +private fun DiscoveringCashuWallet(modifier: Modifier) { + Column( + modifier = + modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator(modifier = Modifier.size(36.dp), strokeWidth = 3.dp) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringRes(R.string.cashu_discovering), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringRes(R.string.cashu_discovering_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) + } +} + @Composable private fun EmptyCashuWallet( modifier: Modifier, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt index dd77ee4584..e084fdf25b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt @@ -145,6 +145,7 @@ class CashuWalletViewModel : ViewModel() { val tokenEntries: StateFlow> get() = state.tokenEntries val history get() = state.history val pendingQuotes get() = state.pendingQuotes + val discovering get() = state.discovering private val _createState = MutableStateFlow(CashuWalletCreateState.Idle) val createState = _createState.asStateFlow() diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 5f643a07d5..33a6b27858 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1876,6 +1876,8 @@ Cashu Wallet No Cashu wallet yet Create a Cashu wallet to hold ecash tokens and receive nutzaps. + Looking for your wallet… + NIP-60 wallets sync across clients. If you created one in another app under this Nostr key, it should appear in a few seconds. Balance Mints Mint URL