diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 407cd48b97..af22c00ff3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -652,14 +652,12 @@ class Account( suspend fun updateZapAmounts( amountSet: List, - onchainAmountSet: List, selectedZapType: LnZapEvent.ZapType, nip47Update: Nip47WalletConnect.Nip47URINorm?, ) { var changed = false if (settings.changeZapAmounts(amountSet)) changed = true - if (settings.changeOnchainZapAmounts(onchainAmountSet)) changed = true if (settings.changeDefaultZapType(selectedZapType)) changed = true if (settings.changeZapPaymentRequest(nip47Update)) changed = true diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 7e53a66456..2f226fab7a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -291,15 +291,6 @@ class AccountSettings( return false } - fun changeOnchainZapAmounts(newAmounts: List): Boolean { - if (syncedSettings.zaps.onchainZapAmountChoices.value != newAmounts) { - syncedSettings.zaps.onchainZapAmountChoices.tryEmit(newAmounts.toImmutableList()) - saveAccountSettings() - return true - } - return false - } - fun changeReactionTypes(newTypes: List): Boolean { if (syncedSettings.reactions.reactionChoices.value != newTypes) { syncedSettings.reactions.reactionChoices.tryEmit(newTypes.toImmutableList()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt index c8fa71e767..e5f6367fe7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt @@ -39,8 +39,7 @@ class AccountSyncedSettings( ) val zaps = AccountZapPreferences( - MutableStateFlow(internalSettings.zaps.zapAmountChoices.toImmutableList()), - MutableStateFlow(internalSettings.zaps.onchainZapAmountChoices.toImmutableList()), + MutableStateFlow(mergeZapAmounts(internalSettings.zaps).toImmutableList()), MutableStateFlow(internalSettings.zaps.defaultZapType), ) val languages = @@ -70,7 +69,10 @@ class AccountSyncedSettings( zaps = AccountZapPreferencesInternal( zaps.zapAmountChoices.value, - zaps.onchainZapAmountChoices.value, + // Write the on-chain-eligible subset into the legacy field so + // older clients still get sensible on-chain presets. Unioning + // it back on load is idempotent (subset ⊆ full list). + zaps.zapAmountChoices.value.filter { it >= MIN_ONCHAIN_ZAP_SATS }, zaps.defaultZapType.value, ), languages = @@ -104,16 +106,11 @@ class AccountSyncedSettings( reactions.reactionRowItems.tryEmit(newReactionRowItems) } - val newZapChoices = syncedSettingsInternal.zaps.zapAmountChoices.toImmutableList() + val newZapChoices = mergeZapAmounts(syncedSettingsInternal.zaps).toImmutableList() if (!equalImmutableLists(zaps.zapAmountChoices.value, newZapChoices)) { zaps.zapAmountChoices.tryEmit(newZapChoices) } - val newOnchainZapChoices = syncedSettingsInternal.zaps.onchainZapAmountChoices.toImmutableList() - if (!equalImmutableLists(zaps.onchainZapAmountChoices.value, newOnchainZapChoices)) { - zaps.onchainZapAmountChoices.tryEmit(newOnchainZapChoices) - } - if (zaps.defaultZapType.value != syncedSettingsInternal.zaps.defaultZapType) { zaps.defaultZapType.tryEmit(syncedSettingsInternal.zaps.defaultZapType) } @@ -182,10 +179,18 @@ class AccountVideoPlayerPreferences( @Stable class AccountZapPreferences( var zapAmountChoices: MutableStateFlow>, - var onchainZapAmountChoices: MutableStateFlow>, val defaultZapType: MutableStateFlow, ) +/** + * Union the (historically separate) zap and on-chain preset lists into the + * single sorted set the app now uses everywhere. Preserves amounts customized + * on either side and amounts synced from an older client that still writes the + * legacy `onchainZapAmountChoices`. Idempotent: [AccountSyncedSettings.toInternal] + * re-derives the on-chain field as a subset of this list. + */ +internal fun mergeZapAmounts(zaps: AccountZapPreferencesInternal): List = (zaps.zapAmountChoices + zaps.onchainZapAmountChoices).distinct().sorted() + @Stable class AccountLanguagePreferences( var dontTranslateFrom: MutableStateFlow>, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt index 29ed96a2c0..8b8aa10b2b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt @@ -41,6 +41,16 @@ val DefaultZapAmounts = listOf(21L, 50L, 100L) val DefaultOnchainZapAmounts = listOf(5_000L) val DefaultReportWarningThreshold = 5 +/** + * Product floor for the on-chain rail — stricter than the protocol-level + * dust threshold (OnchainZapBuilder.DUST_THRESHOLD_SATS). The unified zap + * picker offers the on-chain logo only for amounts at or above this, and the + * on-chain send dialog draws its presets from the single zap-amount list + * filtered by it. Single source of truth for everywhere that gates on-chain + * by amount. + */ +const val MIN_ONCHAIN_ZAP_SATS = 1_000L + @Serializable enum class ReactionRowAction { Reply, @@ -154,6 +164,11 @@ class AccountReactionPreferencesInternal( @Serializable class AccountZapPreferencesInternal( var zapAmountChoices: List = DefaultZapAmounts, + // Legacy field: the on-chain rail no longer has its own editable preset + // list — amounts live in [zapAmountChoices] and on-chain just filters by + // [MIN_ONCHAIN_ZAP_SATS]. Kept (de)serialized so older clients still sync + // and so the on-chain-eligible subset round-trips back for them; on load it + // is unioned into [zapAmountChoices]. See AccountSyncedSettings. var onchainZapAmountChoices: List = DefaultOnchainZapAmounts, val defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt index 76b09c1316..ffc8de96d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt @@ -99,8 +99,8 @@ object RailCapabilityResolver { * derives the Taproot address from the recipient pubkey, so any nostr * pubkey is payable; lnAddress-only recipients are not (they have no * pubkey to tweak). The sender's onchain wallet availability is a - * *sender* concern — handled elsewhere by the popup gating the chip - * on `onchainZapAmountChoices` and the dialog on `LocalCache.onchainBackend`. + * *sender* concern — handled elsewhere by the chip gating on-chain by + * `MIN_ONCHAIN_ZAP_SATS` and the dialog on `LocalCache.onchainBackend`. */ fun peek( baseNote: Note, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ReusableZapButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ReusableZapButton.kt index 772948a63d..a5757930c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ReusableZapButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ReusableZapButton.kt @@ -44,7 +44,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @@ -110,10 +109,6 @@ fun ReusableZapButton( var onchainZapAmount by remember { mutableStateOf(null) } var showOnchainDialog by remember { mutableStateOf(false) } - val onchainZapAmountChoices by - accountViewModel.account.settings.syncedSettings.zaps.onchainZapAmountChoices - .collectAsStateWithLifecycle() - // Makes sure the user is loaded to get his ln address ahead of time (for DVM buttons) if (config.showUserFinderSubscription) { baseNote.author?.let { author -> @@ -201,7 +196,6 @@ fun ReusableZapButton( nav.nav(Route.ManualZapSplitPayment(uid)) } }, - onchainZapAmountChoices = onchainZapAmountChoices, onOnchainAmount = { amount -> wantsToZap = null onchainZapAmount = amount diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 35d1626c15..681b3f836f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -110,6 +110,7 @@ import com.vitorpamplona.amethyst.commons.hashtags.Cashu import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.MIN_ONCHAIN_ZAP_SATS import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.ReactionRowAction import com.vitorpamplona.amethyst.model.ReactionRowItem @@ -1888,9 +1889,6 @@ fun ZapAmountChoicePopup( val zapAmountChoices by accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices .collectAsStateWithLifecycle() - val onchainZapAmountChoices by - accountViewModel.account.settings.syncedSettings.zaps.onchainZapAmountChoices - .collectAsStateWithLifecycle() ZapAmountChoicePopup( baseNote = baseNote, @@ -1903,7 +1901,6 @@ fun ZapAmountChoicePopup( onError = onError, onProgress = onProgress, onPayViaIntent = onPayViaIntent, - onchainZapAmountChoices = onchainZapAmountChoices, onOnchainAmount = onOnchainAmount ?: {}, onchainSupported = onOnchainAmount != null, ) @@ -1921,15 +1918,14 @@ fun ZapAmountChoicePopup( onError: (title: String, text: String, user: User?) -> Unit, onProgress: (percent: Float) -> Unit, onPayViaIntent: (ImmutableList) -> Unit, - onchainZapAmountChoices: ImmutableList = persistentListOf(), onOnchainAmount: (Long?) -> Unit = {}, onchainSupported: Boolean = true, ) { - // One chip per amount; the chip itself shows which rails can pay it. Merge - // the (historically separate) Lightning and on-chain amount presets into a - // single sorted set of choices. Rail availability per amount is decided in - // [UnifiedZapAmountChip] from [railCapability], so the lists no longer need - // to be pre-emptied per rail. + // One chip per amount; the chip itself shows which rails can pay it. + // [zapAmountChoices] is already the single merged+sorted preset list (the + // on-chain amounts were folded in at the settings layer), so rail + // availability per amount is decided in [UnifiedZapAmountChip] from + // [railCapability] rather than by keeping separate per-rail lists. // // [railCapability] gates which rails the recipient can receive on (plus the // sender's cashu balance, which is free to read). It's recomputed only when @@ -1942,8 +1938,8 @@ fun ZapAmountChoicePopup( if (onchainSupported) rc else rc.copy(hasOnchain = false) } val amountChoices = - remember(zapAmountChoices, onchainZapAmountChoices) { - (zapAmountChoices + onchainZapAmountChoices).distinct().sorted().toImmutableList() + remember(zapAmountChoices) { + zapAmountChoices.distinct().sorted().toImmutableList() } val visibilityState = rememberVisibilityState(onDismiss) ZapAmountChoicePopup( @@ -2087,14 +2083,6 @@ fun ZapAmountChoicePopupContent( } } -/** - * Floor for the on-chain rail. Mirrors `MIN_ONCHAIN_ZAP_SATS` in - * `OnchainZapSendDialog` (stricter than the protocol dust threshold); kept in - * sync by hand because that one is private to the dialog. Below this the - * on-chain logo is not offered for an amount. - */ -private const val MIN_ONCHAIN_ZAP_SATS = 1_000L - /** * One pill per amount, showing a tappable logo for every rail that can pay it: * - **Cashu** only when a single shared mint is already funded for the amount diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt index ca6750051e..c1cc450e08 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -339,96 +339,7 @@ fun UpdateZapAmountContent( ) } - // ── Section 3: Quick On-chain Zap Amounts ───────────────────────────── - - Text( - text = stringRes(R.string.quick_zap_amounts_onchain), - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.titleSmall, - modifier = SettingsCategorySpacingModifier, - ) - Text( - text = stringRes(R.string.quick_zap_amounts_onchain_explainer), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.padding(bottom = 6.dp), - ) - - FlowRow( - modifier = - Modifier - .fillMaxWidth() - .animateContentSize(animationSpec = spring(stiffness = Spring.StiffnessMediumLow)), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - postViewModel.onchainAmountSet.forEach { amountInSats -> - InputChip( - selected = false, - onClick = { postViewModel.removeOnchainAmount(amountInSats) }, - label = { - Text( - text = "₿ ${showAmount(amountInSats.toBigDecimal().setScale(1))}", - ) - }, - trailingIcon = { - Icon( - symbol = MaterialSymbols.Close, - contentDescription = stringRes(R.string.remove), - modifier = Modifier.size(InputChipDefaults.AvatarSize), - ) - }, - colors = - InputChipDefaults.inputChipColors( - containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), - labelColor = MaterialTheme.colorScheme.primary, - trailingIconColor = MaterialTheme.colorScheme.primary, - ), - ) - } - } - - Spacer(modifier = Modifier.height(6.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - OutlinedTextField( - label = { Text(text = stringRes(R.string.new_amount_in_sats_onchain)) }, - value = postViewModel.nextOnchainAmount, - onValueChange = { postViewModel.nextOnchainAmount = it }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.None, - keyboardType = KeyboardType.Number, - ), - placeholder = { - Text( - text = "5000, 25000, 100000", - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - trailingIcon = { - IconButton( - onClick = postViewModel::addOnchainAmount, - shape = ButtonBorder, - enabled = postViewModel.nextOnchainAmount.text.isNotBlank(), - ) { - Icon( - symbol = MaterialSymbols.AddCircle, - contentDescription = stringRes(R.string.add), - modifier = Size20Modifier, - ) - } - }, - singleLine = true, - modifier = Modifier.weight(1f), - ) - } - - // ── Section 4: Nostr Wallet Connect ─────────────────────────────────── + // ── Section 3: Nostr Wallet Connect ─────────────────────────────────── HorizontalDivider( modifier = Modifier.padding(vertical = 16.dp), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt index 963e6c2864..8d50017d9a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt @@ -41,8 +41,6 @@ class UpdateZapAmountViewModel : ViewModel() { var nextAmount by mutableStateOf(TextFieldValue("")) var amountSet by mutableStateOf(listOf()) - var nextOnchainAmount by mutableStateOf(TextFieldValue("")) - var onchainAmountSet by mutableStateOf(listOf()) var walletConnectRelay by mutableStateOf(TextFieldValue("")) var walletConnectPubkey by mutableStateOf(TextFieldValue("")) var walletConnectSecret by mutableStateOf(TextFieldValue("")) @@ -61,7 +59,6 @@ class UpdateZapAmountViewModel : ViewModel() { fun load() { this.amountSet = accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices.value - this.onchainAmountSet = accountViewModel.account.settings.syncedSettings.zaps.onchainZapAmountChoices.value this.selectedZapType = accountViewModel.account.settings.syncedSettings.zaps.defaultZapType.value val nip47 = accountViewModel.account.settings.defaultZapPaymentRequest() @@ -86,19 +83,6 @@ class UpdateZapAmountViewModel : ViewModel() { amountSet = amountSet - amount } - fun addOnchainAmount() { - val newValue = nextOnchainAmount.text.trim().toLongOrNull() - if (newValue != null) { - onchainAmountSet = onchainAmountSet + newValue - } - - nextOnchainAmount = TextFieldValue("") - } - - fun removeOnchainAmount(amount: Long) { - onchainAmountSet = onchainAmountSet - amount - } - fun sendPost() { accountViewModel.launchSigner { sendPostSuspend() @@ -132,15 +116,13 @@ class UpdateZapAmountViewModel : ViewModel() { null } - accountViewModel.account.updateZapAmounts(amountSet, onchainAmountSet, selectedZapType, nip47Update) + accountViewModel.account.updateZapAmounts(amountSet, selectedZapType, nip47Update) nextAmount = TextFieldValue("") - nextOnchainAmount = TextFieldValue("") } fun cancel() { nextAmount = TextFieldValue("") - nextOnchainAmount = TextFieldValue("") } fun hasChanged(): Boolean { @@ -148,7 +130,6 @@ class UpdateZapAmountViewModel : ViewModel() { return ( selectedZapType != accountViewModel.account.settings.syncedSettings.zaps.defaultZapType.value || amountSet != accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices.value || - onchainAmountSet != accountViewModel.account.settings.syncedSettings.zaps.onchainZapAmountChoices.value || walletConnectPubkey.text != (defaultUri?.pubKeyHex ?: "") || walletConnectRelay.text != (defaultUri?.relayUri?.url ?: "") || walletConnectSecret.text != (defaultUri?.secret ?: "") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 1c254fc462..3f034ebac0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1297,10 +1297,9 @@ class AccountViewModel( fun updateZapAmounts( amountSet: List, - onchainAmountSet: List, selectedZapType: LnZapEvent.ZapType, nip47Update: Nip47WalletConnect.Nip47URINorm?, - ) = launchSigner { account.updateZapAmounts(amountSet, onchainAmountSet, selectedZapType, nip47Update) } + ) = launchSigner { account.updateZapAmounts(amountSet, selectedZapType, nip47Update) } fun toggleDontTranslateFrom(languageCode: String) = launchSigner { account.toggleDontTranslateFrom(languageCode) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt index 000ca33990..3693ab853f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt @@ -74,6 +74,7 @@ import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage import com.vitorpamplona.amethyst.commons.onchain.OnchainZapShare import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSplitter import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.MIN_ONCHAIN_ZAP_SATS import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.components.namecoin.NamecoinResolutionRow import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav @@ -99,13 +100,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.text.NumberFormat -// UX floor for onchain zaps. Below this any on-chain transaction is dominated -// by miner fees — the recipient nets close to nothing even at low fee rates, -// so quietly funneling the user to a Lightning zap is the friendlier outcome. -// This is stricter than the protocol-level [OnchainZapBuilder.DUST_THRESHOLD_SATS] -// (330 sats), which only guards against creating outputs the network rejects. -private const val MIN_ONCHAIN_ZAP_SATS = 1_000L - private enum class FeeTier( val label: String, val etaLabel: String, @@ -213,8 +207,11 @@ fun OnchainZapSendDialog( } } - val presetAmounts by accountViewModel.account.settings.syncedSettings.zaps.onchainZapAmountChoices + // On-chain shares the single zap-amount list now; show only the entries + // that clear the on-chain minimum as quick presets. + val zapAmountChoices by accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices .collectAsStateWithLifecycle() + val presetAmounts = remember(zapAmountChoices) { zapAmountChoices.filter { it >= MIN_ONCHAIN_ZAP_SATS } } // Mirror the dropdown's NIP-05 / Namecoin (.bit) resolution so Send can // enable as soon as the typed name resolves, without forcing the user to diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 2df3e835a9..e8c5e56fb8 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -869,10 +869,7 @@ Not connected Advanced: enter connection details manually Quick Zap Amounts - Shown when pressing the zap button. Tap an amount to remove it. If you leave it empty, it will open the dialog to insert an amount every time. - Quick On-chain Zap Amounts - On-chain zaps pay miner fees, so amounts are usually larger than Lightning. Tap an amount to remove it. - New on-chain amount in sats + Shown when pressing the zap button. Each amount can be paid by any rail the recipient supports — Lightning, Cashu, or on-chain (on-chain only for larger amounts). Tap an amount to remove it. If you leave it empty, it will open the dialog to insert an amount every time. Send on-chain instead Zap Privacy Controls how your identity is shown when you send a zap. diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/ZapAmountMergeTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/ZapAmountMergeTest.kt new file mode 100644 index 0000000000..b0b07fa635 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/ZapAmountMergeTest.kt @@ -0,0 +1,71 @@ +/* + * 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.model + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * The on-chain rail's separate amount-preset list was folded into the single + * [AccountZapPreferencesInternal.zapAmountChoices]. These lock in that the + * migration union preserves amounts from both the legacy lists and round-trips + * idempotently (write the on-chain subset back, union it again → same set). + */ +class ZapAmountMergeTest { + private fun zaps( + zap: List, + onchain: List, + ) = AccountZapPreferencesInternal(zapAmountChoices = zap, onchainZapAmountChoices = onchain) + + @Test + fun unionsDefaultsSortedAndDeduped() { + assertEquals( + listOf(21L, 50L, 100L, 5_000L), + mergeZapAmounts(zaps(DefaultZapAmounts, DefaultOnchainZapAmounts)), + ) + } + + @Test + fun preservesCustomAmountsFromBothLists() { + // Simulates a sync from an older client that still split the two lists. + assertEquals( + listOf(10L, 21L, 100L, 1_000L, 7_777L), + mergeZapAmounts(zaps(zap = listOf(21L, 100L, 10L), onchain = listOf(7_777L, 1_000L))), + ) + } + + @Test + fun dedupesAcrossLists() { + assertEquals( + listOf(100L, 5_000L), + mergeZapAmounts(zaps(zap = listOf(100L, 5_000L), onchain = listOf(5_000L))), + ) + } + + @Test + fun roundTripIsIdempotent() { + val merged = mergeZapAmounts(zaps(DefaultZapAmounts, DefaultOnchainZapAmounts)) + // toInternal() writes the full list as zap and the on-chain-eligible + // subset as the legacy field; merging those again must not drift. + val onchainSubset = merged.filter { it >= MIN_ONCHAIN_ZAP_SATS } + assertEquals(merged, mergeZapAmounts(zaps(zap = merged, onchain = onchainSubset))) + } +}