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 8ffc1fcec7..e3fdaaa743 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 @@ -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(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(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, + 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, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt index a39106e4e8..1c8eaab052 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt @@ -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(null) } + + onchainZapRequest?.let { request -> + val zappedEventHint = remember(note) { note.toEventHint() } + 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 -> + 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,