mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
fix(cashu): show "discovering" state instead of empty-create CTA on first launch
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<Boolean>` 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
This commit is contained in:
+46
@@ -133,6 +133,24 @@ class CashuWalletState(
|
||||
private val _pendingQuotes = MutableStateFlow<List<CashuMintQuoteEvent>>(emptyList())
|
||||
val pendingQuotes: StateFlow<List<CashuMintQuoteEvent>> = _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<Boolean> = _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. */
|
||||
|
||||
+55
-17
@@ -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,
|
||||
|
||||
+1
@@ -145,6 +145,7 @@ class CashuWalletViewModel : ViewModel() {
|
||||
val tokenEntries: StateFlow<List<TokenEntry>> get() = state.tokenEntries
|
||||
val history get() = state.history
|
||||
val pendingQuotes get() = state.pendingQuotes
|
||||
val discovering get() = state.discovering
|
||||
|
||||
private val _createState = MutableStateFlow<CashuWalletCreateState>(CashuWalletCreateState.Idle)
|
||||
val createState = _createState.asStateFlow()
|
||||
|
||||
@@ -1876,6 +1876,8 @@
|
||||
<string name="cashu_wallet_title">Cashu Wallet</string>
|
||||
<string name="cashu_no_wallet">No Cashu wallet yet</string>
|
||||
<string name="cashu_no_wallet_description">Create a Cashu wallet to hold ecash tokens and receive nutzaps.</string>
|
||||
<string name="cashu_discovering">Looking for your wallet…</string>
|
||||
<string name="cashu_discovering_description">NIP-60 wallets sync across clients. If you created one in another app under this Nostr key, it should appear in a few seconds.</string>
|
||||
<string name="cashu_balance">Balance</string>
|
||||
<string name="cashu_mints">Mints</string>
|
||||
<string name="cashu_mint_url">Mint URL</string>
|
||||
|
||||
Reference in New Issue
Block a user