mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
Merge pull request #3380 from vitorpamplona/claude/adoring-babbage-stzzck
Auto-save Cashu wallet on mint add/remove, remove manual key picker
This commit is contained in:
+31
-106
@@ -33,10 +33,8 @@ 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.selection.selectable
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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
|
||||
@@ -48,7 +46,6 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
@@ -99,22 +96,16 @@ fun AddCashuWalletScreen(
|
||||
// suggestions on first open instead of waiting for the next relay
|
||||
// round-trip. Cheap (one sweep over the existing cache); idempotent.
|
||||
LaunchedEffect(Unit) { LocalCache.ensureMintDirectoryBackfilled() }
|
||||
// Keyed on isEditMode so that if the wallet is delivered AFTER this screen
|
||||
// first composes (existingWallet null → non-null), keyMode flips to
|
||||
// KeepCurrent. Otherwise a cold open with an undelivered wallet would keep
|
||||
// AutoGenerate and silently rotate the key on save, orphaning nutzaps.
|
||||
var keyMode by remember(isEditMode) {
|
||||
mutableStateOf(
|
||||
if (isEditMode) CashuWalletViewModel.P2pkKeyMode.KeepCurrent else CashuWalletViewModel.P2pkKeyMode.AutoGenerate,
|
||||
)
|
||||
}
|
||||
var manualPrivkey by remember { mutableStateOf("") }
|
||||
val createState by viewModel.createState.collectAsState()
|
||||
|
||||
// Pre-fill the mints list with the existing wallet's mints whenever we
|
||||
// enter edit mode (or the existing mints update). Without this, hitting
|
||||
// the Edit button silently wipes the user's mint list because the local
|
||||
// `mints` state holder starts empty.
|
||||
//
|
||||
// Mutates `mints` directly rather than going through `publishMints` so the
|
||||
// pre-fill never re-publishes the wallet — only genuine user add/remove
|
||||
// actions do.
|
||||
LaunchedEffect(existingMints) {
|
||||
if (existingMints.isNotEmpty()) {
|
||||
val current = mints.toSet()
|
||||
@@ -122,13 +113,6 @@ fun AddCashuWalletScreen(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(createState) {
|
||||
if (createState is CashuWalletCreateState.Success) {
|
||||
nav.popBack()
|
||||
viewModel.resetCreateState()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -196,7 +180,10 @@ fun AddCashuWalletScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { mints.removeAt(index) }) {
|
||||
IconButton(onClick = {
|
||||
mints.removeAt(index)
|
||||
viewModel.publishMints(mints.toList())
|
||||
}) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Delete,
|
||||
contentDescription = stringRes(R.string.cashu_remove_mint),
|
||||
@@ -277,6 +264,7 @@ fun AddCashuWalletScreen(
|
||||
mints.add(trimmed)
|
||||
mintInput = ""
|
||||
viewModel.resetMintPing()
|
||||
viewModel.publishMints(mints.toList())
|
||||
}
|
||||
},
|
||||
enabled = mintInput.isNotBlank(),
|
||||
@@ -340,6 +328,7 @@ fun AddCashuWalletScreen(
|
||||
if (trimmed.isNotEmpty() && trimmed !in mints) {
|
||||
mints.add(trimmed)
|
||||
viewModel.resetMintPing()
|
||||
viewModel.publishMints(mints.toList())
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -370,46 +359,29 @@ fun AddCashuWalletScreen(
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
// The nutzap (P2PK) key is only chosen at creation. In edit mode
|
||||
// keyMode stays KeepCurrent so saving never rotates the key — the
|
||||
// (rare, destructive) rotation lives in the settings Danger Zone
|
||||
// instead, so editing mints can't orphan inbound nutzaps.
|
||||
if (!isEditMode) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = stringRes(R.string.cashu_p2pk_section),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringRes(R.string.cashu_p2pk_explainer),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// No Save button: the wallet's kind:17375 + kind:10019 are
|
||||
// published the moment the first mint is added and re-published on
|
||||
// every later add/remove (see CashuWalletViewModel.publishMints).
|
||||
// This line tells the user the screen auto-saves and that the
|
||||
// nutzap key is generated for them — the old explicit key picker is
|
||||
// gone; advanced key rotation lives in the settings Danger Zone.
|
||||
Text(
|
||||
text = stringRes(R.string.cashu_wallet_autosaves),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
if (createState is CashuWalletCreateState.Saving) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
P2pkRadio(
|
||||
label = stringRes(R.string.cashu_p2pk_autogen),
|
||||
selected = keyMode == CashuWalletViewModel.P2pkKeyMode.AutoGenerate,
|
||||
onSelect = { keyMode = CashuWalletViewModel.P2pkKeyMode.AutoGenerate },
|
||||
)
|
||||
P2pkRadio(
|
||||
label = stringRes(R.string.cashu_p2pk_manual_label),
|
||||
selected = keyMode == CashuWalletViewModel.P2pkKeyMode.Manual,
|
||||
onSelect = { keyMode = CashuWalletViewModel.P2pkKeyMode.Manual },
|
||||
)
|
||||
|
||||
if (keyMode == CashuWalletViewModel.P2pkKeyMode.Manual) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = manualPrivkey,
|
||||
onValueChange = { manualPrivkey = it },
|
||||
label = { Text(stringRes(R.string.cashu_p2pk_manual_label)) },
|
||||
placeholder = { Text("hex…") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringRes(R.string.cashu_wallet_saving),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -423,57 +395,10 @@ fun AddCashuWalletScreen(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.saveWallet(
|
||||
mints = mints.toList(),
|
||||
keyMode = keyMode,
|
||||
manualPrivkey = manualPrivkey.takeIf { keyMode == CashuWalletViewModel.P2pkKeyMode.Manual },
|
||||
)
|
||||
},
|
||||
enabled =
|
||||
mints.isNotEmpty() &&
|
||||
createState !is CashuWalletCreateState.Saving &&
|
||||
(keyMode != CashuWalletViewModel.P2pkKeyMode.Manual || manualPrivkey.isNotBlank()),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
stringRes(
|
||||
if (isEditMode) R.string.wallet_save_changes else R.string.wallet_save,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun P2pkRadio(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
onSelect: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(selected = selected, onClick = onSelect)
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(selected = selected, onClick = onSelect)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint directory autocomplete rendered inline under the mint input.
|
||||
* Each row uses [MintDirectoryRow] so the user sees follower avatars
|
||||
|
||||
+59
-59
@@ -39,14 +39,14 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
sealed class CashuWalletCreateState {
|
||||
data object Idle : CashuWalletCreateState()
|
||||
|
||||
data object Saving : CashuWalletCreateState()
|
||||
|
||||
data object Success : CashuWalletCreateState()
|
||||
|
||||
data class Error(
|
||||
val message: String,
|
||||
) : CashuWalletCreateState()
|
||||
@@ -448,70 +448,70 @@ class CashuWalletViewModel : ViewModel() {
|
||||
_restoreState.value = RestoreFlowState.Idle
|
||||
}
|
||||
|
||||
/** What to do with the wallet's P2PK key during a save. */
|
||||
enum class P2pkKeyMode {
|
||||
/** Keep the existing key from the on-cache wallet (edit only). */
|
||||
KeepCurrent,
|
||||
/**
|
||||
* Serialises [publishMints] so two near-simultaneous mint adds can't both
|
||||
* reach [CashuWalletOps.publishWalletEvents] before the first has cached
|
||||
* its key. Without it, the second add — firing before the freshly created
|
||||
* kind:17375 round-trips back into `_walletEvent` — would read a null key
|
||||
* and generate a *second* one, rotating the P2PK key and orphaning any
|
||||
* inbound nutzaps locked to the first.
|
||||
*/
|
||||
private val publishMutex = Mutex()
|
||||
|
||||
/** Generate a fresh random key — invalidates inbound nutzaps locked to the old one. */
|
||||
AutoGenerate,
|
||||
/**
|
||||
* The P2PK key in use for the wallet currently being edited on screen.
|
||||
* Set on the first successful publish (inside [publishMutex]); reused by
|
||||
* every later add/remove so the nutzap key stays put across changes.
|
||||
*/
|
||||
private var sessionPrivkeyHex: String? = null
|
||||
|
||||
/** Use a user-pasted hex key. */
|
||||
Manual,
|
||||
}
|
||||
|
||||
fun saveWallet(
|
||||
mints: List<String>,
|
||||
keyMode: P2pkKeyMode,
|
||||
manualPrivkey: String? = null,
|
||||
) {
|
||||
/**
|
||||
* Publish (or re-publish) the wallet's kind:17375 + kind:10019 with the
|
||||
* given mint list. The Add/Edit screen has no Save button — the wallet is
|
||||
* created the instant the first mint is added and kept in sync on every
|
||||
* subsequent add/remove, so this runs on each list change.
|
||||
*
|
||||
* Key handling, in priority order:
|
||||
* 1. [sessionPrivkeyHex] — the key from a publish we already did this
|
||||
* session (covers rapid successive adds before the event round-trips).
|
||||
* 2. [CashuWalletState.exportP2pkPrivkeyHex] — the existing wallet's key
|
||||
* when editing a wallet that was already on cache when the screen
|
||||
* opened.
|
||||
* 3. null → [CashuWalletOps.publishWalletEvents] generates a fresh key
|
||||
* (a genuinely new wallet).
|
||||
*
|
||||
* An empty list is a no-op: a NIP-60 wallet must advertise at least one
|
||||
* mint, so removing the last mint leaves the previously published config
|
||||
* untouched rather than publishing an empty, unusable wallet.
|
||||
*/
|
||||
fun publishMints(mints: List<String>) {
|
||||
val vm = accountViewModel ?: return
|
||||
val acc = account ?: return
|
||||
|
||||
if (mints.isEmpty()) {
|
||||
_createState.value = CashuWalletCreateState.Error("Add at least one mint")
|
||||
return
|
||||
}
|
||||
if (keyMode == P2pkKeyMode.Manual && manualPrivkey.isNullOrBlank()) {
|
||||
_createState.value = CashuWalletCreateState.Error("Paste a P2PK private key")
|
||||
return
|
||||
}
|
||||
if (mints.isEmpty()) return
|
||||
|
||||
_createState.value = CashuWalletCreateState.Saving
|
||||
vm.launchSigner {
|
||||
try {
|
||||
val privkey: String? =
|
||||
when (keyMode) {
|
||||
P2pkKeyMode.KeepCurrent -> {
|
||||
val existing = state.exportP2pkPrivkeyHex()
|
||||
if (existing == null) {
|
||||
_createState.value =
|
||||
CashuWalletCreateState.Error(
|
||||
"Could not read the existing wallet key. " +
|
||||
"Use auto-generate or paste a key instead.",
|
||||
)
|
||||
return@launchSigner
|
||||
}
|
||||
existing
|
||||
}
|
||||
P2pkKeyMode.AutoGenerate -> null // ops generates one
|
||||
P2pkKeyMode.Manual -> manualPrivkey?.trim()
|
||||
}
|
||||
ops.publishWalletEvents(
|
||||
mints = mints,
|
||||
p2pkPrivkeyHex = privkey,
|
||||
// Advertise our NIP-65 inbox relays as the nutzap relays,
|
||||
// so senders publish kind:9321 where we read incoming
|
||||
// events (NIP-65 outbox model). The kind:10019 default
|
||||
// copies the inbox relay list, not outbox; the wallet
|
||||
// still subscribes to a wider inbox + DM + tags set.
|
||||
nutzapRelays =
|
||||
acc.notificationRelays.flow.value
|
||||
.toList(),
|
||||
)
|
||||
_createState.value = CashuWalletCreateState.Success
|
||||
} catch (e: Exception) {
|
||||
_createState.value = CashuWalletCreateState.Error(describeMintError(e))
|
||||
publishMutex.withLock {
|
||||
try {
|
||||
val privkey = sessionPrivkeyHex ?: state.exportP2pkPrivkeyHex()
|
||||
val created =
|
||||
ops.publishWalletEvents(
|
||||
mints = mints,
|
||||
p2pkPrivkeyHex = privkey,
|
||||
// Advertise our NIP-65 inbox relays as the nutzap relays,
|
||||
// so senders publish kind:9321 where we read incoming
|
||||
// events (NIP-65 outbox model). The kind:10019 default
|
||||
// copies the inbox relay list, not outbox; the wallet
|
||||
// still subscribes to a wider inbox + DM + tags set.
|
||||
nutzapRelays =
|
||||
acc.notificationRelays.flow.value
|
||||
.toList(),
|
||||
)
|
||||
sessionPrivkeyHex = created.p2pkPrivkeyHex
|
||||
_createState.value = CashuWalletCreateState.Idle
|
||||
} catch (e: Exception) {
|
||||
_createState.value = CashuWalletCreateState.Error(describeMintError(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2014,6 +2014,8 @@
|
||||
<string name="cashu_remove_mint">Remove mint</string>
|
||||
<string name="cashu_add_mint">Add mint</string>
|
||||
<string name="cashu_history">History</string>
|
||||
<string name="cashu_wallet_autosaves">Your wallet saves automatically as you add or remove mints. A nutzap key is created for you the first time you add a mint.</string>
|
||||
<string name="cashu_wallet_saving">Saving…</string>
|
||||
<string name="cashu_p2pk_section">Nutzap key (advanced)</string>
|
||||
<string name="cashu_p2pk_explainer">A separate private key used only to receive NIP-61 nutzaps. Not your Nostr identity key.</string>
|
||||
<string name="cashu_p2pk_autogen">Generate a new key</string>
|
||||
|
||||
Reference in New Issue
Block a user