From ec3b41aa340e933c02ed68222af48464f03822b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 22:45:48 +0000 Subject: [PATCH] feat(podcasts): pay V4V splits through the zap button as real zaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The standard zap button now detects a Podcasting-2.0 value-for-value block on a podcast note (episode or show) and pays that split instead of a plain author zap — so V4V becomes a first-class zap rather than a separate, no- receipt payment: - AccountViewModel.zap() intercepts a note carrying a value block and routes the chosen amount to the V4V split. Intercepting at zap() covers every entry point (one-tap, amount popup, custom dialog, polls) with no UI churn. - V4VPaymentHandler gains asZap/zapType: lnaddress shares now attach a NIP-57 zap request, so a Nostr-aware provider mints a zappable invoice and publishes a receipt (driving the zap button's icon/counter). Node shares stay keysend — no LNURL, so a receipt is impossible there by protocol. - Per-minute streaming pays with asZap=false so it doesn't publish a receipt every minute. - The dedicated "Send value" button is now redundant and removed; PodcastValueSplits becomes a pure recipient/percentage breakdown display next to the zap button. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18 --- .../amethyst/service/V4VPaymentHandler.kt | 52 ++++++++-- .../amethyst/ui/note/types/PodcastEpisode.kt | 8 +- .../amethyst/ui/note/types/PodcastMetadata.kt | 8 +- .../ui/note/types/PodcastValueSplits.kt | 97 +++---------------- .../ui/screen/loggedIn/AccountViewModel.kt | 59 ++++++++++- amethyst/src/main/res/values/strings.xml | 4 +- 6 files changed, 118 insertions(+), 110 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt index 9a545e8aeb..6fa95cfe3f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt @@ -32,6 +32,8 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip47WalletConnect.rpc.TlvRecord +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlForm import com.vitorpamplona.quartz.podcasts.PodcastBoostagram import com.vitorpamplona.quartz.podcasts.PodcastValue import com.vitorpamplona.quartz.podcasts.PodcastValueShare @@ -45,17 +47,20 @@ import okhttp3.OkHttpClient * Executes a Podcasting-2.0 value-for-value (V4V) split: takes a [PodcastValue] block and a total * amount, computes each recipient's share ([PodcastValue.computeShares]) and pays them. * - * This is the V4V analogue of [ZapPaymentHandler], but the recipients are raw Lightning destinations - * declared in the value block (not Nostr users), so there is no zap request and no zap receipt. Two - * recipient kinds are handled: + * This is the V4V analogue of [ZapPaymentHandler]. The recipients are raw Lightning destinations + * declared in the value block, but the two kinds are paid very differently: * * - [PodcastValue.TYPE_LNADDRESS] — resolved to a BOLT-11 via LNURL-pay and paid through the user's * default payment source (NWC, CLINK debit, or — when none is set — handed to an external wallet - * via [onPayInvoicesViaIntent]). Same rails as a zap. + * via [onPayInvoicesViaIntent]). Same rails as a zap, and when `asZap` is set each share also + * carries a NIP-57 zap request, so a Nostr-aware lnaddress provider issues a real **zap receipt** + * (this is what lets the standard zap button drive a V4V split with its usual icon/counter UI). + * Per-minute streaming pays with `asZap = false` to avoid publishing a receipt every minute. * - [PodcastValue.TYPE_NODE] — paid by **keysend** (NIP-47 `pay_keysend`) carrying the Podcasting-2.0 - * boostagram TLV ([PodcastValue.PODCAST_TLV_RECORD]) plus any per-recipient custom TLV. Keysend is - * only available over NWC, so node recipients are skipped (with an error) when no NWC wallet is set - * up. + * boostagram TLV ([PodcastValue.PODCAST_TLV_RECORD]) plus any per-recipient custom TLV. There is no + * LNURL endpoint and no invoice, so keysend can never produce a zap receipt regardless of `asZap`. + * Keysend is only available over NWC, so node recipients are skipped (with an error) when no NWC + * wallet is set up. */ class V4VPaymentHandler( val account: Account, @@ -76,6 +81,8 @@ class V4VPaymentHandler( onError: (title: String, message: String) -> Unit, onProgress: (percent: Float) -> Unit, onPayInvoicesViaIntent: (invoices: List) -> Unit, + asZap: Boolean = false, + zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC, ) = withContext(Dispatchers.IO) { val shares = value.computeShares(totalMilliSats) if (shares.isEmpty()) { @@ -108,6 +115,9 @@ class V4VPaymentHandler( assembleInvoices( shares = lnAddressShares, message = boostagram.message.orEmpty(), + asZap = asZap, + zapType = zapType, + zappedNote = zappedNote, okHttpClient = okHttpClient, context = context, onError = onError, @@ -164,21 +174,47 @@ class V4VPaymentHandler( private suspend fun assembleInvoices( shares: List, message: String, + asZap: Boolean, + zapType: LnZapEvent.ZapType, + zappedNote: Note?, okHttpClient: (String) -> OkHttpClient, context: Context, onError: (String, String) -> Unit, onProgress: (percent: Float) -> Unit, ): List { + // When paying as a zap, attach a NIP-57 request to each share so the recipient's LNURL + // provider mints a zappable invoice and publishes a receipt. The receipt is attributed to + // the zapped note (toUser = null) since a value-block lnaddress is a raw payee, not + // necessarily a Nostr identity. Send to the show/episode author's inbox so they see it. + val noteEvent = zappedNote?.event + val authorRelays = zappedNote?.author?.inboxRelays()?.toSet() ?: emptySet() + var progress = 0f return mapNotNullAsync(shares) { share: PodcastValueShare -> val lnAddress = share.recipient.address ?: return@mapNotNullAsync null try { + val nostrRequest = + if (asZap && noteEvent != null) { + account.createZapRequestFor( + event = noteEvent, + pollOption = null, + message = message, + zapType = zapType, + toUser = null, + additionalRelays = authorRelays, + amountMillisats = share.amountMilliSats, + lnurl = LnurlForm.toUrl(lnAddress)?.let(LnurlForm::urlToBech32), + ) + } else { + null + } + val invoice = LightningAddressResolver().lnAddressInvoice( lnAddress = lnAddress, milliSats = share.amountMilliSats, message = message, - nostrRequest = null, + nostrRequest = nostrRequest, okHttpClient = okHttpClient, onProgress = {}, context = context, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt index a00834012c..a8a7f33572 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt @@ -195,13 +195,7 @@ fun RenderPodcastEpisode( } value?.takeIf { !makeItShort }?.let { - PodcastValueSplits( - value = it, - note = note, - episodeName = title, - podcastName = null, - accountViewModel = accountViewModel, - ) + PodcastValueSplits(value = it) } markdown?.takeIf { !makeItShort }?.let { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt index fb461c5907..6fa80e7a86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt @@ -176,13 +176,7 @@ fun RenderPodcastMetadata( } value?.takeIf { !makeItShort }?.let { - PodcastValueSplits( - value = it, - note = note, - episodeName = null, - podcastName = title, - accountViewModel = accountViewModel, - ) + PodcastValueSplits(value = it) } if (fundingUrls.isNotEmpty() && !makeItShort) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastValueSplits.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastValueSplits.kt index e25d8232c0..f698555e15 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastValueSplits.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastValueSplits.kt @@ -22,35 +22,24 @@ package com.vitorpamplona.amethyst.ui.note.types import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.grayText @@ -58,17 +47,16 @@ import com.vitorpamplona.quartz.podcasts.PodcastValue /** * Renders a Podcasting-2.0 value-for-value split as a tinted card: a "Value-for-Value" header, a - * "Send value" button (amount picker that fires the weighted Lightning split via - * [AccountViewModel.payV4V]), and one row per recipient (name/address + its share of the split). + * one-line hint that zaps to this item are split, and one row per recipient (name/address + its + * share of the split). + * + * This is a **breakdown display only** — there is no dedicated send button. Paying the split is the + * job of the standard zap button: when a podcast note carries a value block, [AccountViewModel.zap] + * detects it and fans the chosen amount out to these recipients (lnaddress shares as real zaps, node + * shares as keysend). Keeping a separate "Send value" button here would just duplicate that action. */ @Composable -fun PodcastValueSplits( - value: PodcastValue, - note: Note, - episodeName: String?, - podcastName: String?, - accountViewModel: AccountViewModel, -) { +fun PodcastValueSplits(value: PodcastValue) { val recipients = value.recipients.filter { it.split > 0 || it.address != null } if (recipients.isEmpty()) return @@ -101,9 +89,14 @@ fun PodcastValueSplits( color = MaterialTheme.colorScheme.primary, modifier = Modifier.weight(1f), ) - SendValueButton(value, note, episodeName, podcastName, accountViewModel) } + Text( + text = stringRes(R.string.podcast_value_zap_split_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + ) + recipients.forEach { recipient -> val label = recipient.name?.takeIf { it.isNotEmpty() } ?: recipient.address.orEmpty() val percent = recipient.split * 100 / total @@ -142,65 +135,3 @@ fun PodcastValueSplits( } } } - -/** - * "Send value" button: opens a dropdown of the account's configured zap amounts. Picking one fires - * the V4V split for that many sats through [AccountViewModel.payV4V] (which fans the weighted shares - * out to each recipient). The recipient list is fixed by the show/episode, so the only choice the - * user makes is the total amount. - */ -@Composable -private fun SendValueButton( - value: PodcastValue, - note: Note, - episodeName: String?, - podcastName: String?, - accountViewModel: AccountViewModel, -) { - val context = LocalContext.current - var expanded by remember { mutableStateOf(false) } - val choices = remember { accountViewModel.zapAmountChoices() } - - Box { - FilledTonalButton( - onClick = { expanded = true }, - enabled = choices.isNotEmpty(), - ) { - Icon( - symbol = MaterialSymbols.Bolt, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - Text( - text = stringRes(R.string.podcast_value_send), - modifier = Modifier.padding(start = 6.dp), - ) - } - - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - choices.forEach { sats -> - DropdownMenuItem( - text = { Text("$sats ${stringRes(R.string.sats)}") }, - onClick = { - expanded = false - accountViewModel.toastManager.toast( - R.string.podcast_value_for_value, - R.string.podcast_value_sending, - ) - accountViewModel.payV4V( - value = value, - totalSats = sats, - podcastName = podcastName, - episodeName = episodeName, - zappedNote = note, - context = context, - ) - }, - ) - } - } - } -} 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 5d21ad66bb..30239ac821 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 @@ -161,6 +161,8 @@ import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentD import com.vitorpamplona.quartz.nip92IMeta.imeta import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.podcasts.PodcastBoostagram +import com.vitorpamplona.quartz.podcasts.PodcastEpisode +import com.vitorpamplona.quartz.podcasts.PodcastShow import com.vitorpamplona.quartz.podcasts.PodcastValue import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log @@ -942,6 +944,27 @@ class AccountViewModel( onPayViaIntent: (ImmutableList) -> Unit, zapType: LnZapEvent.ZapType? = null, ) = launchSigner { + // A podcast note (episode or show) can carry a Podcasting-2.0 value-for-value block. When it + // does, "zapping" it means paying that split — lnaddress recipients go out as real zaps (with + // receipts, which drive this same button's icon/counter), node recipients go out as keysend. + // This makes the standard zap button the single payment action for V4V content. + val v4v = + (note.event as? PodcastEpisode)?.episodeValue() + ?: (note.event as? PodcastShow)?.showValue() + if (v4v != null && v4v.recipients.any { it.split > 0 && !it.address.isNullOrBlank() }) { + executeV4V( + value = v4v, + totalMilliSats = amountInMillisats, + podcastName = (note.event as? PodcastShow)?.showTitle(), + episodeName = (note.event as? PodcastEpisode)?.episodeTitle(), + zappedNote = note, + context = context, + streaming = false, + onProgress = onProgress, + ) + return@launchSigner + } + val requestedType = zapType ?: defaultZapType() // Zaps on private rumors are forced to PRIVATE so the sender and @@ -990,22 +1013,54 @@ class AccountViewModel( streaming: Boolean = false, onProgress: (Float) -> Unit = {}, ) = launchSigner { + executeV4V( + value = value, + totalMilliSats = totalSats * 1000, + podcastName = podcastName, + episodeName = episodeName, + zappedNote = zappedNote, + context = context, + streaming = streaming, + onProgress = onProgress, + ) + } + + /** + * Shared V4V execution used by both [payV4V] and the V4V reroute inside [zap]. Must be called + * from within a [launchSigner] block (it does signing). [streaming] = true marks per-minute + * payments: errors are swallowed (no per-minute toast spam), the external-wallet intent fallback + * is skipped (can't auto-launch a wallet every minute), and lnaddress shares are paid WITHOUT a + * zap request so streaming doesn't publish a receipt every minute. One-off boosts ([streaming] = + * false) pay lnaddress shares as real zaps, producing receipts that feed the zap button's UI. + */ + private suspend fun executeV4V( + value: PodcastValue, + totalMilliSats: Long, + podcastName: String?, + episodeName: String?, + zappedNote: Note?, + context: Context, + streaming: Boolean, + onProgress: (Float) -> Unit, + ) { val boostagram = PodcastBoostagram( podcast = podcastName, episode = episodeName, action = if (streaming) PodcastBoostagram.ACTION_STREAM else PodcastBoostagram.ACTION_BOOST, appName = "Amethyst", - valueMsatTotal = totalSats * 1000, + valueMsatTotal = totalMilliSats, senderName = account.userProfile().toBestDisplayName(), ) V4VPaymentHandler(account).pay( value = value, - totalMilliSats = totalSats * 1000, + totalMilliSats = totalMilliSats, boostagram = boostagram, zappedNote = zappedNote, context = context, + asZap = !streaming, + zapType = LnZapEvent.ZapType.PUBLIC, okHttpClient = httpClientBuilder::okHttpClientForMoney, onError = { title, message -> if (!streaming) toastManager.toast(title, message) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index cbe69fd8b5..704c10645a 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1010,13 +1010,11 @@ Chapters Value-for-Value %1$d%% + Zaps to this are split between: Host Co-host Editor Verified author - Send value - Sending value… - Value sent Value-for-Value error This podcast has no payable value recipients. Connect a Nostr Wallet Connect wallet to send to keysend (node) recipients.