mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 00:37:41 +00:00
refactor(zap): merge on-chain amount presets into the single zap-amount list
The on-chain rail no longer has its own editable preset list. There is now one zap-amount list used by every rail; on-chain simply filters it by the shared MIN_ONCHAIN_ZAP_SATS floor (1000 sat), and the unified chip already gates each logo per amount. - Settings: drop the public onchainZapAmountChoices StateFlow and its editor section in the Update Zap Amount dialog. The serialized field is kept for backward/cross-client (NIP-78) compatibility and migration: on load the two saved lists are unioned into zapAmountChoices (mergeZapAmounts), and on save the on-chain-eligible subset is written back so older clients still get sensible on-chain presets. The round-trip is idempotent. - updateZapAmounts / changeOnchainZapAmounts lose the separate on-chain parameter throughout (Account, AccountViewModel, UpdateZapAmountViewModel). - OnchainZapSendDialog draws its quick presets from the single list filtered by the minimum. - MIN_ONCHAIN_ZAP_SATS is now a single shared constant in the model, replacing the copies previously private to OnchainZapSendDialog and ReactionsRow. - Remove the now-unused on-chain amount strings; clarify the zap-amounts explainer to note amounts apply to all rails. Adds ZapAmountMergeTest covering the union/dedup/idempotent-round-trip. https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
This commit is contained in:
@@ -652,14 +652,12 @@ class Account(
|
||||
|
||||
suspend fun updateZapAmounts(
|
||||
amountSet: List<Long>,
|
||||
onchainAmountSet: List<Long>,
|
||||
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
|
||||
|
||||
|
||||
@@ -291,15 +291,6 @@ class AccountSettings(
|
||||
return false
|
||||
}
|
||||
|
||||
fun changeOnchainZapAmounts(newAmounts: List<Long>): Boolean {
|
||||
if (syncedSettings.zaps.onchainZapAmountChoices.value != newAmounts) {
|
||||
syncedSettings.zaps.onchainZapAmountChoices.tryEmit(newAmounts.toImmutableList())
|
||||
saveAccountSettings()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun changeReactionTypes(newTypes: List<String>): Boolean {
|
||||
if (syncedSettings.reactions.reactionChoices.value != newTypes) {
|
||||
syncedSettings.reactions.reactionChoices.tryEmit(newTypes.toImmutableList())
|
||||
|
||||
@@ -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<ImmutableList<Long>>,
|
||||
var onchainZapAmountChoices: MutableStateFlow<ImmutableList<Long>>,
|
||||
val defaultZapType: MutableStateFlow<LnZapEvent.ZapType>,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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<Long> = (zaps.zapAmountChoices + zaps.onchainZapAmountChoices).distinct().sorted()
|
||||
|
||||
@Stable
|
||||
class AccountLanguagePreferences(
|
||||
var dontTranslateFrom: MutableStateFlow<Set<String>>,
|
||||
|
||||
+15
@@ -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<Long> = 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<Long> = DefaultOnchainZapAmounts,
|
||||
val defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Long?>(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
|
||||
|
||||
@@ -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<ZapPaymentHandler.Payable>) -> Unit,
|
||||
onchainZapAmountChoices: ImmutableList<Long> = 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
|
||||
|
||||
@@ -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),
|
||||
|
||||
+1
-20
@@ -41,8 +41,6 @@ class UpdateZapAmountViewModel : ViewModel() {
|
||||
|
||||
var nextAmount by mutableStateOf(TextFieldValue(""))
|
||||
var amountSet by mutableStateOf(listOf<Long>())
|
||||
var nextOnchainAmount by mutableStateOf(TextFieldValue(""))
|
||||
var onchainAmountSet by mutableStateOf(listOf<Long>())
|
||||
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 ?: "")
|
||||
|
||||
+1
-2
@@ -1297,10 +1297,9 @@ class AccountViewModel(
|
||||
|
||||
fun updateZapAmounts(
|
||||
amountSet: List<Long>,
|
||||
onchainAmountSet: List<Long>,
|
||||
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) }
|
||||
|
||||
|
||||
+5
-8
@@ -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
|
||||
|
||||
@@ -869,10 +869,7 @@
|
||||
<string name="wallet_connect_status_not_connected">Not connected</string>
|
||||
<string name="wallet_connect_manual_config">Advanced: enter connection details manually</string>
|
||||
<string name="quick_zap_amounts">Quick Zap Amounts</string>
|
||||
<string name="quick_zap_amounts_explainer">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.</string>
|
||||
<string name="quick_zap_amounts_onchain">Quick On-chain Zap Amounts</string>
|
||||
<string name="quick_zap_amounts_onchain_explainer">On-chain zaps pay miner fees, so amounts are usually larger than Lightning. Tap an amount to remove it.</string>
|
||||
<string name="new_amount_in_sats_onchain">New on-chain amount in sats</string>
|
||||
<string name="quick_zap_amounts_explainer">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.</string>
|
||||
<string name="send_onchain_instead">Send on-chain instead</string>
|
||||
<string name="zap_privacy_section">Zap Privacy</string>
|
||||
<string name="zap_type_section_explainer">Controls how your identity is shown when you send a zap.</string>
|
||||
|
||||
@@ -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<Long>,
|
||||
onchain: List<Long>,
|
||||
) = 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)))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user