mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 16:57:39 +00:00
feat: unpack zap amount presets directly in the chat long-press sheet
Instead of a single zap icon that opens the amount popup, the sheet now embeds the rail-aware amount chip grid itself (same chips as the popup: lightning/cashu/on-chain per amount, reload-mint, preset editor). Lightning and cashu zaps fire immediately and close the sheet; on-chain amounts swap the sheet for the OnchainZapSendDialog it hosts. Extracted from ReactionsRow for reuse: ZapAmountChoiceGrid (the chip grid without the popup card) and observeZapRailCapability (the async rail availability computation). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
This commit is contained in:
@@ -2025,6 +2025,42 @@ fun ZapAmountChoicePopup(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The recipient/sender rail availability (lightning, cashu, on-chain) for zapping
|
||||
* [baseNote], recomputed as the async inputs (recipient kind:0 / kind:10019, the
|
||||
* local cashu wallet) load. Each observe call also subscribes the relay fetch, so
|
||||
* missing inputs are pulled in while the caller is on screen.
|
||||
*/
|
||||
@Composable
|
||||
fun observeZapRailCapability(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
onchainSupported: Boolean,
|
||||
): RailCapability {
|
||||
val cashuState = accountViewModel.account.cashuWalletState
|
||||
val author = baseNote.author
|
||||
// These four are deliberately read only to drive the recompute below — do NOT
|
||||
// delete them as "unused". Each observe* call ALSO subscribes the relay fetch
|
||||
// (so a not-yet-seen kind:0 / kind:10019 gets pulled in while the popup is
|
||||
// open), and each value is a remember() key so railCapability recomputes when
|
||||
// it arrives. RailCapabilityResolver.peek re-reads everything itself; these
|
||||
// just say *when* to re-run it.
|
||||
val cashuMints by cashuState.mints.collectAsStateWithLifecycle()
|
||||
val cashuEntries by cashuState.tokenEntries.collectAsStateWithLifecycle()
|
||||
val recipientInfo = author?.let { observeUserInfo(it, accountViewModel).value }
|
||||
val nutzapInfo = author?.let { observeNoteEvent<NutzapInfoEvent>(it.nutzapInfoNote, accountViewModel).value }
|
||||
// Honors the user's "show on-chain wallet" preference: off hides the on-chain
|
||||
// rail from the zap chips too, matching the wallet screen, profile chips, and
|
||||
// Send Payment screen.
|
||||
val showOnchainWallet by accountViewModel.settings.uiSettingsFlow.showOnchainWallet
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
return remember(baseNote, onchainSupported, showOnchainWallet, cashuMints, cashuEntries, recipientInfo, nutzapInfo) {
|
||||
val rc = RailCapabilityResolver.peek(baseNote, cashuState)
|
||||
if (onchainSupported && showOnchainWallet) rc else rc.copy(hasOnchain = false)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ZapAmountChoicePopup(
|
||||
baseNote: Note,
|
||||
@@ -2055,29 +2091,7 @@ fun ZapAmountChoicePopup(
|
||||
// also trigger the relay fetch, so a not-yet-seen lnAddress / kind:10019 is
|
||||
// pulled in while the popup is open. A caller that can't drive the on-chain
|
||||
// dialog (onchainSupported == false) masks that rail off here.
|
||||
val cashuState = accountViewModel.account.cashuWalletState
|
||||
val author = baseNote.author
|
||||
// These four are deliberately read only to drive the recompute below — do NOT
|
||||
// delete them as "unused". Each observe* call ALSO subscribes the relay fetch
|
||||
// (so a not-yet-seen kind:0 / kind:10019 gets pulled in while the popup is
|
||||
// open), and each value is a remember() key so railCapability recomputes when
|
||||
// it arrives. RailCapabilityResolver.peek re-reads everything itself; these
|
||||
// just say *when* to re-run it.
|
||||
val cashuMints by cashuState.mints.collectAsStateWithLifecycle()
|
||||
val cashuEntries by cashuState.tokenEntries.collectAsStateWithLifecycle()
|
||||
val recipientInfo = author?.let { observeUserInfo(it, accountViewModel).value }
|
||||
val nutzapInfo = author?.let { observeNoteEvent<NutzapInfoEvent>(it.nutzapInfoNote, accountViewModel).value }
|
||||
// Honors the user's "show on-chain wallet" preference: off hides the on-chain
|
||||
// rail from the zap chips too, matching the wallet screen, profile chips, and
|
||||
// Send Payment screen.
|
||||
val showOnchainWallet by accountViewModel.settings.uiSettingsFlow.showOnchainWallet
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
val railCapability =
|
||||
remember(baseNote, onchainSupported, showOnchainWallet, cashuMints, cashuEntries, recipientInfo, nutzapInfo) {
|
||||
val rc = RailCapabilityResolver.peek(baseNote, cashuState)
|
||||
if (onchainSupported && showOnchainWallet) rc else rc.copy(hasOnchain = false)
|
||||
}
|
||||
val railCapability = observeZapRailCapability(baseNote, accountViewModel, onchainSupported)
|
||||
val amountChoices =
|
||||
remember(zapAmountChoices) {
|
||||
// Keep the user's saved order (already de-duped at the settings
|
||||
@@ -2197,39 +2211,66 @@ fun ZapAmountChoicePopupContent(
|
||||
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 8.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = Modifier.padding(horizontal = 5.dp, vertical = 5.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
itemVerticalAlignment = CenterVertically,
|
||||
) {
|
||||
amountChoices.forEach { amountInSats ->
|
||||
UnifiedZapAmountChip(
|
||||
amountInSats = amountInSats,
|
||||
railCapability = railCapability,
|
||||
onLightningZap = onLightningZap,
|
||||
onNutzap = onNutzap,
|
||||
onOnchainAmount = onOnchainAmount,
|
||||
onReloadNutzap = onReloadNutzap,
|
||||
onChangeAmount = onChangeAmount,
|
||||
)
|
||||
}
|
||||
ClickableBox(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(horizontal = 4.dp, vertical = 6.dp)
|
||||
.size(32.dp)
|
||||
.padding(7.dp),
|
||||
onClick = onChangeAmount,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Tune,
|
||||
contentDescription = stringRes(R.string.quick_zap_amounts),
|
||||
modifier = Size18Modifier,
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
}
|
||||
}
|
||||
ZapAmountChoiceGrid(
|
||||
amountChoices = amountChoices,
|
||||
railCapability = railCapability,
|
||||
onLightningZap = onLightningZap,
|
||||
onNutzap = onNutzap,
|
||||
onOnchainAmount = onOnchainAmount,
|
||||
onChangeAmount = onChangeAmount,
|
||||
onReloadNutzap = onReloadNutzap,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The bare zap-amount chip grid (one rail-aware pill per preset amount plus the
|
||||
* preset editor button), without the popup card — embeddable in any surface, e.g.
|
||||
* the chat long-press sheet.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun ZapAmountChoiceGrid(
|
||||
amountChoices: ImmutableList<Long>,
|
||||
railCapability: RailCapability,
|
||||
onLightningZap: (Long) -> Unit,
|
||||
onNutzap: (Long) -> Unit,
|
||||
onOnchainAmount: (Long?) -> Unit,
|
||||
onChangeAmount: () -> Unit,
|
||||
onReloadNutzap: (Long) -> Unit = {},
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = Modifier.padding(horizontal = 5.dp, vertical = 5.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
itemVerticalAlignment = CenterVertically,
|
||||
) {
|
||||
amountChoices.forEach { amountInSats ->
|
||||
UnifiedZapAmountChip(
|
||||
amountInSats = amountInSats,
|
||||
railCapability = railCapability,
|
||||
onLightningZap = onLightningZap,
|
||||
onNutzap = onNutzap,
|
||||
onOnchainAmount = onOnchainAmount,
|
||||
onReloadNutzap = onReloadNutzap,
|
||||
onChangeAmount = onChangeAmount,
|
||||
)
|
||||
}
|
||||
ClickableBox(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(horizontal = 4.dp, vertical = 6.dp)
|
||||
.size(32.dp)
|
||||
.padding(7.dp),
|
||||
onClick = onChangeAmount,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Tune,
|
||||
contentDescription = stringRes(R.string.quick_zap_amounts),
|
||||
modifier = Size18Modifier,
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+142
-20
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -38,6 +37,7 @@ import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -46,6 +46,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -54,29 +55,47 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableBox
|
||||
import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorMessage
|
||||
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.ChangeReactionIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.RenderReaction
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapReaction
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapAmountChoiceGrid
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.NoteDropDownMenu
|
||||
import com.vitorpamplona.amethyst.ui.note.observeZapRailCapability
|
||||
import com.vitorpamplona.amethyst.ui.note.payViaIntent
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainZapSendDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.navigateToReloadMint
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size27dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size28Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.reactionBox
|
||||
import com.vitorpamplona.amethyst.ui.theme.selectedReactionBoxModifier
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
// null amount = open the on-chain dialog with no prefill.
|
||||
@Immutable
|
||||
private data class OnchainZapRequest(
|
||||
val amountSats: Long?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Long-press surface for a chat message: a quick-reaction row on top, the common
|
||||
* chat actions (reply, copy) as rows, and a handoff to the full note options menu
|
||||
* (the same 3-dot menu posts get) for everything else.
|
||||
* Long-press surface for a chat message: a quick-reaction row and the unpacked
|
||||
* zap amount presets on top, the common chat actions (reply, copy) as rows, and a
|
||||
* handoff to the full note options menu (the same 3-dot menu posts get) for
|
||||
* everything else.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -90,6 +109,22 @@ fun ChatMessageActionSheet(
|
||||
) {
|
||||
var showMoreOptions by remember { mutableStateOf(false) }
|
||||
|
||||
// On-chain zaps need a dialog that outlives the sheet, so the request swaps
|
||||
// the sheet for the dialog (same pattern as "More options" below).
|
||||
var onchainZapRequest by remember { mutableStateOf<OnchainZapRequest?>(null) }
|
||||
|
||||
onchainZapRequest?.let { request ->
|
||||
val zappedEventHint = remember(note) { note.toEventHint<Event>() }
|
||||
OnchainZapSendDialog(
|
||||
accountViewModel = accountViewModel,
|
||||
onDismiss = onDismiss,
|
||||
recipientPubKey = note.author?.pubkeyHex,
|
||||
zappedEvent = zappedEventHint,
|
||||
prefillAmountSats = request.amountSats,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// "More options" swaps this sheet for the full note menu instead of stacking
|
||||
// the two surfaces; dismissing the menu closes the whole interaction.
|
||||
if (showMoreOptions) {
|
||||
@@ -109,6 +144,14 @@ fun ChatMessageActionSheet(
|
||||
if (!note.isDraft()) {
|
||||
QuickReactionRow(note, onDismiss, accountViewModel, nav)
|
||||
|
||||
QuickZapAmountRow(
|
||||
note = note,
|
||||
onDismiss = onDismiss,
|
||||
onOnchainRequest = { onchainZapRequest = OnchainZapRequest(it) },
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
thickness = DividerThickness,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
@@ -181,20 +224,6 @@ private fun QuickReactionRow(
|
||||
}
|
||||
}
|
||||
|
||||
// The full zap flow (amount choices, custom amounts, progress, wallet
|
||||
// handoff) lives inside ZapReaction; its popups anchor above the icon.
|
||||
Box(modifier = reactionBox, contentAlignment = Alignment.Center) {
|
||||
ZapReaction(
|
||||
baseNote = note,
|
||||
grayTint = MaterialTheme.colorScheme.onBackground,
|
||||
accountViewModel = accountViewModel,
|
||||
iconSize = Size27dp,
|
||||
iconSizeModifier = Size28Modifier,
|
||||
showCounter = false,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
ClickableBox(
|
||||
modifier = reactionBox,
|
||||
onClick = {
|
||||
@@ -207,6 +236,99 @@ private fun QuickReactionRow(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's zap presets unpacked as rail-aware amount chips (same grid the zap
|
||||
* amount popup shows), firing directly from the sheet. Lightning/cashu zaps
|
||||
* dismiss immediately — errors surface as toasts and the receipt lands on the
|
||||
* bubble's sats chip; on-chain amounts hand off to the dialog hosted by the sheet.
|
||||
*/
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
@Composable
|
||||
private fun QuickZapAmountRow(
|
||||
note: Note,
|
||||
onDismiss: () -> Unit,
|
||||
onOnchainRequest: (Long?) -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val zapAmountChoices by
|
||||
accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
val amountChoices = remember(zapAmountChoices) { zapAmountChoices.distinct().toImmutableList() }
|
||||
if (amountChoices.isEmpty()) return
|
||||
|
||||
val railCapability =
|
||||
observeZapRailCapability(
|
||||
baseNote = note,
|
||||
accountViewModel = accountViewModel,
|
||||
// Onchain zap events are public and would e-tag the rumor id.
|
||||
onchainSupported = !note.isPrivateRumor(),
|
||||
)
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
val onError = { _: String, message: String, user: User? ->
|
||||
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, message, user)
|
||||
}
|
||||
|
||||
val onPayViaIntent = { payables: ImmutableList<ZapPaymentHandler.Payable> ->
|
||||
if (payables.size == 1) {
|
||||
val payable = payables.first()
|
||||
payViaIntent(payable.invoice, context, { }) { error ->
|
||||
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, UserBasedErrorMessage(error, payable.info.user))
|
||||
}
|
||||
} else {
|
||||
val uid = Uuid.random().toString()
|
||||
accountViewModel.tempManualPaymentCache.put(uid, payables)
|
||||
nav.nav(Route.ManualZapSplitPayment(uid))
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
ZapAmountChoiceGrid(
|
||||
amountChoices = amountChoices,
|
||||
railCapability = railCapability,
|
||||
onLightningZap = { amountInSats ->
|
||||
accountViewModel.zap(
|
||||
note,
|
||||
amountInSats * 1000,
|
||||
null,
|
||||
"",
|
||||
context,
|
||||
true,
|
||||
onError,
|
||||
{ },
|
||||
onPayViaIntent,
|
||||
)
|
||||
onDismiss()
|
||||
},
|
||||
onNutzap = { amountInSats ->
|
||||
accountViewModel.sendNutzap(
|
||||
baseNote = note,
|
||||
amountSats = amountInSats,
|
||||
message = "",
|
||||
onError = onError,
|
||||
onProgress = { },
|
||||
)
|
||||
onDismiss()
|
||||
},
|
||||
onOnchainAmount = onOnchainRequest,
|
||||
onReloadNutzap = { amount ->
|
||||
navigateToReloadMint(accountViewModel, nav, note, amount)
|
||||
onDismiss()
|
||||
},
|
||||
onChangeAmount = {
|
||||
nav.nav(Route.UpdateZapAmount())
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CopyTextActionRow(
|
||||
note: Note,
|
||||
|
||||
Reference in New Issue
Block a user