mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
@@ -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) {
|
||||
|
||||
+14
-83
@@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+57
-2
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user