feat(podcasts): pay V4V splits through the zap button as real zaps

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
This commit is contained in:
Claude
2026-06-30 22:45:48 +00:00
parent a65e37b8f5
commit ec3b41aa34
6 changed files with 118 additions and 110 deletions
@@ -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<String>) -> 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<PodcastValueShare>,
message: String,
asZap: Boolean,
zapType: LnZapEvent.ZapType,
zappedNote: Note?,
okHttpClient: (String) -> OkHttpClient,
context: Context,
onError: (String, String) -> Unit,
onProgress: (percent: Float) -> Unit,
): List<InvoicePayable> {
// 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,
@@ -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 {
@@ -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) {
@@ -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,
)
},
)
}
}
}
}
@@ -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<ZapPaymentHandler.Payable>) -> 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)
+1 -3
View File
@@ -1010,13 +1010,11 @@
<string name="podcast_chapters">Chapters</string>
<string name="podcast_value_for_value">Value-for-Value</string>
<string name="podcast_value_split_percent">%1$d%%</string>
<string name="podcast_value_zap_split_hint">Zaps to this are split between:</string>
<string name="podcast_role_host">Host</string>
<string name="podcast_role_cohost">Co-host</string>
<string name="podcast_role_editor">Editor</string>
<string name="podcast_author_verified">Verified author</string>
<string name="podcast_value_send">Send value</string>
<string name="podcast_value_sending">Sending value…</string>
<string name="podcast_value_sent">Value sent</string>
<string name="podcast_value_error_title">Value-for-Value error</string>
<string name="podcast_value_no_recipients">This podcast has no payable value recipients.</string>
<string name="podcast_value_keysend_requires_nwc">Connect a Nostr Wallet Connect wallet to send to keysend (node) recipients.</string>